0% found this document useful (0 votes)
3 views81 pages

Mastering Python OOP

The document is a comprehensive guide to mastering Object-Oriented Programming (OOP) in Python, covering fundamental concepts, class structures, and advanced topics such as metaclasses and design patterns. It emphasizes the importance of encapsulation, abstraction, inheritance, and polymorphism, while providing practical examples and best practices. The content is structured into chapters that progressively build on the principles of OOP, making it suitable for both beginners and experienced programmers.

Uploaded by

ejazkeet945
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
3 views81 pages

Mastering Python OOP

The document is a comprehensive guide to mastering Object-Oriented Programming (OOP) in Python, covering fundamental concepts, class structures, and advanced topics such as metaclasses and design patterns. It emphasizes the importance of encapsulation, abstraction, inheritance, and polymorphism, while providing practical examples and best practices. The content is structured into chapters that progressively build on the principles of OOP, making it suitable for both beginners and experienced programmers.

Uploaded by

ejazkeet945
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

Mastering Python OOP

A Complete, Detailed Reference with Working Code

From first principles to metaclasses, descriptors,


dataclasses, ABCs, and design patterns
Table of Contents
1. Why OOP? Core Concepts
2. Classes and Objects: The Basics
3. Attributes: Class vs Instance
4. Methods: Instance, Class, Static
5. The __init__ and Object Lifecycle
6. Encapsulation and Name Mangling
7. Properties and Computed Attributes
8. Inheritance
9. Multiple Inheritance and the MRO
10. super() in Depth
11. Polymorphism and Duck Typing
12. Abstract Base Classes
13. Protocols and Structural Typing
14. Dunder (Magic) Methods: The Full Tour
15. Operator Overloading
16. Composition vs Inheritance
17. __slots__ and Memory
18. Descriptors
19. Metaclasses
20. Dataclasses
21. NamedTuple and Enum
22. Context Managers
23. Iterators and Generators as Classes
24. Class Decorators
25. Copying, Pickling, Equality, Hashing
26. Exceptions as Classes
27. SOLID Principles
28. Design Patterns in Python
29. Testing OOP Code
30. Common Mistakes and Best Practices
31. Capstone Project
1. Why OOP? Core Concepts
Object-Oriented Programming organizes code around objects — bundles of data (state) and the functions that operate on that data
(behaviour). Instead of passing dictionaries of data into loose functions, you create types that know how to manage themselves.

The Four Pillars

Pillar Meaning Python mechanism

Encapsulation Bundle data + behaviour; hide internals Classes, _private convention, properties

Abstraction Expose a simple interface, hide complexity Public methods, ABCs, Protocols

Inheritance Reuse and specialize existing types class Child(Parent) , super()

Polymorphism Same interface, different behaviour Method overriding, duck typing

Procedural vs OOP — the same problem twice

# PROCEDURAL: data and behaviour are separate and can drift apart
account = {"owner": "Ali", "balance": 1000}

def deposit(acct, amount):


acct["balance"] += amount

def withdraw(acct, amount):


if amount > acct["balance"]:
raise ValueError("Insufficient funds")
acct["balance"] -= amount

deposit(account, 500)
account["balance"] = -99999 # Nothing stops this. Data is unprotected.

# OOP: the object guards its own invariants


class BankAccount:
def __init__(self, owner, balance=0):
[Link] = owner
self._balance = balance # underscore = "internal"

@property
def balance(self):
return self._balance # read-only from outside

def deposit(self, amount):


if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
return self._balance

def withdraw(self, amount):


if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
return self._balance

acct = BankAccount("Ali", 1000)


[Link](500)
print([Link]) # 1500
# [Link] = -99999 -> AttributeError: can't set attribute

Key idea: An object is responsible for keeping itself in a valid state. That is the single biggest practical win of OOP.

Everything in Python is an object

print(type(5)) #
print(type("hi")) #
print(type([1, 2])) #
print(type(print)) #
print(type(int)) # <- classes are objects too!
print(type(type)) # <- type is its own type

# Because functions are objects, they have attributes:


def greet(): pass
[Link] = "English"
print([Link]) # English
2. Classes and Objects: The Basics

Anatomy of a class

class Dog:
"""A simple Dog class.""" # docstring -> Dog.__doc__

species = "Canis familiaris" # CLASS attribute (shared by all dogs)

def __init__(self, name, age): # constructor-ish (initializer)


[Link] = name # INSTANCE attribute (unique per dog)
[Link] = age

def bark(self): # instance method


return f"{[Link]} says Woof!"

def __str__(self): # human-readable string


return f"Dog({[Link]}, {[Link]})"

def __repr__(self): # unambiguous, dev-facing string


return f"Dog(name={[Link]!r}, age={[Link]!r})"

d = Dog("Rex", 3)
print([Link]()) # Rex says Woof!
print(d) # Dog(Rex, 3) <- uses __str__
print(repr(d)) # Dog(name='Rex', age=3) <- uses __repr__
print([Link]) # Canis familiaris
print([Link]) # Canis familiaris
print(Dog.__doc__) # A simple Dog class.

What is self ?
self is simply the instance, passed automatically as the first argument. It is a convention, not a keyword — but never rename it.

d = Dog("Rex", 3)
[Link]() # Python translates this to...
[Link](d) # ...exactly this. Same result.

# Proof:
print([Link]) # -> plain function
print([Link]) # -> bound to d
print([Link].__self__ is d) # True

The instance __dict__

d = Dog("Rex", 3)
print(d.__dict__) # {'name': 'Rex', 'age': 3}
print(Dog.__dict__.keys())
# dict_keys(['__module__', '__doc__', 'species', '__init__', 'bark', ...])

[Link] = "Rexy" # attributes can be added at runtime


print(d.__dict__) # {'name': 'Rex', 'age': 3, 'nickname': 'Rexy'}

del [Link]
print(hasattr(d, "nickname")) # False

Attribute lookup order


When you write obj.x , Python searches in this order:

1. Data descriptors on type(obj) and its MRO (e.g. property )


2. obj.__dict__ (instance attributes)
3. Non-data descriptors and class attributes on type(obj) and its MRO
4. __getattr__ fallback if defined
5. Otherwise → AttributeError

class A:
x = "class value"

a = A()
print(a.x) # class value (found on the class)
a.x = "instance" # creates an entry in a.__dict__
print(a.x) # instance (instance shadows class)
print(A.x) # class value (class untouched)
del a.x
print(a.x) # class value (shadow removed)
Dynamic attribute access

d = Dog("Rex", 3)
print(getattr(d, "name")) # Rex
print(getattr(d, "colour", "n/a")) # n/a (default avoids AttributeError)
setattr(d, "colour", "brown")
print(hasattr(d, "colour")) # True
delattr(d, "colour")

# Useful for config-driven code:


for field in ["name", "age"]:
print(field, "=", getattr(d, field))
3. Attributes: Class vs Instance

The mutable class attribute trap

This is the #1 beginner bug in Python OOP.

class BadDog:
tricks = [] # SHARED list — one list for ALL dogs!

def __init__(self, name):


[Link] = name

def add_trick(self, trick):


[Link](trick) # mutates the SHARED list

a = BadDog("Rex")
b = BadDog("Buddy")
a.add_trick("roll over")
print([Link]) # ['roll over'] <-- BUG! Buddy learned it too.
print([Link] is [Link]) # True — literally the same object

class GoodDog:
def __init__(self, name):
[Link] = name
[Link] = [] # NEW list per instance

a = GoodDog("Rex")
b = GoodDog("Buddy")
[Link]("roll over")
print([Link]) # [] correct
print([Link] is [Link]) # False

Legitimate uses of class attributes

class Circle:
PI = 3.14159265 # constant shared by all
count = 0 # counter across all instances
registry = [] # deliberate shared state

def __init__(self, radius):


[Link] = radius
[Link] += 1 # NOTE: [Link], not [Link]
[Link](self)

def area(self):
return [Link] * [Link] ** 2

c1, c2 = Circle(1), Circle(2)


print([Link]) # 2
print(round([Link](), 2)) # 12.57

Why [Link] += 1 and not [Link] += 1 ?


[Link] += 1 expands to [Link] = [Link] + 1 . The right side reads the class attribute (2), but the assignment creates
an instance attribute, leaving the class counter stuck. Always use the class name for shared mutable counters.

class Broken:
count = 0
def __init__(self):
[Link] += 1 # WRONG

a, b, c = Broken(), Broken(), Broken()


print([Link]) # 0 <-- never incremented!
print([Link]) # 1 <-- each got its own
4. Methods: Instance, Class, Static

Type Decorator First arg Access Use for

Instance none self instance + class Normal behaviour

Class @classmethod cls class only Alt constructors, class state

Static @staticmethod none neither Related utility functions

from datetime import date

class Employee:
raise_pct = 1.05
count = 0

def __init__(self, first, last, pay):


[Link], [Link], [Link] = first, last, pay
[Link] += 1

# ---- INSTANCE METHOD: works with this specific object ----


def apply_raise(self):
[Link] = int([Link] * self.raise_pct)
return [Link]

@property
def email(self):
return f"{[Link]()}.{[Link]()}@[Link]"

# ---- CLASS METHOD: works with the class ----


@classmethod
def set_raise_pct(cls, amount):
cls.raise_pct = amount # modifies the class

@classmethod
def from_string(cls, s): # ALTERNATIVE CONSTRUCTOR
first, last, pay = [Link]("-")
return cls(first, last, int(pay)) # cls, not Employee -> subclass-safe!

@classmethod
def from_dict(cls, d):
return cls(d["first"], d["last"], d["pay"])

# ---- STATIC METHOD: just a namespaced function ----


@staticmethod
def is_workday(day):
return [Link]() not in (5, 6) # 5=Sat, 6=Sun

e1 = Employee("Ali", "Khan", 50000)


print([Link]) # [Link]@[Link]
print(e1.apply_raise()) # 52500

e2 = Employee.from_string("Sara-Ahmed-60000")
print([Link]) # [Link]@[Link]

Employee.set_raise_pct(1.10)
print(Employee.raise_pct) # 1.1

print(Employee.is_workday(date(2024, 1, 6))) # False (Saturday)

Why cls matters for alternative constructors

class Manager(Employee):
def __init__(self, first, last, pay, reports=None):
super().__init__(first, last, pay)
[Link] = reports or []

m = Manager.from_string("Zara-Ali-90000")
print(type(m)) # <-- correct subclass!
# If from_string had used `return Employee(...)`, we'd get an Employee. Bug.

Calling static/class methods from anywhere

print(Employee.is_workday([Link]())) # via class


print(e1.is_workday([Link]())) # via instance — also works
print(e1.from_string("A-B-1")) # classmethod via instance works too
# (cls is still Employee)
5. __init__ and the Object Lifecycle

__new__ vs __init__

__new__ creates the object; __init__ initializes it. You almost never need __new__ — except for immutable types and singletons.

class Demo:
def __new__(cls, *args, **kwargs):
print("1. __new__ called — creating instance")
instance = super().__new__(cls) # actually allocate
return instance # MUST return it

def __init__(self, value):


print("2. __init__ called — initializing")
[Link] = value

def __del__(self):
print("3. __del__ called — being destroyed")

d = Demo(42)
# 1. __new__ called — creating instance
# 2. __init__ called — initializing
del d
# 3. __del__ called — being destroyed

If __new__ returns an object that is not an instance of cls , __init__ is never called.

class Weird:
def __new__(cls):
return "I am a string" # not a Weird!
def __init__(self):
print("never runs")

print(Weird()) # I am a string — __init__ skipped

Subclassing an immutable type — the real use of __new__

class PositiveInt(int):
def __new__(cls, value):
if value <= 0:
raise ValueError("Must be positive")
return super().__new__(cls, value)
# __init__ can't help: int is immutable, value is fixed at creation

p = PositiveInt(5)
print(p + 10) # 15
print(type(p)) #
# PositiveInt(-3) # ValueError: Must be positive

Singleton via __new__

class Singleton:
_instance = None

def __new__(cls, *args, **kwargs):


if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __init__(self, value=None):


# CAREFUL: __init__ runs on EVERY call, even for the same instance
if not hasattr(self, "_ready"):
[Link] = value
self._ready = True

a = Singleton("first")
b = Singleton("second")
print(a is b) # True
print([Link]) # first (guard prevented re-init)

__del__ caveats

__del__ is not a destructor you can rely on. It runs when the refcount hits zero — which may be never (reference cycles), or at
interpreter shutdown in an unpredictable order. Use context managers for cleanup, not __del__ .
import weakref

class Resource:
def __init__(self, name):
[Link] = name
def close(self):
print(f"closing {[Link]}")

# BAD: relying on __del__


# GOOD: explicit context manager (see chapter 22)

# weakref: hold a reference without preventing garbage collection


r = Resource("db")
ref = [Link](r)
print(ref()) #
del r
print(ref()) # None — object was collected

Flexible initialization patterns

class Config:
def __init__(self, host="localhost", port=8080, **extras):
[Link] = host
[Link] = port
for key, value in [Link]():
setattr(self, key, value) # absorb arbitrary kwargs

def __repr__(self):
return f"Config({self.__dict__})"

c = Config(port=9000, debug=True, timeout=30)


print(c) # Config({'host': 'localhost', 'port': 9000, 'debug': True, 'timeout': 30})

Mutable default arguments: defaults are evaluated once, at function definition time.

class BadCart:
def __init__(self, items=[]): # SHARED across all instances!
[Link] = items

a, b = BadCart(), BadCart()
[Link]("apple")
print([Link]) # ['apple'] BUG

class GoodCart:
def __init__(self, items=None):
[Link] = items if items is not None else []

a, b = GoodCart(), GoodCart()
[Link]("apple")
print([Link]) # [] correct
6. Encapsulation and Name Mangling

Three levels of "privacy"

Name Meaning Enforced?

name Public API —

_name Internal; don't touch (convention) No — only excluded from import *

__name Name-mangled to _Class__name Partly — mangling, not security

__name__ Dunder — reserved by Python Never mangled

class Vault:
def __init__(self):
[Link] = "anyone"
self._internal = "please don't"
self.__secret = "mangled"

def reveal(self):
return self.__secret # works fine inside the class

v = Vault()
print([Link]) # anyone
print(v._internal) # please don't — nothing stops you
# print(v.__secret) # AttributeError!
print([Link]()) # mangled
print(v._Vault__secret) # mangled — mangling, not encryption
print([a for a in vars(v)]) # ['public', '_internal', '_Vault__secret']

Why name mangling exists: avoiding subclass collisions

class Base:
def __init__(self):
self.__value = "base value" # -> self._Base__value
def show_base(self):
return self.__value # -> self._Base__value

class Child(Base):
def __init__(self):
super().__init__()
self.__value = "child value" # -> self._Child__value (NO CLASH)
def show_child(self):
return self.__value

c = Child()
print(c.show_base()) # base value <- unaffected by the child
print(c.show_child()) # child value
print(vars(c)) # {'_Base__value': 'base value', '_Child__value': 'child value'}

# Contrast with single underscore — the child CLOBBERS the parent:


class Base2:
def __init__(self):
self._value = "base value"
def show_base(self):
return self._value

class Child2(Base2):
def __init__(self):
super().__init__()
self._value = "child value" # overwrites!

print(Child2().show_base()) # child value <-- parent logic broken

Rule of thumb: use a single underscore _x for "internal". Reserve double underscore __x for when you genuinely need to
protect an attribute from subclass name collisions — mainly in library base classes and mixins.

Controlling attribute access with dunders


class Strict:
def __init__(self):
self._data = {}

def __getattr__(self, name):


# ONLY called when normal lookup FAILS
print(f"__getattr__ fallback for {name!r}")
if name in self._data:
return self._data[name]
raise AttributeError(f"No attribute {name!r}")

def __setattr__(self, name, value):


# Called for EVERY assignment — easy to cause infinite recursion
if [Link]("_"):
super().__setattr__(name, value) # must use super()!
else:
print(f"__setattr__ storing {name!r}")
self._data[name] = value

def __delattr__(self, name):


print(f"__delattr__ {name!r}")
self._data.pop(name, None)

s = Strict()
[Link] = "red" # __setattr__ storing 'colour'
print([Link]) # __getattr__ fallback for 'colour' -> red
del [Link] # __delattr__ 'colour'

Infinite recursion trap: inside __setattr__ , writing self.x = v calls __setattr__ again forever. Always use
super().__setattr__(name, value) or self.__dict__[name] = value .

__getattribute__ — the nuclear option

class Logged:
def __init__(self):
self.x = 1

def __getattribute__(self, name):


# Called for EVERY attribute access, even successful ones
print(f"accessing {name!r}")
return super().__getattribute__(name) # NEVER [Link] here

l = Logged()
print(l.x)
# accessing 'x'
# 1

__getattr__ __getattribute__

When called Only when lookup fails Every single access

Cost Cheap Expensive; easy to break things

Use for Proxies, lazy attrs, defaults Rarely — deep interception


7. Properties and Computed Attributes
Properties let you start with a plain attribute and later add validation without changing any calling code. This is why Python has no
need for Java-style getters/setters everywhere.

Start simple

class Temp:
def __init__(self, celsius):
[Link] = celsius # plain attribute — perfectly fine!

t = Temp(25)
[Link] = 30 # no validation... yet

Add validation later — callers don't change

class Temperature:
def __init__(self, celsius=0):
[Link] = celsius # goes through the SETTER below

@property
def celsius(self):
"""Getter."""
return self._celsius

@[Link]
def celsius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Temperature must be numeric")
if value < -273.15:
raise ValueError("Below absolute zero!")
self._celsius = value

@[Link]
def celsius(self):
print("Resetting temperature")
self._celsius = 0

# ---- COMPUTED, read-only property ----


@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32

@[Link]
def fahrenheit(self, value):
[Link] = (value - 32) * 5 / 9 # reuses validation

@property
def kelvin(self):
return self._celsius + 273.15 # read-only: no setter

t = Temperature(25)
print([Link]) # 77.0
[Link] = 212
print([Link]) # 100.0
print([Link]) # 373.15
del [Link] # Resetting temperature
# [Link] = 300 # AttributeError: can't set attribute
# [Link] = -300 # ValueError: Below absolute zero!

Read-only computed attributes


class Rectangle:
def __init__(self, width, height):
[Link] = width
[Link] = height

@property
def area(self):
return [Link] * [Link] # always in sync

@property
def perimeter(self):
return 2 * ([Link] + [Link])

@property
def is_square(self):
return [Link] == [Link]

r = Rectangle(3, 4)
print([Link]) # 12
[Link] = 10
print([Link]) # 40 — recomputed automatically, never stale

Caching expensive properties

from functools import cached_property


import time

class DataSet:
def __init__(self, values):
[Link] = values

@property
def total(self):
print("computing total...")
return sum([Link]) # recomputed every access

@cached_property
def expensive_stat(self):
print("computing expensive stat...")
[Link](0.1)
return sum(x ** 2 for x in [Link])

d = DataSet([1, 2, 3, 4])
print([Link]); print([Link]) # computes TWICE
print(d.expensive_stat) # computes once
print(d.expensive_stat) # cached — instant
del d.expensive_stat # clear the cache
print(d.expensive_stat) # recomputes

cached_property stores the value in the instance __dict__ , so it needs a mutable __dict__ — it does not work with __slots__ . It is
also not thread-safe by default in 3.12+.

The manual way (what the decorator does)

class Manual:
def get_x(self):
return self._x
def set_x(self, value):
self._x = value
def del_x(self):
del self._x

x = property(get_x, set_x, del_x, "The x property.")

m = Manual()
m.x = 5
print(m.x) # 5
print(Manual.x.__doc__) # The x property.

Do not add properties preemptively. Use public attributes until you actually need validation, computation, or logging. Adding
a property later is a non-breaking change.
8. Inheritance

Basic inheritance and overriding

class Animal:
def __init__(self, name, sound="..."):
[Link] = name
[Link] = sound

def speak(self):
return f"{[Link]} says {[Link]}"

def describe(self):
return f"I am {[Link]}, an {type(self).__name__}"

class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, sound="Woof") # ALWAYS call the parent init
[Link] = breed

def speak(self): # OVERRIDE


return f"{[Link]} the {[Link]} says {[Link]}!"

def fetch(self): # EXTEND with new behaviour


return f"{[Link]} fetches the ball"

class Cat(Animal):
def __init__(self, name):
super().__init__(name, sound="Meow")

def speak(self):
base = super().speak() # reuse parent logic
return base + " (dismissively)"

d = Dog("Rex", "Husky")
c = Cat("Whiskers")
print([Link]()) # Rex the Husky says Woof!
print([Link]()) # Whiskers says Meow (dismissively)
print([Link]()) # I am Rex, an Dog <- inherited, but sees Dog
print([Link]())

Introspecting relationships

print(isinstance(d, Dog)) # True


print(isinstance(d, Animal)) # True — subclass instances count
print(isinstance(d, Cat)) # False
print(issubclass(Dog, Animal)) # True
print(issubclass(Dog, Dog)) # True — a class is its own subclass
print(Dog.__bases__) # (,)
print(Dog.__mro__) # (Dog, Animal, object)
print(Animal.__subclasses__()) # [, ]
print(type(d) is Dog) # True
print(type(d) is Animal) # False — type() is exact, isinstance is not

type(x) is C vs isinstance(x, C) : prefer isinstance . It respects inheritance (the Liskov principle). Use exact type() checks only
when you deliberately want to exclude subclasses.

Multi-level inheritance

class LivingThing:
def __init__(self, name):
[Link] = name
[Link] = True

class Animal2(LivingThing):
def __init__(self, name, legs):
super().__init__(name)
[Link] = legs

class Dog2(Animal2):
def __init__(self, name, breed):
super().__init__(name, legs=4)
[Link] = breed

d = Dog2("Rex", "Husky")
print([Link], [Link], [Link], [Link]) # Rex 4 Husky True
print([c.__name__ for c in Dog2.__mro__]) # ['Dog2', 'Animal2', 'LivingThing', 'object']

Forgetting super().__init__()
class Broken(Animal):
def __init__(self, name, breed):
[Link] = breed # forgot super().__init__(name)!

b = Broken("Rex", "Husky")
# print([Link]()) # AttributeError: 'Broken' object has no attribute 'name'

Overriding with a different signature — Liskov violation

# BAD: subclass demands MORE than the parent


class Bird(Animal):
def speak(self, volume): # parent's speak() takes no args!
return f"{[Link]} chirps at volume {volume}"

def make_all_speak(animals):
for a in animals:
print([Link]()) # breaks on Bird -> TypeError

# GOOD: subclass may accept MORE but must still work with the parent's call
class Bird2(Animal):
def __init__(self, name):
super().__init__(name, sound="Chirp")
def speak(self, volume=5): # optional arg -> still substitutable
return f"{[Link]} chirps at volume {volume}"

make_all_speak([Dog("Rex", "Husky"), Cat("Tom"), Bird2("Tweety")])

Liskov Substitution Principle: anywhere a Parent works, a Child must work too. Subclasses may weaken preconditions
(accept more) and strengthen postconditions (promise more) — never the reverse.

The classic square/rectangle problem

# This LOOKS right but violates LSP:


class Rect:
def __init__(self, w, h):
self.w, self.h = w, h
def set_width(self, w): self.w = w
def set_height(self, h): self.h = h
def area(self): return self.w * self.h

class Square(Rect): # "a square IS-A rectangle"... mathematically


def set_width(self, w): self.w = self.h = w
def set_height(self, h): self.w = self.h = h

def stretch(rect):
rect.set_width(5)
rect.set_height(4)
assert [Link]() == 20, f"Expected 20, got {[Link]()}"

stretch(Rect(1, 1)) # fine


# stretch(Square(1)) # AssertionError: Expected 20, got 16

# FIX: they are not substitutable. Use composition or a common base.


class Shape:
def area(self): raise NotImplementedError

class Rectangle3(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h

class Square3(Shape):
def __init__(self, side): [Link] = side
def area(self): return [Link] ** 2

Lesson: "IS-A" in the real world does not guarantee "IS-A" in code. The test is behavioural substitutability, not English grammar.
9. Multiple Inheritance and the MRO
Python allows a class to inherit from several parents. The Method Resolution Order (MRO) decides which method wins. It is
computed by the C3 linearization algorithm.

The diamond problem

# A
# / \
# B C
# \ /
# D

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

d = D()
print([Link]()) # B
print([c.__name__ for c in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']
print([Link]()) # same thing as a list

C3 linearization rules
The MRO satisfies three constraints simultaneously:

1. The class itself comes first.


2. Local precedence order: parents appear in the order written in the bases list.
3. Monotonicity: if X precedes Y in some ancestor's MRO, X precedes Y everywhere.

A class never appears before any of its subclasses, and every class appears exactly once.

# The algorithm: L[D] = D + merge(L[B], L[C], [B, C])


# L[B] = [B, A, object]
# L[C] = [C, A, object]
# merge([B,A,object], [C,A,object], [B,C]):
# take B (head of list 1, not in the tail of any other) -> D, B
# remaining: merge([A,object], [C,A,object], [C])
# A is head of list 1, but A appears in the TAIL of [C,A,object] -> SKIP
# take C (head of list 2, not in any tail) -> D, B, C
# remaining: merge([A,object], [A,object], [])
# take A -> D, B, C, A
# take object -> D, B, C, A, object

Impossible MROs raise TypeError

class X: pass
class Y: pass
class P(X, Y): pass
class Q(Y, X): pass
# class Z(P, Q): pass
# TypeError: Cannot create a consistent method resolution order (MRO)
# for bases X, Y
# Why? P demands X before Y; Q demands Y before X. Contradiction.

Cooperative multiple inheritance with super()


class Base:
def __init__(self, **kwargs):
print("Base.__init__")
super().__init__(**kwargs) # forwards to object — the chain end

class LoggerMixin(Base):
def __init__(self, log_level="INFO", **kwargs):
print(f"LoggerMixin.__init__ (level={log_level})")
self.log_level = log_level
super().__init__(**kwargs) # pass the rest along

class TimestampMixin(Base):
def __init__(self, tz="UTC", **kwargs):
print(f"TimestampMixin.__init__ (tz={tz})")
[Link] = tz
super().__init__(**kwargs)

class Service(LoggerMixin, TimestampMixin):


def __init__(self, name, **kwargs):
print(f"Service.__init__ ({name})")
[Link] = name
super().__init__(**kwargs)

s = Service("api", log_level="DEBUG", tz="PKT")


# Service.__init__ (api)
# LoggerMixin.__init__ (level=DEBUG)
# TimestampMixin.__init__ (tz=PKT)
# Base.__init__
print([c.__name__ for c in Service.__mro__])
# ['Service', 'LoggerMixin', 'TimestampMixin', 'Base', 'object']

The cooperative inheritance recipe:


Every __init__ in the chain accepts **kwargs .
Every __init__ calls super().__init__(**kwargs) .
Each class pops only the kwargs it owns.
A common base absorbs the rest so object.__init__ never gets extra args.

Mixins
A mixin adds one focused capability. It is not meant to be instantiated alone and usually has no __init__ state of its own.

import json

class JSONSerializableMixin:
"""Adds to_json / from_json. Assumes the host has a plain __dict__."""
def to_json(self, **kw):
return [Link](self.__dict__, default=str, **kw)

@classmethod
def from_json(cls, s):
return cls(**[Link](s))

class ComparableMixin:
"""Adds all comparisons given a _sort_key() method."""
def _sort_key(self):
raise NotImplementedError
def __eq__(self, other):
if not isinstance(other, ComparableMixin): return NotImplemented
return self._sort_key() == other._sort_key()
def __lt__(self, other):
if not isinstance(other, ComparableMixin): return NotImplemented
return self._sort_key() < other._sort_key()
def __hash__(self):
return hash(self._sort_key())

class ReprMixin:
def __repr__(self):
args = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{type(self).__name__}({args})"

class Product(JSONSerializableMixin, ComparableMixin, ReprMixin):


def __init__(self, name, price):
[Link] = name
[Link] = price
def _sort_key(self):
return [Link]

items = [Product("Pen", 50), Product("Book", 500), Product("Bag", 200)]


print(sorted(items))
# [Product(name='Pen', price=50), Product(name='Bag', price=200), Product(name='Book', price=500)]
print(items[0].to_json()) # {"name": "Pen", "price": 50}
print(Product.from_json('{"name": "Ink", "price": 90}'))

Mixin naming/ordering convention: name them SomethingMixin , and list them before the main base class: class C(Mixin1,
Mixin2, Base) . Mixins come first so their methods take precedence in the MRO.
10. super() in Depth

super() is not "the parent class"


This is the most misunderstood point in Python OOP. super() returns a proxy that looks at the next class in the MRO of
type(self) — which depends on the actual runtime object, not on where the code is written.

class A:
def hello(self): print("[Link]")

class B(A):
def hello(self):
print("[Link]")
super().hello() # "next after B in type(self).__mro__"

class C(A):
def hello(self):
print("[Link]")
super().hello()

class D(B, C):


def hello(self):
print("[Link]")
super().hello()

D().hello()
# [Link]
# [Link]
# [Link] <-- !! B's super() went to C, NOT to A
# [Link]
# MRO: D -> B -> C -> A -> object. After B comes C.

B().hello()
# [Link]
# [Link] <-- same B code, different next class. MRO: B -> A -> object

The same line of code ( super().hello() inside B) called A in one case and C in the other. This is why super() enables
cooperative multiple inheritance — and why hardcoding [Link](self) breaks it.

Why you must not hardcode the parent

class BBad(A):
def hello(self):
print("[Link]")
[Link](self) # HARDCODED — skips C entirely

class DBad(BBad, C):


def hello(self):
print("[Link]")
super().hello()

DBad().hello()
# [Link]
# [Link]
# [Link] <-- [Link] was SKIPPED. Cooperative chain broken.

The two-argument form


# super() is shorthand (Python 3) for
# super(CurrentClass, self)

class Base:
def greet(self): return "Base"
class Mid(Base):
def greet(self): return "Mid -> " + super().greet()
class Leaf(Mid):
def greet(self): return "Leaf -> " + super().greet()

l = Leaf()
print([Link]()) # Leaf -> Mid -> Base
print(super(Leaf, l).greet()) # Mid -> Base (start after Leaf)
print(super(Mid, l).greet()) # Base (start after Mid)

# super(Class, Class2) form -> unbound, for classmethods:


class P:
@classmethod
def create(cls): return f"[Link] for {cls.__name__}"
class Ch(P):
@classmethod
def create(cls):
return "Ch -> " + super().create()
print([Link]()) # Ch -> [Link] for Ch

super() in __init_subclass__ and __set_name__

class Plugin:
registry = {}

def __init_subclass__(cls, /, key=None, **kwargs):


super().__init_subclass__(**kwargs) # ALWAYS forward
key = key or cls.__name__.lower()
[Link][key] = cls
print(f"Registered plugin: {key}")

class CSVPlugin(Plugin, key="csv"):


pass

class JSONPlugin(Plugin): # key defaults to 'jsonplugin'


pass

print([Link])
# {'csv': , 'jsonplugin': }

__init_subclass__ is an implicit classmethod that runs whenever a subclass is defined. It is the modern, simpler alternative to a
metaclass for auto-registration.
11. Polymorphism and Duck Typing
"If it walks like a duck and quacks like a duck, it's a duck." Python cares about what an object can do, not what it is.

class Duck:
def speak(self): return "Quack"
class Robot:
def speak(self): return "Beep"
class Human:
def speak(self): return "Hello"

# No common base class. No interface declaration. It just works.


for thing in [Duck(), Robot(), Human()]:
print([Link]())

EAFP vs LBYL

# LBYL — "Look Before You Leap" (common in Java/C#)


def process_lbyl(obj):
if hasattr(obj, "speak") and callable([Link]):
return [Link]()
return "cannot speak"

# EAFP — "Easier to Ask Forgiveness than Permission" (PYTHONIC)


def process_eafp(obj):
try:
return [Link]()
except AttributeError:
return "cannot speak"

print(process_eafp(Duck())) # Quack
print(process_eafp(42)) # cannot speak

EAFP is preferred because it is race-free (no gap between check and use) and usually faster in the success case. But keep the
try block tight — a broad block can swallow an AttributeError raised deep inside speak() itself.

Polymorphism through overriding

from math import pi

class Shape:
def area(self):
raise NotImplementedError(f"{type(self).__name__} must implement area()")
def __repr__(self):
return f"{type(self).__name__}(area={[Link]():.2f})"

class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return pi * self.r ** 2

class Rectangle(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h

class Triangle(Shape):
def __init__(self, b, h): self.b, self.h = b, h
def area(self): return 0.5 * self.b * self.h

shapes = [Circle(1), Rectangle(3, 4), Triangle(6, 2)]


print(sorted(shapes, key=lambda s: [Link]()))
print(f"Total area: {sum([Link]() for s in shapes):.2f}")
# The loop doesn't know or care which shape it has. That's polymorphism.

Single dispatch: polymorphism without classes


from functools import singledispatch, singledispatchmethod

@singledispatch
def describe(obj):
return f"Some object: {obj!r}"

@[Link]
def _(obj: int):
return f"Integer {obj}, {'even' if obj % 2 == 0 else 'odd'}"

@[Link]
def _(obj: str):
return f"String of length {len(obj)}"

@[Link](list)
def _(obj):
return f"List with {len(obj)} items"

print(describe(42)) # Integer 42, even


print(describe("hello")) # String of length 5
print(describe([1, 2])) # List with 2 items
print(describe(3.14)) # Some object: 3.14

# As a METHOD inside a class:


class Formatter:
@singledispatchmethod
def format(self, arg):
return f"default: {arg}"

@[Link]
def _(self, arg: int):
return f"int: {arg:,}"

@[Link]
def _(self, arg: float):
return f"float: {arg:.2f}"

@[Link](list)
def _(self, arg):
return "[" + ", ".join([Link](x) for x in arg) + "]"

f = Formatter()
print([Link]([1000000, 3.14159, "x"]))
# [int: 1,000,000, float: 3.14, default: x]
12. Abstract Base Classes
ABCs let you declare a contract that subclasses must fulfil, and the check happens at instantiation time rather than at first use.

The problem without ABCs

class Shape:
def area(self):
raise NotImplementedError

class Blob(Shape):
pass

b = Blob() # allowed! No error until...


# [Link]() # ...NotImplementedError, possibly in production

With abc

from abc import ABC, abstractmethod

class Shape(ABC):
def __init__(self, name):
[Link] = name # ABCs CAN have __init__ and state

@abstractmethod
def area(self):
"""Subclass MUST implement this."""

@abstractmethod
def perimeter(self):
...

# Concrete method — inherited for free


def describe(self):
return f"{[Link]}: area={[Link]():.2f}, perim={[Link]():.2f}"

@property
@abstractmethod # order matters: @property OUTSIDE
def sides(self):
...

@classmethod
@abstractmethod
def unit(cls):
...

class Square(Shape):
def __init__(self, side):
super().__init__("Square")
[Link] = side
def area(self): return [Link] ** 2
def perimeter(self): return 4 * [Link]
@property
def sides(self): return 4
@classmethod
def unit(cls): return cls(1)

s = Square(3)
print([Link]()) # Square: area=9.00, perim=12.00
print([Link]().area()) # 1

class Incomplete(Shape):
def area(self): return 0
# missing perimeter, sides, unit

# Incomplete("x")
# TypeError: Can't instantiate abstract class Incomplete with abstract
# methods perimeter, sides, unit
print(Shape.__abstractmethods__) # frozenset({'area', 'perimeter', 'sides', 'unit'})

ABC only blocks instantiation if there is at least one unimplemented abstract method. A subclass that implements all of them is
instantiable, and an ABC with no abstract methods is instantiable too.

Virtual subclasses with register


from abc import ABC, abstractmethod

class Serializer(ABC):
@abstractmethod
def serialize(self, data): ...

class ThirdPartyJSON: # code you can't edit


def serialize(self, data):
import json; return [Link](data)

[Link](ThirdPartyJSON) # declare it a virtual subclass

t = ThirdPartyJSON()
print(isinstance(t, Serializer)) # True !
print(issubclass(ThirdPartyJSON, Serializer)) # True
print(ThirdPartyJSON.__mro__) # (ThirdPartyJSON, object) — NOT inherited
# NOTE: registration is a promise. Python does NOT verify serialize exists.

Structural checks with __subclasshook__

from abc import ABC, abstractmethod

class Drawable(ABC):
@abstractmethod
def draw(self): ...

@classmethod
def __subclasshook__(cls, C):
if cls is Drawable:
if any("draw" in B.__dict__ for B in C.__mro__):
return True # anything with draw() counts
return NotImplemented # fall back to normal rules

class Circle: # no inheritance, no registration


def draw(self): return "()"

print(issubclass(Circle, Drawable)) # True


print(isinstance(Circle(), Drawable)) # True

Built-in ABCs from [Link]

from [Link] import Sequence, MutableMapping, Iterable, Hashable

class Playlist(Sequence):
"""Implement __len__ + __getitem__; get the rest FREE."""
def __init__(self, songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __getitem__(self, i):
return self._songs[i]

p = Playlist(["a", "b", "c", "b"])


print(len(p)) # 4
print(p[1]) # b
print(p[::-1]) # ['b', 'c', 'b', 'a']
print("c" in p) # True <- __contains__ free
print(list(reversed(p))) # free
print([Link]("b")) # 1 <- free
print([Link]("b")) # 2 <- free
for song in p: pass # __iter__ free

print(isinstance([], Sequence), isinstance("s", Sequence)) # True True


print(isinstance({}, MutableMapping)) # True
print(isinstance([], Hashable)) # False

ABC You must write You get free

Container __contains__ —

Iterable __iter__ —

Sized __len__ —

Sequence __len__ , __getitem__ __contains__ , __iter__ , __reversed__ , index , count

MutableSequence + __setitem__ , __delitem__ , insert + append , extend , pop , remove , __iadd__ , reverse

Mapping __getitem__ , __len__ , __iter__ get , keys , items , values , __contains__ , __eq__

MutableMapping + __setitem__ , __delitem__ + pop , popitem , clear , update , setdefault

Set __contains__ , __iter__ , __len__ & , | , - , ^ , <= , isdisjoint


from [Link] import MutableMapping

class CaseInsensitiveDict(MutableMapping):
def __init__(self, data=None):
self._store = {}
if data: [Link](data) # update() is FREE from the ABC

def __setitem__(self, key, value):


self._store[[Link]()] = (key, value) # keep original casing
def __getitem__(self, key):
return self._store[[Link]()][1]
def __delitem__(self, key):
del self._store[[Link]()]
def __iter__(self):
return (orig for orig, _ in self._store.values())
def __len__(self):
return len(self._store)
def __repr__(self):
return f"{type(self).__name__}({dict([Link]())})"

d = CaseInsensitiveDict({"Content-Type": "text/html"})
print(d["content-type"]) # text/html
print(d["CONTENT-TYPE"]) # text/html
print("Content-TYPE" in d) # True <- free
print([Link]("missing", "-"))# - <- free
print(list([Link]())) # ['Content-Type'] original casing preserved
print(d)
13. Protocols and Structural Typing
ABCs are nominal typing — you must inherit or register. [Link] (PEP 544) gives structural typing: matching shape is enough.
It's "static duck typing".

from typing import Protocol, runtime_checkable

class Flyer(Protocol):
def fly(self) -> str: ...
altitude: int # attributes can be part of the protocol

class Bird: # NO inheritance from Flyer


def __init__(self):
[Link] = 100
def fly(self) -> str:
return "flapping"

class Plane:
def __init__(self):
[Link] = 30000
def fly(self) -> str:
return "jet engines"

def launch(f: Flyer) -> None: # type checker verifies the shape
print(f"{[Link]()} at {[Link]}ft")

launch(Bird()) # flapping at 100ft


launch(Plane()) # jet engines at 30000ft
# mypy would reject launch("string") at check time — zero runtime cost.

Runtime-checkable protocols

@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...

class F:
def close(self): print("closed")

print(isinstance(F(), Closeable)) # True


print(isinstance(42, Closeable)) # False
# WARNING: runtime_checkable only checks METHOD NAMES exist.
# It does NOT check signatures or non-method attributes.

Protocol vs ABC — which to use

ABC Protocol

Typing style Nominal (must inherit/register) Structural (shape matches)

Can provide default impls Yes, fully Yes, but discouraged

Enforced at Runtime (instantiation) Static check time

Works with 3rd-party classes Only via register() Automatically

Best for Your own class hierarchies with shared code Interfaces for code you don't own; loose coupling
from typing import Protocol

# Generic protocol
from typing import TypeVar
T = TypeVar("T")

class Repository(Protocol[T]):
def get(self, id: int) -> T | None: ...
def save(self, item: T) -> None: ...
def delete(self, id: int) -> bool: ...

class User:
def __init__(self, id, name): [Link], [Link] = id, name

class InMemoryUserRepo: # structurally satisfies Repository[User]


def __init__(self): self._data: dict[int, User] = {}
def get(self, id): return self._data.get(id)
def save(self, item): self._data[[Link]] = item
def delete(self, id): return self._data.pop(id, None) is not None

def register_user(repo: Repository[User], user: User) -> None:


[Link](user)

repo = InMemoryUserRepo()
register_user(repo, User(1, "Ali"))
print([Link](1).name) # Ali
14. Dunder (Magic) Methods: The Full Tour

Object representation

class Point:
def __init__(self, x, y):
self.x, self.y = x, y

def __repr__(self):
# GOAL: eval(repr(obj)) == obj. For developers/debuggers.
return f"Point({self.x!r}, {self.y!r})"

def __str__(self):
# For end users. Falls back to __repr__ if not defined.
return f"({self.x}, {self.y})"

def __format__(self, spec):


if spec == "polar":
import math
r = [Link](self.x, self.y)
theta = math.atan2(self.y, self.x)
return f"{r:.2f}∠{[Link](theta):.1f}°"
if spec:
return f"({self.x:{spec}}, {self.y:{spec}})"
return str(self)

def __bytes__(self):
return f"{self.x},{self.y}".encode()

p = Point(3, 4)
print(str(p)) # (3, 4)
print(repr(p)) # Point(3, 4)
print(p) # (3, 4) — print uses __str__
print([p]) # [Point(3, 4)] — containers use __repr__!
print(f"{p}") # (3, 4) — f-string uses __format__/__str__
print(f"{p!r}") # Point(3, 4) — !r forces __repr__
print(f"{p:.2f}") # (3.00, 4.00)
print(f"{p:polar}") # 5.00∠53.1°
print(bytes(p)) # b'3,4'

Always define __repr__ . It appears in debuggers, logs, tracebacks, and inside lists/dicts. __str__ is optional — it defaults to
__repr__ .

Truthiness and size

class Cart:
def __init__(self, items=None):
[Link] = items or []

def __len__(self):
return len([Link])

def __bool__(self):
# If __bool__ is absent, Python uses __len__ != 0
# If both absent, every object is truthy
return len([Link]) > 0

c = Cart()
print(bool(c), len(c)) # False 0
if not c: print("Cart empty")
[Link]("apple")
print(bool(c)) # True

Subtle bug: if you define __len__ but the object can legitimately be "empty yet meaningful" (e.g. an empty Response ), if
response: becomes False unexpectedly. Define __bool__ explicitly to control this.

Comparison methods
from functools import total_ordering

@total_ordering # write __eq__ + ONE ordering; get the rest


class Version:
def __init__(self, major, minor, patch):
[Link], [Link], [Link] = major, minor, patch

def _key(self):
return ([Link], [Link], [Link])

def __eq__(self, other):


if not isinstance(other, Version):
return NotImplemented # let Python try the reflected op
return self._key() == other._key()

def __lt__(self, other):


if not isinstance(other, Version):
return NotImplemented
return self._key() < other._key()

def __hash__(self):
return hash(self._key()) # needed: defining __eq__ kills hash

def __repr__(self):
return f"v{[Link]}.{[Link]}.{[Link]}"

a, b = Version(1, 2, 3), Version(1, 3, 0)


print(a < b, a <= b, a > b, a >= b, a == b, a != b)
# True True False False False True — all from __eq__ + __lt__
print(sorted([Version(2,0,0), Version(1,0,0), Version(1,5,2)]))
print(max([a, b]))
print(a == "not a version") # False — NotImplemented -> falls back to identity

Return NotImplemented , not False , for unknown types. NotImplemented tells Python to try the reflected operation on the other
operand ( other.__gt__(self) ). Returning False silently claims "definitely not equal" and breaks interop.

The full comparison set

Operator Method Reflected

== __eq__ __eq__

!= __ne__ __ne__ (auto from __eq__ )

< __lt__ __gt__

<= __le__ __ge__

> __gt__ __lt__

>= __ge__ __le__

Container methods
class Matrix:
def __init__(self, rows):
[Link] = rows

def __len__(self): return len([Link])

def __getitem__(self, key):


if isinstance(key, tuple): # m[1, 2]
r, c = key
return [Link][r][c]
if isinstance(key, slice): # m[1:3]
return Matrix([Link][key])
return [Link][key] # m[1]

def __setitem__(self, key, value):


if isinstance(key, tuple):
r, c = key
[Link][r][c] = value
else:
[Link][key] = value

def __delitem__(self, key):


del [Link][key]

def __contains__(self, value):


return any(value in row for row in [Link])

def __iter__(self):
return iter([Link])

def __reversed__(self):
return reversed([Link])

def __repr__(self):
return "\n".join(str(r) for r in [Link])

m = Matrix([[1,2,3],[4,5,6],[7,8,9]])
print(m[1]) # [4, 5, 6]
print(m[1, 2]) # 6
m[0, 0] = 99
print(m[0]) # [99, 2, 3]
print(5 in m) # True
print(m[0:2]) # first two rows as a new Matrix
for row in m: pass

Callable objects

class Multiplier:
def __init__(self, factor):
[Link] = factor
def __call__(self, x):
return x * [Link]

double = Multiplier(2)
print(double(5)) # 10
print(callable(double)) # True
print(list(map(double, [1, 2, 3]))) # [2, 4, 6]

# Stateful callable — better than a closure when you need introspection


class Counter:
def __init__(self):
[Link] = 0
def __call__(self, *args, **kwargs):
[Link] += 1
return [Link]

c = Counter()
c(); c(); c()
print([Link]) # 3

Hashing
# RULE: if a == b then hash(a) MUST == hash(b)
# RULE: mutable objects should NOT be hashable

class Immutable:
def __init__(self, x, y):
object.__setattr__(self, "x", x) # bypass our own __setattr__
object.__setattr__(self, "y", y)
def __setattr__(self, *args):
raise AttributeError("Immutable")
def __eq__(self, other):
return isinstance(other, Immutable) and (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))

s = {Immutable(1, 2), Immutable(1, 2)}


print(len(s)) # 1 — they're equal AND same hash, so deduplicated

class Mutable:
def __init__(self, x): self.x = x
def __eq__(self, other): return self.x == other.x
# __hash__ is set to None automatically when __eq__ is defined
print(Mutable.__hash__) # None
# {Mutable(1)} # TypeError: unhashable type: 'Mutable'

Defining __eq__ automatically sets __hash__ = None , making the class unhashable. If you want it hashable, define __hash__
explicitly. This is deliberate: mutable objects that change their equality would corrupt dicts and sets.

# What happens if you break the rule:


class Bad:
def __init__(self, x): self.x = x
def __eq__(self, o): return self.x == o.x
def __hash__(self): return hash(self.x) # depends on MUTABLE x!

b = Bad(1)
s = {b}
b.x = 2 # hash changed while in the set
print(b in s) # False !! The object is lost inside its own set.
print(list(s)[0].x) # 2 — it's still there, just unfindable
15. Operator Overloading

Full arithmetic table

Op Method Reflected In-place

+ __add__ __radd__ __iadd__

- __sub__ __rsub__ __isub__

* __mul__ __rmul__ __imul__

/ __truediv__ __rtruediv__ __itruediv__

// __floordiv__ __rfloordiv__ __ifloordiv__

% __mod__ __rmod__ __imod__

** __pow__ __rpow__ __ipow__

@ __matmul__ __rmatmul__ __imatmul__

& | ^ __and__ __or__ __xor__ __rand__ etc. __iand__ etc.

<< >> __lshift__ __rshift__ __rlshift__ etc. __ilshift__ etc.

-x +x ~x __neg__ __pos__ __invert__ — —

abs(x) __abs__ — —

round() __round__ — —

int() float() __int__ __float__ — —

divmod() __divmod__ __rdivmod__ —

A complete Vector class


import math

class Vector:
def __init__(self, *components):
self.c = tuple(components)

def __repr__(self):
return f"Vector{self.c}"

def __len__(self): return len(self.c)


def __getitem__(self, i): return self.c[i]
def __iter__(self): return iter(self.c)
def __eq__(self, o): return isinstance(o, Vector) and self.c == o.c
def __hash__(self): return hash(self.c)

def __add__(self, other):


if not isinstance(other, Vector): return NotImplemented
if len(self) != len(other): raise ValueError("Dimension mismatch")
return Vector(*(a + b for a, b in zip(self.c, other.c)))

def __sub__(self, other):


if not isinstance(other, Vector): return NotImplemented
return Vector(*(a - b for a, b in zip(self.c, other.c)))

def __mul__(self, other):


if isinstance(other, (int, float)): # scalar
return Vector(*(a * other for a in self.c))
if isinstance(other, Vector): # dot product
return sum(a * b for a, b in zip(self.c, other.c))
return NotImplemented

def __rmul__(self, other):


# Called for `3 * v` when int.__mul__(3, v) returns NotImplemented
return self.__mul__(other)

def __matmul__(self, other): # the @ operator


return sum(a * b for a, b in zip(self.c, other.c))

def __truediv__(self, scalar):


return Vector(*(a / scalar for a in self.c))

def __neg__(self): return Vector(*(-a for a in self.c))


def __pos__(self): return Vector(*self.c)
def __abs__(self): return [Link](sum(a*a for a in self.c))
def __bool__(self): return any(self.c)
def __round__(self, n=0): return Vector(*(round(a, n) for a in self.c))

def normalized(self):
mag = abs(self)
if mag == 0: raise ValueError("Cannot normalize zero vector")
return self / mag

v1 = Vector(1, 2, 3)
v2 = Vector(4, 5, 6)
print(v1 + v2) # Vector(5, 7, 9)
print(v2 - v1) # Vector(3, 3, 3)
print(v1 * 3) # Vector(3, 6, 9)
print(3 * v1) # Vector(3, 6, 9) <- __rmul__ saved us
print(v1 * v2) # 32 <- dot product
print(v1 @ v2) # 32
print(-v1) # Vector(-1, -2, -3)
print(abs(Vector(3, 4))) # 5.0
print(round([Link](), 3)) # Vector(0.267, 0.535, 0.802)
print(bool(Vector(0, 0))) # False
print(v1 + 5) # TypeError: unsupported operand type(s)

How Python resolves a + b

1. If type(b) is a proper subclass of type(a) and overrides __radd__ → try b.__radd__(a) first.
2. Try a.__add__(b) . If it returns anything except NotImplemented , done.
3. Try b.__radd__(a) . If not NotImplemented , done.
4. Raise TypeError: unsupported operand type(s) for + .
class Money:
def __init__(self, amount, currency="PKR"):
[Link], [Link] = amount, currency
def __repr__(self): return f"{[Link]:.2f} {[Link]}"

def __add__(self, other):


if isinstance(other, Money):
if [Link] != [Link]:
raise ValueError(f"Cannot add {[Link]} and {[Link]}")
return Money([Link] + [Link], [Link])
if isinstance(other, (int, float)):
return Money([Link] + other, [Link])
return NotImplemented

def __radd__(self, other):


# Makes sum() work: sum() starts with 0, so 0 + Money is attempted
if other == 0:
return self
return self.__add__(other)

wallet = [Money(100), Money(250.5), Money(49.5)]


print(sum(wallet)) # 400.00 PKR <- only works because of __radd__
print(100 + Money(50)) # 150.00 PKR

In-place operators

class Basket:
def __init__(self, items=None):
[Link] = list(items or [])
def __repr__(self): return f"Basket({[Link]})"

def __add__(self, other):


# Creates a NEW object — original unchanged
return Basket([Link] + [Link])

def __iadd__(self, other):


# MUTATES in place and returns self — more efficient
[Link]([Link])
return self # MUST return something!

a = Basket([1, 2])
b = Basket([3])
c = a
a += b
print(a) # Basket([1, 2, 3])
print(c) # Basket([1, 2, 3]) <- same object mutated!
print(a is c) # True

x = Basket([1])
y = x
x = x + Basket([2]) # __add__ creates a new object
print(y) # Basket([1]) <- unchanged
print(x is y) # False

__iadd__ must return self (or a new object). If it returns None , then a += b sets a = None . If __iadd__ is not defined, Python
falls back to a = a + b using __add__ .
16. Composition vs Inheritance

"Favor composition over inheritance." Inheritance is IS-A and couples you to the parent's implementation forever.
Composition is HAS-A and can be swapped at runtime.

Inheritance abuse

# BAD: is a Car really a kind of Engine? No.


class Engine:
def start(self): return "Vroom"
def stop(self): return "Silence"

class Car(Engine): # WRONG relationship


pass

c = Car()
print([Link]()) # works, but now Car IS-A Engine — nonsense.
# You can never have an ElectricCar with a different engine.

# GOOD: a Car HAS-A Engine, and the engine can be swapped


class PetrolEngine:
def start(self): return "Vroom vroom"
def fuel_type(self): return "petrol"

class ElectricEngine:
def start(self): return "Silent hum"
def fuel_type(self): return "electricity"

class Car:
def __init__(self, brand, engine):
[Link] = brand
[Link] = engine # composition / dependency injection

def start(self):
return f"{[Link]}: {[Link]()}"

def swap_engine(self, engine): # impossible with inheritance!


[Link] = engine

c = Car("Toyota", PetrolEngine())
print([Link]()) # Toyota: Vroom vroom
c.swap_engine(ElectricEngine())
print([Link]()) # Toyota: Silent hum

The classic combinatorial explosion

# With inheritance you need a class per combination:


# Coffee, CoffeeWithMilk, CoffeeWithSugar, CoffeeWithMilkAndSugar,
# CoffeeWithMilkAndSugarAndCream ... 2^n classes.

# With composition (Decorator pattern) — n classes, any combination:


class Beverage:
def __init__(self, name, cost):
[Link], self._cost = name, cost
def cost(self): return self._cost
def description(self): return [Link]

class Addon:
def __init__(self, beverage, name, extra):
[Link], [Link], [Link] = beverage, name, extra
def cost(self): return [Link]() + [Link]
def description(self): return f"{[Link]()} + {[Link]}"

drink = Beverage("Coffee", 150)


drink = Addon(drink, "Milk", 30)
drink = Addon(drink, "Sugar", 10)
drink = Addon(drink, "Cream", 40)
print([Link]()) # Coffee + Milk + Sugar + Cream
print([Link]()) # 230

Delegation with __getattr__


class Logger:
def __init__(self, wrapped):
self._wrapped = wrapped
[Link] = []

def __getattr__(self, name):


# Only called when Logger itself lacks the attribute
attr = getattr(self._wrapped, name)
if callable(attr):
def logged(*args, **kwargs):
[Link](name)
print(f"[LOG] {name}({args})")
return attr(*args, **kwargs)
return logged
return attr

lst = Logger([])
[Link](1) # [LOG] append((1,))
[Link](2) # [LOG] append((2,))
print(lst._wrapped) # [1, 2]
print([Link]) # ['append', 'append']

When inheritance IS right


A true IS-A relationship that satisfies Liskov substitution
You want to share a substantial default implementation
The framework demands it (Django models, [Link] , Exception )
Implementing an ABC / [Link] contract

Decision table

Question Inheritance Composition

Relationship IS-A HAS-A / USES-A

Coupling Tight (white-box) Loose (black-box)

Change at runtime No Yes

Testability Harder (must build the parent) Easy (inject a fake)

Fragile base class risk High None


17. __slots__ and Memory
By default every instance carries a __dict__ , which costs memory. __slots__ replaces it with a fixed array of descriptors.

import sys

class Normal:
def __init__(self, x, y):
self.x, self.y = x, y

class Slotted:
__slots__ = ("x", "y") # declare ALL attributes up front
def __init__(self, x, y):
self.x, self.y = x, y

n, s = Normal(1, 2), Slotted(1, 2)


print([Link](n) + [Link](n.__dict__)) # ~152 bytes
print([Link](s)) # ~56 bytes

print(hasattr(n, "__dict__")) # True


print(hasattr(s, "__dict__")) # False

n.z = 3 # fine
# s.z = 3 # AttributeError: 'Slotted' object has no attribute 'z'

Benefits and costs

Benefit Cost

~40–50% less memory per instance No dynamic attributes

Faster attribute access (~10–20%) No __dict__ / vars()

Typo protection (fails loudly) Breaks cached_property

Better cache locality Multiple-inheritance restrictions

Slots and inheritance — the common gotcha

class Base:
__slots__ = ("a",)

class Child(Base):
pass # NO __slots__ -> Child gets a __dict__ back!

c = Child()
[Link] = 1 # works — slots benefit LOST
print(hasattr(c, "__dict__")) # True

class Child2(Base):
__slots__ = ("b",) # only NEW attributes; don't repeat 'a'

c2 = Child2()
c2.a, c2.b = 1, 2
# c2.c = 3 # AttributeError — benefit preserved
print(Child2.__slots__) # ('b',)

Every class in the chain must define __slots__ , or a __dict__ reappears and all savings vanish. Also: never repeat a parent's
slot name in a child — it wastes space and hides the parent's descriptor.

# Multiple inheritance: you can't have two parents with NONEMPTY slots
class A: __slots__ = ("x",)
class B: __slots__ = ("y",)
# class C(A, B): __slots__ = ()
# TypeError: multiple bases have instance lay-out conflict

# But this works — one has empty slots:


class Mixin: __slots__ = ()
class D(A, Mixin): __slots__ = ()
d = D(); d.x = 1
print(hasattr(d, "__dict__")) # False
# Allow SOME dynamic attributes while keeping most slotted:
class Hybrid:
__slots__ = ("x", "y", "__dict__") # explicit escape hatch
h = Hybrid(); h.x = 1; [Link] = 2 # both work

# Slots + weakref support:


class W:
__slots__ = ("data", "__weakref__") # needed for [Link](obj)
import weakref
w = W(); [Link] = 1
print([Link](w)()) # works

# Slots with a default via a class attribute:


class Conflict:
__slots__ = ("x",)
# x = 5 # ValueError: 'x' in __slots__ conflicts with class variable

# Real benefit: many small objects


class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y): self.x, self.y = x, y

points = [PointSlots(i, i) for i in range(1_000_000)]


# ~56 MB vs ~152 MB for the dict version. Meaningful at scale.

Use __slots__ when: you create thousands/millions of instances, or want strict attribute control (value objects, records, graph
nodes). Skip it for ordinary application classes — it's a micro-optimization that costs flexibility.
18. Descriptors
A descriptor is any object defining __get__ , __set__ , or __delete__ . Descriptors power property , classmethod , staticmethod , and
bound methods. They are the low-level mechanism behind attribute access.

The protocol

__get__(self, obj, objtype=None) -> value


__set__(self, obj, value) -> None
__delete__(self, obj) -> None
__set_name__(self, owner, name) -> None (Python 3.6+, auto-called)

Type Defines Priority vs instance __dict__

Data descriptor __set__ or __delete__ WINS over instance dict

Non-data descriptor only __get__ LOSES to instance dict

A validating descriptor
class Validated:
def __set_name__(self, owner, name):
# Called automatically when the class body is created.
# Tells the descriptor which attribute name it was assigned to.
self.public_name = name
self.private_name = "_" + name

def __get__(self, obj, objtype=None):


if obj is None:
return self # accessed on the CLASS -> return descriptor
return getattr(obj, self.private_name)

def __set__(self, obj, value):


[Link](value)
setattr(obj, self.private_name, value)

def __delete__(self, obj):


delattr(obj, self.private_name)

def validate(self, value):


pass # subclasses override

class Integer(Validated):
def __init__(self, minvalue=None, maxvalue=None):
[Link], [Link] = minvalue, maxvalue
def validate(self, value):
if not isinstance(value, int):
raise TypeError(f"{self.public_name} must be an int, got {type(value).__name__}")
if [Link] is not None and value < [Link]:
raise ValueError(f"{self.public_name} must be >= {[Link]}")
if [Link] is not None and value > [Link]:
raise ValueError(f"{self.public_name} must be <= {[Link]}")

class String(Validated):
def __init__(self, minlen=0, maxlen=None, predicate=None):
[Link], [Link], [Link] = minlen, maxlen, predicate
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be a str")
if len(value) < [Link]:
raise ValueError(f"{self.public_name} too short (min {[Link]})")
if [Link] and len(value) > [Link]:
raise ValueError(f"{self.public_name} too long (max {[Link]})")
if [Link] and not [Link](value):
raise ValueError(f"{self.public_name} failed validation")

class OneOf(Validated):
def __init__(self, *options):
[Link] = set(options)
def validate(self, value):
if value not in [Link]:
raise ValueError(f"{self.public_name} must be one of {[Link]}")

# ---- Reuse the validators across ANY class. Zero duplication. ----
class Person:
name = String(minlen=2, maxlen=50, predicate=[Link])
age = Integer(minvalue=0, maxvalue=150)
kind = OneOf("employee", "contractor", "intern")

def __init__(self, name, age, kind):


[Link], [Link], [Link] = name, age, kind

def __repr__(self):
return f"Person({[Link]!r}, {[Link]}, {[Link]!r})"

p = Person("Ali", 30, "employee")


print(p) # Person('Ali', 30, 'employee')
print(vars(p)) # {'_name': 'Ali', '_age': 30, '_kind': 'employee'}
print([Link]) # — obj is None branch

# [Link] = -5 # ValueError: age must be >= 0


# [Link] = "thirty" # TypeError: age must be an int, got str
# [Link] = "A" # ValueError: name too short (min 2)
# [Link] = "boss" # ValueError: kind must be one of {...}

Why descriptors beat properties here: with @property you'd write ~8 lines of getter/setter per attribute, per class. The
descriptor is written once and reused everywhere. This is exactly how Django fields, SQLAlchemy columns, and Pydantic work
internally.

Data vs non-data priority — demonstrated


class NonData:
def __get__(self, obj, objtype=None):
return "from non-data descriptor"

class Data:
def __get__(self, obj, objtype=None):
return "from data descriptor"
def __set__(self, obj, value):
print(f"data descriptor intercepted set of {value}")
obj.__dict__["_hidden"] = value

class Demo:
nd = NonData()
d = Data()

x = Demo()
print([Link]) # from non-data descriptor
x.__dict__["nd"] = "instance value"
print([Link]) # instance value <- instance dict WINS

print(x.d) # from data descriptor


x.__dict__["d"] = "instance value"
print(x.d) # from data descriptor <- descriptor WINS
x.d = 5 # data descriptor intercepted set of 5

Lazy loading via a non-data descriptor

class LazyProperty:
"""Compute once, then let the instance dict take over — zero overhead after."""
def __init__(self, func):
[Link] = func
[Link] = func.__name__
self.__doc__ = func.__doc__

def __get__(self, obj, objtype=None):


if obj is None:
return self
print(f" computing {[Link]}...")
value = [Link](obj)
obj.__dict__[[Link]] = value # shadows the descriptor forever
return value # works ONLY because it's non-data

class Config:
def __init__(self, path):
[Link] = path

@LazyProperty
def settings(self):
return {"db": "postgres", "debug": True} # imagine a slow file read

c = Config("[Link]")
print([Link]) # computing settings... then the dict
print([Link]) # instant — descriptor bypassed entirely
print(c.__dict__) # {'path': '[Link]', 'settings': {...}}

How methods actually work

import types

class Function:
"""A simplified reimplementation of how Python functions become methods."""
def __init__(self, func):
[Link] = func
def __get__(self, obj, objtype=None):
if obj is None:
return [Link] # [Link] -> plain function
return [Link]([Link], obj) # [Link] -> bound method

class MyClass:
def greet(self):
return f"hello from {self}"
greet = Function(greet)

m = MyClass()
print(type([Link])) #
print(type([Link])) #
# THIS is why self is passed automatically. Functions are non-data descriptors.

Reimplementing property, staticmethod, classmethod


class MyProperty:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
[Link], [Link], [Link] = fget, fset, fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)

def __get__(self, obj, objtype=None):


if obj is None: return self
if [Link] is None: raise AttributeError("unreadable attribute")
return [Link](obj)

def __set__(self, obj, value):


if [Link] is None: raise AttributeError("can't set attribute")
[Link](obj, value)

def __delete__(self, obj):


if [Link] is None: raise AttributeError("can't delete attribute")
[Link](obj)

def setter(self, fset):


return type(self)([Link], fset, [Link], self.__doc__)

class MyStaticMethod:
def __init__(self, f): self.f = f
def __get__(self, obj, objtype=None):
return self.f # no binding at all

class MyClassMethod:
def __init__(self, f): self.f = f
def __get__(self, obj, objtype=None):
if objtype is None: objtype = type(obj)
return [Link](self.f, objtype) # bind to the CLASS

class T:
_v = 1
@MyProperty
def v(self): return self._v
@[Link]
def v(self, val): self._v = val * 2

@MyStaticMethod
def s(a, b): return a + b

@MyClassMethod
def c(cls): return cls.__name__

t = T()
t.v = 5
print(t.v) # 10
print(T.s(1, 2)) # 3
print(t.c()) # T
19. Metaclasses

"Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them,
you don't." — Tim Peters

Before reaching for a metaclass, check whether __init_subclass__ , __set_name__ , a class decorator, or a descriptor solves it. They
usually do.

Classes are objects; metaclasses are their type

class Foo: pass


print(type(Foo)) # — Foo is an INSTANCE of type
print(type(type)) # — type is its own metaclass
print(isinstance(Foo, type)) # True

Creating classes dynamically with type()

# These two are EXACTLY equivalent:


class Dog:
species = "canine"
def bark(self): return "Woof"

def bark(self): return "Woof"


Dog2 = type("Dog2", (), {"species": "canine", "bark": bark})
# name bases namespace dict

d = Dog2()
print([Link](), [Link]) # Woof canine
print(Dog2.__name__) # Dog2

# With inheritance:
Puppy = type("Puppy", (Dog2,), {"size": "small"})
print(Puppy().bark(), Puppy.__mro__)

Writing a metaclass

class Meta(type):
def __prepare__(name, bases, **kwargs):
# 1. Returns the namespace mapping used for the class body.
# Return an OrderedDict, a logging dict, etc.
print(f"1. __prepare__ for {name}")
return {}

def __new__(mcs, name, bases, namespace, **kwargs):


# 2. CREATES the class object. Modify `namespace` here.
print(f"2. __new__ creating {name}, members: {list(namespace)}")
cls = super().__new__(mcs, name, bases, namespace)
return cls

def __init__(cls, name, bases, namespace, **kwargs):


# 3. INITIALIZES the already-created class.
print(f"3. __init__ for {name}")
super().__init__(name, bases, namespace)

def __call__(cls, *args, **kwargs):


# 4. Runs when you INSTANTIATE the class: MyClass()
print(f"4. __call__ — making an instance of {cls.__name__}")
return super().__call__(*args, **kwargs)

class MyClass(metaclass=Meta):
x = 1
def method(self): pass
# 1. __prepare__ for MyClass
# 2. __new__ creating MyClass, members: ['__module__', '__qualname__', 'x', 'method']
# 3. __init__ for MyClass

obj = MyClass()
# 4. __call__ — making an instance of MyClass

Practical metaclass: auto-registration


class PluginMeta(type):
registry = {}

def __new__(mcs, name, bases, ns, **kw):


cls = super().__new__(mcs, name, bases, ns)
if bases: # skip the abstract base itself
[Link][[Link]()] = cls
return cls

class Plugin(metaclass=PluginMeta):
def run(self): raise NotImplementedError

class EmailPlugin(Plugin):
def run(self): return "sending email"

class SMSPlugin(Plugin):
def run(self): return "sending sms"

print([Link])
# {'emailplugin': , 'smsplugin': }
print([Link]["smsplugin"]().run()) # sending sms

Practical metaclass: enforcing an interface

class InterfaceMeta(type):
required = ("save", "load")

def __new__(mcs, name, bases, ns):


if bases:
missing = [m for m in [Link]
if m not in ns and not any(hasattr(b, m) for b in bases)]
if missing:
raise TypeError(f"{name} is missing: {', '.join(missing)}")
return super().__new__(mcs, name, bases, ns)

class Storage(metaclass=InterfaceMeta): pass

class GoodStore(Storage):
def save(self, x): pass
def load(self, k): pass

# class BadStore(Storage):
# def save(self, x): pass
# TypeError: BadStore is missing: load <- fails at IMPORT time!

Singleton via metaclass

class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]

class Database(metaclass=SingletonMeta):
def __init__(self, url):
print(f"Connecting to {url}")
[Link] = url

a = Database("postgres://x") # Connecting to postgres://x


b = Database("mysql://y") # (nothing printed — __init__ never runs again)
print(a is b, [Link]) # True postgres://x

Metaclass advantage over __new__ -based singletons: because __call__ short-circuits before __init__ , the initializer
genuinely runs only once. No _ready guard needed.

Using __prepare__ to record definition order


class OrderedMeta(type):
@classmethod
def __prepare__(mcs, name, bases, **kw):
return {} # dicts are ordered in 3.7+, but this hook lets you
# substitute any mapping (e.g. one that rejects
# duplicate names)

def __new__(mcs, name, bases, ns):


cls = super().__new__(mcs, name, bases, dict(ns))
cls._field_order = [k for k in ns if not [Link]("_")]
return cls

class Form(metaclass=OrderedMeta):
username = "text"
password = "password"
email = "email"

print(Form._field_order) # ['username', 'password', 'email']

# A namespace that FORBIDS duplicate definitions:


class NoDupDict(dict):
def __setitem__(self, key, value):
if key in self and not [Link]("__"):
raise TypeError(f"Duplicate definition of {key!r}")
super().__setitem__(key, value)

class StrictMeta(type):
@classmethod
def __prepare__(mcs, name, bases, **kw):
return NoDupDict()
def __new__(mcs, name, bases, ns):
return super().__new__(mcs, name, bases, dict(ns))

# class Oops(metaclass=StrictMeta):
# def run(self): pass
# def run(self): pass # TypeError: Duplicate definition of 'run'

Metaclass conflicts

class M1(type): pass


class M2(type): pass
class A(metaclass=M1): pass
class B(metaclass=M2): pass
# class C(A, B): pass
# TypeError: metaclass conflict: the metaclass of a derived class must be a
# (non-strict) subclass of the metaclasses of all its bases

class M3(M1, M2): pass # FIX: a common metaclass


class C(A, B, metaclass=M3): pass
print(type(C)) #

# This is why combining ABC (metaclass=ABCMeta) with a custom metaclass needs:


from abc import ABCMeta
class MyMeta(ABCMeta): pass # inherit from ABCMeta, not type

The modern alternatives

# 1. __init_subclass__ replaces registration metaclasses


class Plugin2:
registry = {}
def __init_subclass__(cls, key=None, **kw):
super().__init_subclass__(**kw)
[Link][key or cls.__name__.lower()] = cls

class Email2(Plugin2, key="email"): pass


print([Link]) # {'email': }

# 2. A class decorator replaces class-modifying metaclasses


def add_repr(cls):
def __repr__(self):
args = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({args})"
cls.__repr__ = __repr__
return cls

@add_repr
class P:
def __init__(self, x): self.x = x
print(P(1)) # P(x=1)
20. Dataclasses

The boilerplate problem

# Without dataclasses — 20 lines for a 3-field record


class PointOld:
def __init__(self, x, y, z=0):
self.x, self.y, self.z = x, y, z
def __repr__(self):
return f"PointOld(x={self.x!r}, y={self.y!r}, z={self.z!r})"
def __eq__(self, other):
if other.__class__ is not self.__class__: return NotImplemented
return (self.x, self.y, self.z) == (other.x, other.y, other.z)

from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int
z: int = 0 # default

p = Point(1, 2)
print(p) # Point(x=1, y=2, z=0)
print(p == Point(1, 2)) # True
print(p.x) # 1
# __init__, __repr__, __eq__ all generated.

Every parameter explained

from dataclasses import dataclass, field

@dataclass(
init=True, # generate __init__ (default True)
repr=True, # generate __repr__ (default True)
eq=True, # generate __eq__ (default True)
order=False, # generate __lt__ __le__ __gt__ __ge__
unsafe_hash=False, # force __hash__ generation
frozen=False, # immutable: block __setattr__/__delattr__
match_args=True, # generate __match_args__ for structural pattern matching
kw_only=False, # ALL fields become keyword-only (3.10+)
slots=False, # generate __slots__ (3.10+)
weakref_slot=False, # add __weakref__ to slots (3.11+)
)
class Config:
name: str
value: int = 0

Frozen (immutable) + hashable

@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float

c = Coordinate(24.86, 67.01)
# [Link] = 25.0 # FrozenInstanceError: cannot assign to field 'lat'
print(hash(c)) # frozen + eq -> __hash__ generated automatically
print({c: "Karachi"}) # usable as a dict key
print({Coordinate(1,2), Coordinate(1,2)}) # deduplicated — one element

# Frozen is SHALLOW:
@dataclass(frozen=True)
class Box:
items: list = field(default_factory=list)
b = Box()
[Link]("x") # ALLOWED — the list itself is still mutable
print(b) # Box(items=['x'])

The field() function


from dataclasses import dataclass, field
import uuid
from typing import ClassVar

@dataclass
class Item:
name: str
# default_factory: for MUTABLE defaults (a bare [] raises ValueError)
tags: list[str] = field(default_factory=list)
meta: dict = field(default_factory=dict)
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])

# repr=False: hide from __repr__ (secrets, big blobs)


password: str = field(default="", repr=False)

# compare=False: exclude from __eq__ and ordering


created_at: float = field(default=0.0, compare=False)

# init=False: not a constructor parameter; set in __post_init__


slug: str = field(init=False, default="")

# kw_only: this field must be passed by keyword (3.10+)


debug: bool = field(default=False, kw_only=True)

# metadata: arbitrary info for other tools to read


price: float = field(default=0.0, metadata={"unit": "PKR", "min": 0})

# ClassVar is NOT a field — shared, ignored by dataclass machinery


count: ClassVar[int] = 0

def __post_init__(self):
# Runs right after the generated __init__ — validation & derived fields
[Link] = [Link]().replace(" ", "-")
if [Link] < 0:
raise ValueError("price cannot be negative")
[Link] += 1

i = Item("Blue Pen", price=50, debug=True)


print(i)
# Item(name='Blue Pen', tags=[], meta={}, id='a1b2c3d4', created_at=0.0,
# slug='blue-pen', debug=True, price=50)
print([Link], [Link]) # blue-pen 1

# Inspect field metadata:


from dataclasses import fields
for f in fields(Item):
if [Link]:
print([Link], dict([Link])) # price {'unit': 'PKR', 'min': 0}

tags: list = [] raises ValueError: mutable default . Dataclasses detect this classic bug and force you to use default_factory .
This is one of the best things about them.

Ordering

@dataclass(order=True)
class Employee:
# Comparison uses fields IN DECLARATION ORDER, like a tuple
salary: int
name: str = field(compare=False) # exclude name from comparison

staff = [Employee(50000, "Ali"), Employee(80000, "Sara"), Employee(60000, "Zed")]


print(sorted(staff))
print(max(staff).name) # Sara

# For a custom sort key, use a computed init=False field:


@dataclass(order=True)
class Task:
sort_index: int = field(init=False, repr=False)
name: str
priority: int
def __post_init__(self):
self.sort_index = -[Link] # sort by priority DESC

print(sorted([Task("a", 1), Task("b", 5), Task("c", 3)]))

Inheritance rules
@dataclass
class Base:
a: int
b: int = 2

@dataclass
class Child(Base):
c: int = 3 # fields APPEND: __init__(self, a, b=2, c=3)

print(Child(1)) # Child(a=1, b=2, c=3)

# GOTCHA: a non-default field after a default field is an error


# @dataclass
# class Bad(Base):
# c: int # TypeError: non-default argument 'c' follows default
# argument (inherited b has a default)

# FIX with kw_only (3.10+):


@dataclass(kw_only=True)
class Good(Base):
c: int # fine now — keyword-only args have no ordering rule
print(Good(a=1, c=9))

Utility functions

from dataclasses import asdict, astuple, replace, fields, is_dataclass

@dataclass
class Inner:
v: int
@dataclass
class Outer:
name: str
inner: Inner
items: list

o = Outer("x", Inner(5), [Inner(1), Inner(2)])


print(asdict(o))
# {'name': 'x', 'inner': {'v': 5}, 'items': [{'v': 1}, {'v': 2}]} — RECURSIVE
print(astuple(o)) # ('x', (5,), [(1,), (2,)])

o2 = replace(o, name="y") # copy with changes (great for frozen classes)


print([Link], [Link]) # y x
print([Link] is [Link]) # True — replace is SHALLOW

print(is_dataclass(o)) # True
print([[Link] for f in fields(Outer)]) # ['name', 'inner', 'items']

Slots + dataclass

@dataclass(slots=True, frozen=True) # 3.10+


class FastPoint:
x: float
y: float

p = FastPoint(1.0, 2.0)
print(hasattr(p, "__dict__")) # False — memory efficient AND immutable
# NOTE: slots=True creates a NEW class object, so any code holding a
# reference to the pre-decoration class (e.g. in a closure) sees the old one.

Dataclass vs alternatives

dataclass NamedTuple TypedDict Plain class Pydantic

Mutable Yes (optional frozen) No Yes Yes Yes

Runtime validation No (manual) No No Manual Yes

Iterable/unpackable No Yes No (it's a dict) No No

Methods allowed Yes Yes No Yes Yes

Stdlib Yes Yes Yes Yes No

Type hints in dataclasses are NOT enforced. Point("hello", "world") works fine at runtime. Use __post_init__ , descriptors,
or Pydantic if you need real validation.
21. NamedTuple and Enum

NamedTuple

from typing import NamedTuple

class Point(NamedTuple):
x: int
y: int
label: str = "origin"

def distance_from_origin(self) -> float: # methods ARE allowed


return (self.x ** 2 + self.y ** 2) ** 0.5

@property
def quadrant(self) -> int:
if self.x > 0 and self.y > 0: return 1
if self.x < 0 and self.y > 0: return 2
if self.x < 0 and self.y < 0: return 3
return 4

p = Point(3, 4)
print(p) # Point(x=3, y=4, label='origin')
print(p.x, p[0]) # 3 3 — attribute AND index access
x, y, label = p # unpackable
print(p.distance_from_origin())# 5.0
print(p._replace(x=10)) # Point(x=10, y=4, label='origin')
print(p._asdict()) # {'x': 3, 'y': 4, 'label': 'origin'}
print(Point._fields) # ('x', 'y', 'label')
print(Point._make([1, 2, "a"]))# Point(x=1, y=2, label='a')
# p.x = 5 # AttributeError: can't set attribute

# Because it IS a tuple:
print(p == (3, 4, "origin")) # True <- compares equal to a plain tuple!
print(hash(p) == hash((3, 4, "origin"))) # True
print(isinstance(p, tuple)) # True

Enum

from enum import Enum, auto, IntEnum, StrEnum, Flag, unique

class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3

print([Link]) # [Link]
print([Link]) # RED
print([Link]) # 1
print(Color(1)) # [Link] — lookup by value
print(Color["RED"]) # [Link] — lookup by name
print(list(Color)) # [, , ]
print([Link] is Color(1)) # True — enum members are singletons
print([Link] == 1) # False! Plain Enum is NOT its value

@unique # prevents aliases


class Status(Enum):
PENDING = auto() # 1
ACTIVE = auto() # 2
CLOSED = auto() # 3
# Adding CANCELLED = 3 would raise ValueError: duplicate values

class Alias(Enum):
OK = 1
FINE = 1 # alias for OK, NOT a new member
print([Link] is [Link]) # True
print(list(Alias)) # [] — aliases hidden
print(Alias.__members__) # includes both names

Enums with methods and rich values


class Planet(Enum):
MERCURY = (3.303e+23, 2.4397e6)
EARTH = (5.976e+24, 6.37814e6)

def __init__(self, mass, radius):


[Link] = mass # tuple is unpacked into __init__
[Link] = radius

@property
def surface_gravity(self):
G = 6.67300E-11
return G * [Link] / ([Link] ** 2)

def __str__(self):
return f"{[Link]()} (g={self.surface_gravity:.2f})"

for p in Planet:
print(p)
# Mercury (g=3.70)
# Earth (g=9.80)

IntEnum, StrEnum, Flag

class Priority(IntEnum): # comparable to ints — good for legacy APIs


LOW = 1
HIGH = 3
print([Link] > 2) # True
print([Link] == 1) # True
print(sorted(Priority, reverse=True))

class Env(StrEnum): # 3.11+ — behaves like a str


DEV = "dev"
PROD = "prod"
print([Link] == "prod") # True
print(f"Running in {[Link]}") # Running in prod
print("prod".upper() == [Link]()) # True

class Perm(Flag): # bitwise combinable


READ = auto() # 1
WRITE = auto() # 2
EXEC = auto() # 4
ALL = READ | WRITE | EXEC

p = [Link] | [Link]
print(p) # [Link]|WRITE
print([Link] in p) # True
print([Link] in p) # False
print(p & [Link]) # [Link]
print(~p) # [Link]
print([Link]) # [Link]|WRITE|EXEC

Enum in practice — replacing magic strings

from enum import Enum

class OrderState(Enum):
DRAFT = "draft"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"

def can_transition_to(self, other):


allowed = {
[Link]: {[Link], [Link]},
[Link]: {[Link], [Link]},
[Link]: set(),
[Link]: set(),
}
return other in allowed[self]

class Order:
def __init__(self):
[Link] = [Link]

def transition(self, new_state):


if not [Link].can_transition_to(new_state):
raise ValueError(f"Cannot go {[Link]} -> {new_state.value}")
[Link] = new_state
return self

o = Order()
[Link]([Link]).transition([Link])
print([Link]) # [Link]
# [Link]([Link]) # ValueError: Cannot go shipped -> draft
22. Context Managers

The protocol

class ManagedFile:
def __init__(self, filename, mode="r"):
[Link], [Link] = filename, mode
[Link] = None

def __enter__(self):
print(f"Opening {[Link]}")
[Link] = open([Link], [Link])
return [Link] # this is what `as f` receives

def __exit__(self, exc_type, exc_value, traceback):


print(f"Closing {[Link]}")
if [Link]:
[Link]()
# Return True -> SUPPRESS the exception
# Return False/None -> let it propagate (the normal choice)
return False

with ManagedFile("/tmp/[Link]", "w") as f:


[Link]("hello")
# Opening /tmp/[Link]
# Closing /tmp/[Link] <- runs even if the body raises

What __exit__ receives

class Inspector:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
print(f"type = {exc_type}")
print(f"value = {exc_value}")
print(f"tb = {tb is not None}")
return False

with Inspector():
pass
# type = None / value = None / tb = False <- clean exit

try:
with Inspector():
raise ValueError("boom")
except ValueError:
print("caught outside")
# type = / value = boom / tb = True
# caught outside

Suppressing exceptions

class Suppress:
def __init__(self, *exceptions):
[Link] = exceptions
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
return exc_type is not None and issubclass(exc_type, [Link])

with Suppress(ZeroDivisionError):
print(1 / 0)
print("never reached")
print("continues normally")

# The stdlib version:


from contextlib import suppress
with suppress(FileNotFoundError):
open("/does/not/exist")
print("still running")

Returning a truthy value from __exit__ silently swallows exceptions. Do this only when suppression is the explicit purpose of the
manager. Returning True by accident (e.g. from a print() ... no; from a stray return [Link] ) hides real bugs.

@contextmanager — the concise way


from contextlib import contextmanager
import time

@contextmanager
def timer(label="block"):
start = time.perf_counter()
try:
yield start # everything before yield = __enter__
finally:
elapsed = time.perf_counter() - start # after yield = __exit__
print(f"{label} took {elapsed:.4f}s") # finally -> runs on exception too

with timer("sum"):
sum(range(1_000_000))
# sum took 0.0234s

@contextmanager
def transaction(conn):
try:
yield conn
[Link]()
except Exception:
[Link]()
raise # re-raise after cleanup
finally:
[Link]()

Always wrap the yield in try/finally. Without it, an exception in the with body skips your cleanup code entirely.

@contextmanager
def broken():
print("setup")
yield
print("cleanup") # NEVER RUNS if the body raises!

try:
with broken():
raise ValueError()
except ValueError:
pass
# setup <- 'cleanup' never printed

Reentrant and reusable managers

class NotReusable:
def __init__(self, name): [Link] = name
def __enter__(self):
self.f = open("/tmp/x", "w")
return self.f
def __exit__(self, *a):
[Link]()

cm = NotReusable("a")
with cm: pass
with cm: pass # works here, but many CMs (e.g. generator-based) do NOT

@contextmanager
def once():
yield
c = once()
with c: pass
# with c: pass # RuntimeError: generator didn't yield
# Generator-based context managers are SINGLE-USE.

ExitStack — dynamic numbers of managers


from contextlib import ExitStack

filenames = ["/tmp/[Link]", "/tmp/[Link]", "/tmp/[Link]"]


for f in filenames: open(f, "w").write("x")

with ExitStack() as stack:


files = [stack.enter_context(open(fn)) for fn in filenames]
# all files guaranteed closed, however many there are
print([[Link]() for f in files])

# Register arbitrary cleanup callbacks:


with ExitStack() as stack:
[Link](print, "cleanup 3") # LIFO order
[Link](print, "cleanup 2")
[Link](print, "cleanup 1")
print("body")
# body / cleanup 1 / cleanup 2 / cleanup 3

# Conditional / transferable cleanup:


def risky_setup():
with ExitStack() as stack:
f = stack.enter_context(open("/tmp/[Link]"))
# ... if something fails here, f is closed
stack.pop_all() # SUCCESS: transfer ownership, don't close
return f

Other contextlib helpers

from contextlib import closing, redirect_stdout, nullcontext


import io

# closing: turn any object with .close() into a context manager


class Conn:
def close(self): print("closed")
with closing(Conn()) as c:
pass # closed

# redirect_stdout: capture prints


buf = [Link]()
with redirect_stdout(buf):
print("captured!")
print(f"got: {[Link]().strip()}") # got: captured!

# nullcontext: an optional context manager


def process(file=None):
with (open(file) if file else nullcontext([Link])) as f:
pass # uniform code path either way

Async context managers

import asyncio

class AsyncResource:
async def __aenter__(self):
print("async acquiring")
await [Link](0.01)
return self
async def __aexit__(self, exc_type, exc, tb):
print("async releasing")
await [Link](0.01)
return False

async def main():


async with AsyncResource() as r:
print("using")

[Link](main())
# async acquiring / using / async releasing

from contextlib import asynccontextmanager


@asynccontextmanager
async def acm():
print("enter")
try:
yield "value"
finally:
print("exit")
23. Iterators and Generators as Classes

Iterable vs Iterator

Iterable Iterator

Must define __iter__ __iter__ AND __next__

Example list , dict , str iter([1,2]) , generators, files

Reusable Yes — new iterator each loop No — exhausts once

lst = [1, 2, 3]
it = iter(lst)
print(type(it)) #
print(next(it), next(it), next(it)) # 1 2 3
# next(it) # StopIteration
print(iter(it) is it) # True — an iterator's __iter__ returns itself

for x in lst: pass # each `for` calls iter(lst) -> a FRESH iterator
for x in lst: pass # so lists can be looped many times

A custom iterator class

class Countdown:
def __init__(self, start):
[Link] = start

def __iter__(self):
# Returns a NEW iterator each time -> reusable
return CountdownIterator([Link])

class CountdownIterator:
def __init__(self, current):
[Link] = current

def __iter__(self):
return self # iterators must return themselves

def __next__(self):
if [Link] <= 0:
raise StopIteration # the ONLY way to stop
[Link] -= 1
return [Link] + 1

c = Countdown(3)
print(list(c)) # [3, 2, 1]
print(list(c)) # [3, 2, 1] — reusable!

# Self-iterator anti-pattern — works once only:


class BadCountdown:
def __init__(self, start): [Link] = start
def __iter__(self): return self
def __next__(self):
if [Link] <= 0: raise StopIteration
[Link] -= 1
return [Link] + 1

b = BadCountdown(3)
print(list(b)) # [3, 2, 1]
print(list(b)) # [] <- EXHAUSTED. Common source of bugs.

Generators make this trivial

class Countdown2:
def __init__(self, start):
[Link] = start
def __iter__(self):
n = [Link]
while n > 0:
yield n # a generator function IS an iterator factory
n -= 1

c = Countdown2(3)
print(list(c), list(c)) # [3, 2, 1] [3, 2, 1] — reusable, 5 lines
# Python calls __iter__ fresh each time -> a new generator each time.

Generators: send , throw , close


def echo():
print("starting")
try:
while True:
received = yield # yield is an EXPRESSION here
print(f"got: {received}")
except GeneratorExit:
print("closing down")
finally:
print("cleanup")

g = echo()
next(g) # starting — must "prime" the generator
[Link]("hello") # got: hello
[Link]("world") # got: world
[Link]() # closing down / cleanup

def resilient():
while True:
try:
x = yield
print(f"processing {x}")
except ValueError as e:
print(f"recovered from: {e}")

r = resilient(); next(r)
[Link](1) # processing 1
[Link](ValueError("bad")) # recovered from: bad
[Link](2) # processing 2 — still alive

yield from and delegation

class Tree:
def __init__(self, value, children=None):
[Link] = value
[Link] = children or []

def __iter__(self):
yield [Link]
for child in [Link]:
yield from child # delegates — handles nesting elegantly

t = Tree(1, [Tree(2, [Tree(4), Tree(5)]), Tree(3)])


print(list(t)) # [1, 2, 4, 5, 3] — depth-first traversal in 4 lines

A lazy, infinite, memory-efficient pipeline


class DataPipeline:
def __init__(self, source):
[Link] = source
[Link] = []

def map(self, fn):


[Link](("map", fn)); return self # fluent interface

def filter(self, fn):


[Link](("filter", fn)); return self

def take(self, n):


[Link](("take", n)); return self

def __iter__(self):
it = iter([Link])
for kind, arg in [Link]:
if kind == "map": it = (arg(x) for x in it)
elif kind == "filter": it = (x for x in it if arg(x))
elif kind == "take":
it = self._take(it, arg)
return it

@staticmethod
def _take(it, n):
for i, x in enumerate(it):
if i >= n: return
yield x

def naturals():
n = 1
while True:
yield n
n += 1

p = (DataPipeline(naturals())
.filter(lambda x: x % 3 == 0)
.map(lambda x: x ** 2)
.take(5))
print(list(p)) # [9, 36, 81, 144, 225]
# Infinite source, constant memory, nothing computed until iterated.
24. Class Decorators
A class decorator is a function that takes a class and returns a class (usually the same one, modified). It is the simplest tool for cross-
cutting concerns — much lighter than a metaclass.

Adding behaviour to a class

import functools

def auto_repr(cls):
"""Add a __repr__ built from the instance __dict__."""
def __repr__(self):
args = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({args})"
cls.__repr__ = __repr__
return cls # MUST return the class

@auto_repr
class User:
def __init__(self, name, email):
[Link], [Link] = name, email

print(User("Ali", "a@[Link]")) # User(name='Ali', email='a@[Link]')

Decorators with arguments

def add_methods(**methods):
"""Inject arbitrary methods into a class."""
def decorator(cls):
for name, func in [Link]():
setattr(cls, name, func)
return cls
return decorator

@add_methods(
shout=lambda self: [Link](),
initials=lambda self: "".join(w[0] for w in [Link]()),
)
class Person:
def __init__(self, name): [Link] = name

p = Person("Ali Raza Khan")


print([Link]()) # ALI RAZA KHAN
print([Link]()) # ARK

Wrapping every method — logging / timing

import time, functools

def log_methods(prefix="LOG"):
def decorator(cls):
for name, attr in list(vars(cls).items()):
if callable(attr) and not [Link]("__"):
setattr(cls, name, _wrap(attr, cls.__name__, prefix))
return cls
return decorator

def _wrap(method, cls_name, prefix):


@[Link](method) # preserves __name__, __doc__, signature
def wrapper(self, *args, **kwargs):
start = time.perf_counter()
result = method(self, *args, **kwargs)
dur = time.perf_counter() - start
print(f"[{prefix}] {cls_name}.{method.__name__}{args} -> {result!r} ({dur*1000:.2f}ms)")
return result
return wrapper

@log_methods("TRACE")
class Calculator:
def add(self, a, b): return a + b
def mul(self, a, b): return a * b

c = Calculator()
[Link](2, 3) # [TRACE] [Link](2, 3) -> 5 (0.00ms)
[Link](4, 5) # [TRACE] [Link](4, 5) -> 20 (0.00ms)

Always use [Link] when wrapping. Without it the wrapper loses __name__ , __doc__ , __module__ , and __wrapped__ ,
which breaks introspection, help() , and debuggers.
Registration decorators

class Registry:
_handlers = {}

@classmethod
def register(cls, *event_types):
def decorator(handler_cls):
for et in event_types:
cls._handlers[et] = handler_cls
handler_cls.handles = event_types
return handler_cls
return decorator

@classmethod
def dispatch(cls, event_type, payload):
handler = cls._handlers.get(event_type)
if not handler:
raise KeyError(f"No handler for {event_type!r}")
return handler().handle(payload)

@[Link]("[Link]", "[Link]")
class UserHandler:
def handle(self, payload): return f"user handled: {payload}"

@[Link]("[Link]")
class OrderHandler:
def handle(self, payload): return f"order handled: {payload}"

print([Link]("[Link]", {"id": 1}))


print([Link]("[Link]", {"id": 9}))
print(Registry._handlers.keys())

Class decorator vs metaclass vs __init_subclass__

Class decorator __init_subclass__ Metaclass

Applies to Only the decorated class All subclasses, automatically All subclasses, automatically

Complexity Low Low High

Can control __prepare__ No No Yes

Can intercept instantiation No No Yes ( __call__ )

Inheritance conflicts None None Possible

Verdict Try first Try second Last resort


25. Copying, Pickling, Equality, Hashing

Assignment vs shallow copy vs deep copy

import copy

class Node:
def __init__(self, value, children=None):
[Link] = value
[Link] = children or []
def __repr__(self):
return f"Node({[Link]}, {[Link]})"

original = Node("root", [Node("child")])

alias = original # SAME object


shallow = [Link](original) # new Node, SHARED children list
deep = [Link](original) # fully independent

[Link](Node("new"))
print([Link]) # [Node(child), Node(new)] — same object
print([Link]) # [Node(child), Node(new)] — SHARED list!
print([Link]) # [Node(child)] — independent

print(alias is original) # True


print(shallow is original) # False
print([Link] is [Link]) # True <- the shallow trap
print([Link] is [Link]) # False

Customizing copy behaviour

import copy

class Connection:
def __init__(self, host, cache=None):
[Link] = host
[Link] = cache or {}
[Link] = f"" # NOT copyable

def __copy__(self):
cls = self.__class__
new = cls.__new__(cls) # bypass __init__
new.__dict__.update(self.__dict__) # shallow field copy
return new

def __deepcopy__(self, memo):


cls = self.__class__
new = cls.__new__(cls)
memo[id(self)] = new # register BEFORE recursing —
# this is what handles cycles
for k, v in self.__dict__.items():
if k == "socket":
setattr(new, k, f"") # recreate
else:
setattr(new, k, [Link](v, memo))
return new

def __repr__(self):
return f"Connection({[Link]}, cache={[Link]}, {[Link]})"

c = Connection("[Link]", {"k": [1, 2]})


d = [Link](c)
[Link]["k"].append(3)
print(c) # Connection([Link], cache={'k': [1, 2, 3]}, )
print(d) # Connection([Link], cache={'k': [1, 2]}, )

The memo dict maps id(original) -> copy . It prevents infinite recursion on cyclic structures and ensures an object referenced
twice is copied once.

# Cycles handled automatically:


a = Node("a"); b = Node("b")
[Link](b); [Link](a) # cycle!
d = [Link](a) # no infinite loop
print([Link][0].children[0] is d) # True — structure preserved

Pickling
import pickle

class Session:
def __init__(self, user, token):
[Link] = user
[Link] = token # sensitive — don't persist
[Link] = object() # unpicklable resource

def __getstate__(self):
"""Return what to pickle."""
state = self.__dict__.copy()
del state["token"] # exclude secrets
del state["connection"] # exclude unpicklable objects
return state

def __setstate__(self, state):


"""Restore from the pickled state."""
self.__dict__.update(state)
[Link] = None # re-derive / reset
[Link] = object() # re-acquire

def __repr__(self):
return f"Session(user={[Link]!r}, token={[Link]!r})"

s = Session("ali", "secret123")
data = [Link](s)
restored = [Link](data)
print(restored) # Session(user='ali', token=None)
print(b"secret123" in data) # False — secret was excluded

# __reduce__ — full control over reconstruction


class Point:
def __init__(self, x, y): self.x, self.y = x, y
def __reduce__(self):
# (callable, args_tuple) -> unpickling calls callable(*args)
return (self.__class__, (self.x, self.y))
def __repr__(self): return f"Point({self.x}, {self.y})"

print([Link]([Link](Point(1, 2)))) # Point(1, 2)

# With __slots__ there is no __dict__, so define __getstate__:


class Slotted:
__slots__ = ("a", "b")
def __init__(self, a, b): self.a, self.b = a, b
def __getstate__(self):
return {s: getattr(self, s) for s in self.__slots__}
def __setstate__(self, state):
for k, v in [Link](): setattr(self, k, v)
# (Python 3.11+ handles slots automatically, but being explicit is portable.)

NEVER unpickle untrusted data. __reduce__ can execute arbitrary code — [Link] on hostile input is remote code
execution. Use JSON for data you don't control.

The equality/hashing contract — full rules


1. If a == b , then hash(a) == hash(b) . (The reverse need not hold — collisions are fine.)
2. hash(x) must never change during the object's lifetime.
3. Defining __eq__ sets __hash__ = None unless you define __hash__ .
4. __eq__ should be reflexive, symmetric, and transitive.
5. Return NotImplemented for unrecognized types, never False .
# Symmetry matters — this class breaks it:
class Meters:
def __init__(self, v): self.v = v
def __eq__(self, other):
if isinstance(other, (int, float)):
return self.v == other # Meters(5) == 5 -> True
return NotImplemented

m = Meters(5)
print(m == 5) # True
print(5 == m) # True — int.__eq__ returns NotImplemented, Python tries
# the reflected m.__eq__(5). Symmetric by luck here.

# A real asymmetry bug:


class Case:
def __init__(self, s): self.s = s
def __eq__(self, o):
if isinstance(o, str): return [Link]() == [Link]()
if isinstance(o, Case): return [Link]() == [Link]()
return NotImplemented
def __hash__(self): return hash([Link]())

print(Case("Hi") == "hi") # True


print("hi" == Case("Hi")) # True (reflected)
print(hash(Case("hi")) == hash("hi"))# False! -> breaks dict lookups
d = {"hi": 1}
print(Case("hi") in d) # False — equal but different hash
26. Exceptions as Classes

The hierarchy

BaseException
├── SystemExit <- [Link]()
├── KeyboardInterrupt <- Ctrl+C
├── GeneratorExit <- [Link]()
└── Exception <- CATCH THIS, not BaseException
├── ArithmeticError -> ZeroDivisionError, OverflowError
├── LookupError -> IndexError, KeyError
├── OSError -> FileNotFoundError, PermissionError, TimeoutError
├── RuntimeError -> RecursionError, NotImplementedError
├── ValueError -> UnicodeError
├── TypeError
├── AttributeError
├── StopIteration
└── ...

except Exception catches almost everything. except BaseException or a bare except: also swallows Ctrl+C and SystemExit ,
making your program unkillable. Never use a bare except: .

A custom exception hierarchy

class AppError(Exception):
"""Base for ALL errors in this app — lets callers catch everything of ours."""

class ValidationError(AppError):
def __init__(self, field, value, message=None):
[Link] = field
[Link] = value
[Link] = message or f"Invalid value for {field!r}: {value!r}"
super().__init__([Link]) # sets args, powers str()

def __str__(self):
return [Link]

def to_dict(self):
return {"error": "validation", "field": [Link], "message": [Link]}

class NotFoundError(AppError):
def __init__(self, resource, id):
[Link], [Link] = resource, id
super().__init__(f"{resource} with id {id} not found")

class RateLimitError(AppError):
def __init__(self, retry_after):
self.retry_after = retry_after
super().__init__(f"Rate limited; retry after {retry_after}s")

try:
raise ValidationError("email", "not-an-email")
except ValidationError as e:
print(e) # Invalid value for 'email': 'not-an-email'
print([Link]) # email
print(e.to_dict())
except AppError:
print("some other app error") # catches the whole family

Always define one base exception per package/app. It lets consumers write except AppError to catch everything you raise,
without accidentally catching unrelated ValueError s from the stdlib.

Exception chaining
class DatabaseError(AppError): pass

def fetch_user(uid):
try:
return {1: "Ali"}[uid]
except KeyError as e:
# `from e` sets __cause__ -> "The above exception was the direct cause"
raise NotFoundError("User", uid) from e

try:
fetch_user(99)
except NotFoundError as e:
print(e) # User with id 99 not found
print(repr(e.__cause__)) # KeyError(99)
print(e.__context__) # 99 — set automatically even without `from`

# Suppress the chain when the original is noise:


def parse(s):
try:
return int(s)
except ValueError:
raise ValidationError("number", s) from None # __suppress_context__

try: parse("abc")
except ValidationError as e: print(e.__cause__) # None

Form Sets Traceback says

raise B from A __cause__ = A "direct cause of"

raise B inside except A __context__ = A "During handling... another occurred"

raise B from None __suppress_context__ = True (chain hidden)

Exception groups (Python 3.11+)

def validate_all(data):
errors = []
for field, value in [Link]():
try:
if not value:
raise ValidationError(field, value, f"{field} is required")
except ValidationError as e:
[Link](e)
if errors:
raise ExceptionGroup("Validation failed", errors)

try:
validate_all({"name": "", "email": "", "age": 30})
except* ValidationError as eg:
for e in [Link]:
print(f"- {e}")
# - name is required
# - email is required

Best practices

# BAD: swallows everything, hides bugs, unkillable


try:
risky()
except:
pass

# BAD: too broad, loses the traceback


try:
risky()
except Exception as e:
print(f"Error: {e}") # where did it happen? unknown.

# GOOD: specific, informative, re-raises what it can't handle


import logging
try:
risky()
except FileNotFoundError:
[Link]("Config missing; using defaults")
config = default_config()
except PermissionError as e:
[Link]("Cannot read config") # .exception logs the traceback
raise ConfigError("Check file permissions") from e
else:
[Link]("Config loaded") # runs only if NO exception
finally:
cleanup() # ALWAYS runs

else in try/except: code that should run only on success, but that you do not want inside the try (so its own exceptions aren't
caught by your handlers). Underused and very useful.
27. SOLID Principles

S — Single Responsibility Principle


A class should have one reason to change.

# BAD: this class changes if the DB changes, OR the email template changes,
# OR the report format changes. Three reasons.
class UserBad:
def __init__(self, name, email): [Link], [Link] = name, email
def save_to_db(self): ...
def send_welcome_email(self): ...
def generate_pdf_report(self): ...

# GOOD: one reason to change each


class User:
def __init__(self, name, email): [Link], [Link] = name, email

class UserRepository:
def save(self, user): print(f"INSERT {[Link]}")

class EmailService:
def send_welcome(self, user): print(f"Emailing {[Link]}")

class UserReportGenerator:
def to_pdf(self, user): print(f"PDF for {[Link]}")

u = User("Ali", "a@[Link]")
UserRepository().save(u)
EmailService().send_welcome(u)

O — Open/Closed Principle
Open for extension, closed for modification.

# BAD: adding a payment method means EDITING this class


class ProcessorBad:
def pay(self, method, amount):
if method == "card": print(f"card {amount}")
elif method == "cash": print(f"cash {amount}")
# elif method == "jazzcash": ... <- must edit tested code

# GOOD: add a new class; never touch the existing ones


from abc import ABC, abstractmethod

class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount): ...

class Card(PaymentMethod):
def pay(self, amount): return f"Card: {amount}"
class Cash(PaymentMethod):
def pay(self, amount): return f"Cash: {amount}"
class JazzCash(PaymentMethod): # NEW — zero edits elsewhere
def pay(self, amount): return f"JazzCash: {amount}"

class Processor:
def process(self, method: PaymentMethod, amount):
return [Link](amount)

p = Processor()
print([Link](JazzCash(), 500))

L — Liskov Substitution Principle


Subtypes must be substitutable for their base types. (See the square/rectangle example in chapter 8.)
# BAD: the subclass removes capability
class Bird:
def fly(self): return "flying"
class Penguin(Bird):
def fly(self): raise NotImplementedError("Penguins can't fly") # LSP break

def migrate(birds):
for b in birds: print([Link]()) # crashes on Penguin

# GOOD: model the real capability boundary


class Bird2:
def move(self): return "moving"
class FlyingBird(Bird2):
def move(self): return "flying"
class SwimmingBird(Bird2):
def move(self): return "swimming"

class Eagle(FlyingBird): pass


class Penguin2(SwimmingBird): pass

for b in [Eagle(), Penguin2()]:


print([Link]()) # flying / swimming — no exceptions, fully substitutable

I — Interface Segregation Principle


No client should be forced to depend on methods it doesn't use.

# BAD: a fat interface


class WorkerBad(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...

class Robot(WorkerBad):
def work(self): return "working"
def eat(self): raise NotImplementedError("robots don't eat") # forced!

# GOOD: small, focused interfaces


class Workable(ABC):
@abstractmethod
def work(self): ...
class Eatable(ABC):
@abstractmethod
def eat(self): ...

class Human(Workable, Eatable):


def work(self): return "working"
def eat(self): return "eating"

class Robot2(Workable): # only implements what makes sense


def work(self): return "working"

def run_shift(workers: list[Workable]):


return [[Link]() for w in workers]
print(run_shift([Human(), Robot2()]))

D — Dependency Inversion Principle


Depend on abstractions, not concretions. High-level modules should not import low-level ones.
# BAD: the high-level class hardcodes a low-level dependency
class MySQLDatabase:
def save(self, d): print(f"MySQL: {d}")

class UserServiceBad:
def __init__(self):
[Link] = MySQLDatabase() # locked in. Untestable without MySQL.
def register(self, u): [Link](u)

# GOOD: depend on an abstraction, inject the concrete implementation


class Database(ABC):
@abstractmethod
def save(self, data): ...

class MySQL(Database):
def save(self, d): print(f"MySQL: {d}")
class Postgres(Database):
def save(self, d): print(f"Postgres: {d}")
class InMemoryDB(Database): # perfect test double
def __init__(self): [Link] = []
def save(self, d): [Link](d)

class UserService:
def __init__(self, db: Database): # DEPENDENCY INJECTION
[Link] = db
def register(self, user):
[Link](user)

UserService(Postgres()).register("Ali") # Postgres: Ali

fake = InMemoryDB() # testing is now trivial


UserService(fake).register("TestUser")
assert [Link] == ["TestUser"]
28. Design Patterns in Python

Many classic GoF patterns exist to work around limitations Python doesn't have. Strategy is often just a function. Command is
often just a closure. Singleton is often just a module. Use the pattern only when the extra structure earns its keep.

Strategy

# Classic OOP version


class SortStrategy(ABC):
@abstractmethod
def sort(self, data): ...

class QuickSort(SortStrategy):
def sort(self, data): return sorted(data)
class ReverseSort(SortStrategy):
def sort(self, data): return sorted(data, reverse=True)

class Sorter:
def __init__(self, strategy: SortStrategy):
[Link] = strategy
def sort(self, data):
return [Link](data)

print(Sorter(ReverseSort()).sort([3, 1, 2])) # [3, 2, 1]

# PYTHONIC version — functions are first-class. Same flexibility, no classes.


class Sorter2:
def __init__(self, strategy=sorted):
[Link] = strategy
def sort(self, data):
return [Link](data)

print(Sorter2(lambda d: sorted(d, reverse=True)).sort([3, 1, 2])) # [3, 2, 1]

Factory / Factory Method

class Notification(ABC):
@abstractmethod
def send(self, msg): ...

class Email(Notification):
def send(self, msg): return f"Email: {msg}"
class SMS(Notification):
def send(self, msg): return f"SMS: {msg}"
class Push(Notification):
def send(self, msg): return f"Push: {msg}"

class NotificationFactory:
_registry = {"email": Email, "sms": SMS, "push": Push}

@classmethod
def create(cls, kind) -> Notification:
try:
return cls._registry[kind]()
except KeyError:
raise ValueError(f"Unknown type {kind!r}. "
f"Available: {list(cls._registry)}") from None

@classmethod
def register(cls, kind, klass):
cls._registry[kind] = klass # extensible at runtime

print([Link]("sms").send("hi")) # SMS: hi

class WhatsApp(Notification):
def send(self, msg): return f"WhatsApp: {msg}"
[Link]("whatsapp", WhatsApp)
print([Link]("whatsapp").send("hello"))

Observer
class Subject:
def __init__(self):
self._observers = []

def attach(self, observer):


self._observers.append(observer)
return observer # allows use as a decorator

def detach(self, observer):


self._observers.remove(observer)

def notify(self, event, data=None):


for obs in list(self._observers): # copy: handlers may detach
obs(event, data) if callable(obs) else [Link](event, data)

class Logger:
def update(self, event, data): print(f"[LOG] {event}: {data}")

class Store(Subject):
def __init__(self):
super().__init__()
self._stock = {}

def add(self, item, qty):


self._stock[item] = self._stock.get(item, 0) + qty
[Link]("[Link]", {"item": item, "qty": qty})
if self._stock[item] > 100:
[Link]("[Link]", {"item": item})

store = Store()
[Link](Logger())

@[Link] # plain functions work too


def alert(event, data):
if event == "[Link]":
print(f"!! ALERT: too much {data['item']}")

[Link]("pens", 50)
[Link]("pens", 60)
# [LOG] [Link]: {'item': 'pens', 'qty': 50}
# [LOG] [Link]: {'item': 'pens', 'qty': 60}
# [LOG] [Link]: {'item': 'pens'}
# !! ALERT: too much pens

Builder

class Query:
def __init__(self):
self._table = None
self._cols = ["*"]
self._where = []
self._order = None
self._limit = None

def table(self, t): self._table = t; return self # fluent


def select(self, *c): self._cols = list(c); return self
def where(self, cond): self._where.append(cond); return self
def order_by(self, c): self._order = c; return self
def limit(self, n): self._limit = n; return self

def build(self):
if not self._table:
raise ValueError("table is required")
sql = f"SELECT {', '.join(self._cols)} FROM {self._table}"
if self._where: sql += " WHERE " + " AND ".join(self._where)
if self._order: sql += f" ORDER BY {self._order}"
if self._limit: sql += f" LIMIT {self._limit}"
return sql

print(Query().table("users").select("id", "name")
.where("age > 18").where("active = 1")
.order_by("name").limit(10).build())
# SELECT id, name FROM users WHERE age > 18 AND active = 1 ORDER BY name LIMIT 10

Adapter
class ModernLogger: # the interface our app expects
def log(self, level, msg): ...

class LegacyLogger: # third-party, incompatible


def write_line(self, text): print(f"LEGACY >> {text}")

class LegacyAdapter(ModernLogger):
def __init__(self, legacy):
[Link] = legacy
def log(self, level, msg):
[Link].write_line(f"[{[Link]()}] {msg}")

def app(logger: ModernLogger):


[Link]("info", "started")

app(LegacyAdapter(LegacyLogger())) # LEGACY >> [INFO] started

Singleton — and why a module is usually better

# Pythonic approach: a module IS a singleton. It's imported once and cached.


# [Link]
# settings = {"debug": True}
# [Link]
# from config import settings <- same object everywhere. Done.

# If you truly need a class-based singleton, prefer the metaclass version


# from chapter 19, or simply:
class _Registry:
def __init__(self): [Link] = {}
registry = _Registry() # create one instance; export that name

Template Method

class DataProcessor(ABC):
def run(self, source): # the TEMPLATE — fixed skeleton
data = [Link](source)
data = [Link](data)
data = [Link](data)
return [Link](data)

@abstractmethod
def read(self, source): ...
@abstractmethod
def transform(self, data): ...

def validate(self, data): # optional hook with a default


if not data: raise ValueError("no data")
return data

def write(self, data): # default implementation


print(f"Writing {len(data)} rows")
return data

class CSVProcessor(DataProcessor):
def read(self, source): return [Link]().split("\n")
def transform(self, data): return [[Link](",") for r in data]

print(CSVProcessor().run("a,1\nb,2"))
# Writing 2 rows
# [['a', '1'], ['b', '2']]
29. Testing OOP Code

Why dependency injection makes testing easy

import unittest
from [Link] import Mock, MagicMock, patch, call

class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount) -> bool: ...

class OrderService:
def __init__(self, gateway: PaymentGateway, repo):
[Link] = gateway
[Link] = repo

def checkout(self, order):


if order["total"] <= 0:
raise ValueError("Invalid total")
if not [Link](order["total"]):
raise RuntimeError("Payment declined")
order["status"] = "paid"
[Link](order)
return order

class TestOrderService([Link]):
def setUp(self):
[Link] = Mock(spec=PaymentGateway) # spec -> typo-proof mock
[Link] = Mock()
[Link] = OrderService([Link], [Link])

def test_successful_checkout(self):
[Link].return_value = True
order = [Link]({"total": 100})

[Link](order["status"], "paid")
[Link].assert_called_once_with(100)
[Link].assert_called_once()

def test_declined_payment(self):
[Link].return_value = False
with [Link](RuntimeError):
[Link]({"total": 100})
[Link].assert_not_called() # nothing persisted

def test_invalid_total(self):
with [Link](ValueError, "Invalid total"):
[Link]({"total": 0})
[Link].assert_not_called()

def test_gateway_exception_propagates(self):
[Link].side_effect = ConnectionError("network down")
with [Link](ConnectionError):
[Link]({"total": 50})

# [Link]() -> all 4 tests pass, zero real payments made

Mock essentials
m = Mock()
[Link](1, 2, key="v")
[Link](3)

print([Link]) # True
print([Link].call_count) # 2
print([Link].call_args) # call(3) — the LAST call
print([Link].call_args_list) # [call(1, 2, key='v'), call(3)]
[Link].assert_any_call(1, 2, key="v")
[Link].assert_has_calls([call(1, 2, key="v"), call(3)])

# return_value vs side_effect
m2 = Mock(return_value=42)
print(m2(), m2()) # 42 42 — always the same

m3 = Mock(side_effect=[1, 2, 3]) # a different value each call


print(m3(), m3(), m3()) # 1 2 3

m4 = Mock(side_effect=ValueError("boom")) # raise
# m4() # ValueError: boom

m5 = Mock(side_effect=lambda x: x * 2) # compute
print(m5(5)) # 10

# spec: the mock only allows real attributes -> catches typos
real = Mock(spec=PaymentGateway)
# [Link](1) # AttributeError: Mock object has no
# attribute 'charrge'
# Without spec, [Link](1) silently "works" and your test passes wrongly.

Patching

import requests

class WeatherClient:
def get_temp(self, city):
r = [Link](f"[Link]
r.raise_for_status()
return [Link]()["temp"]

class TestWeather([Link]):
@patch("__main__.[Link]") # patch WHERE IT'S USED
def test_get_temp(self, mock_get):
mock_get.return_value.json.return_value = {"temp": 35}
mock_get.return_value.raise_for_status.return_value = None

[Link](WeatherClient().get_temp("Lahore"), 35)
mock_get.assert_called_once_with("[Link]

def test_with_context_manager(self):
with [Link](WeatherClient, "get_temp", return_value=99) as m:
[Link](WeatherClient().get_temp("x"), 99)
m.assert_called_once()

Patch where the object is looked up, not where it's defined. If [Link] does from requests import get , you must patch
[Link] — not [Link] . This is the single most common mocking mistake.

Fakes over mocks

# A hand-written fake is often clearer and less brittle than a mock:


class FakeRepo:
def __init__(self): [Link] = []
def save(self, obj): [Link](obj)
def get(self, id): return next((o for o in [Link] if o["id"] == id), None)

def test_with_fake():
repo = FakeRepo()
svc = OrderService(Mock(spec=PaymentGateway), repo)
[Link].return_value = True
[Link]({"id": 1, "total": 50})
assert len([Link]) == 1
assert [Link](1)["status"] == "paid" # test real behaviour, not calls
test_with_fake()

Testing ABCs and inheritance


class ShapeTestMixin:
"""Reusable contract test — every Shape subclass must pass these."""
shape_class = None
args = ()

def test_area_is_positive(self):
[Link](self.shape_class(*[Link]).area(), 0)

def test_implements_interface(self):
[Link](hasattr(self.shape_class(*[Link]), "area"))

class TestCircle(ShapeTestMixin, [Link]):


shape_class = Circle
args = (5,)
def test_specific_area(self):
[Link](Circle(1).area(), 3.14159, places=4)

class TestRectangle(ShapeTestMixin, [Link]):


shape_class = Rectangle
args = (2, 3)
# Both classes inherit the contract tests for free.
30. Common Mistakes and Best Practices

The mistake checklist

# Mistake Fix

1 Mutable class attribute ( tricks = [] ) Initialize in __init__

2 Mutable default arg ( def f(x=[]) ) x=None then x or []

3 Forgetting super().__init__() Always call it

4 [Link](self) instead of super() Use super() — MRO-safe

5 [Link] += 1 for a class counter [Link] += 1

6 __eq__ without __hash__ Define both, or use @dataclass(frozen=True)

7 Returning False from __eq__ for unknown types Return NotImplemented

8 Getters/setters everywhere (Java habit) Public attributes; @property when needed

9 Deep inheritance trees Composition; max 2–3 levels

10 Bare except: Catch specific exceptions

11 Relying on __del__ for cleanup Context managers

12 Missing __repr__ Always define it

13 Self-iterator (exhausts once) __iter__ returns a new iterator/generator

14 Metaclass where a decorator suffices Decorator / __init_subclass__

15 Recursion in __setattr__ super().__setattr__(...)

16 God class doing everything Single Responsibility

17 __slots__ missing on a subclass Define it in every class of the chain

18 @contextmanager without try/finally Wrap the yield

19 Hardcoded dependencies Inject them

20 Patching where defined, not used Patch the lookup location

The one-page style guide

# NAMING
class HTTPResponseHandler: # PascalCase, no underscores
MAX_RETRIES = 3 # UPPER_SNAKE for constants
def parse_body(self): # snake_case for methods
self.status_code = 0 # snake_case for attributes
self._cache = {} # single _ = internal
self.__id = 1 # double __ = mangled (rare)

# ORDER within a class body:


class WellOrdered:
"""1. Docstring."""
CONSTANT = 1 # 2. Class constants
field: int = 0 # 3. Class attributes
def __init__(self): ... # 4. __init__
def __repr__(self): ... # 5. Other dunders
@property
def value(self): ... # 6. Properties
@classmethod
def create(cls): ... # 7. Class methods
@staticmethod
def helper(): ... # 8. Static methods
def public_api(self): ... # 9. Public methods
def _internal(self): ... # 10. Private methods

Decision guide: which construct?

Need Use

Simple data container @dataclass

Immutable data container @dataclass(frozen=True) or NamedTuple

Fixed set of named values Enum

Validate one attribute in one class @property

Validate many attributes across classes Descriptor


Enforce an interface (your hierarchy) ABC + @abstractmethod

Enforce an interface (foreign classes) [Link]

Add a capability to many classes Mixin

Auto-register subclasses __init_subclass__

Modify one class Class decorator

Control class creation itself Metaclass (last resort)

Guaranteed setup/teardown Context manager

Lazy sequence Generator in __iter__

Millions of instances __slots__

Reuse behaviour Composition first, inheritance second

The overarching rule: start with the simplest thing — a function, then a plain class, then a dataclass. Add properties, ABCs,
descriptors, and metaclasses only when a concrete problem demands them. Python rewards restraint.
31. Capstone Project
A small inventory/point-of-sale system that exercises nearly everything in this guide: ABCs, dataclasses, enums, descriptors,
properties, dunders, context managers, generators, exceptions, mixins, the observer pattern, and dependency injection.

from __future__ import annotations


from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, auto
from typing import Iterator, Protocol
import copy
import uuid

# ============ EXCEPTIONS ============


class ShopError(Exception):
"""Base for every error this package raises."""

class OutOfStockError(ShopError):
def __init__(self, sku, requested, available):
[Link], [Link], [Link] = sku, requested, available
super().__init__(
f"{sku}: requested {requested}, only {available} available")

class InvalidTransition(ShopError):
pass

# ============ DESCRIPTOR ============


class Positive:
"""Reusable validating descriptor — see chapter 18."""
def __set_name__(self, owner, name):
[Link] = "_" + name
[Link] = name

def __get__(self, obj, objtype=None):


if obj is None:
return self
return getattr(obj, [Link])

def __set__(self, obj, value):


if not isinstance(value, (int, float)):
raise TypeError(f"{[Link]} must be numeric")
if value < 0:
raise ValueError(f"{[Link]} cannot be negative, got {value}")
setattr(obj, [Link], value)

# ============ ENUM ============


class OrderState(Enum):
DRAFT = auto()
PAID = auto()
SHIPPED = auto()
CANCELLED = auto()

def can_go_to(self, other: OrderState) -> bool:


transitions = {
[Link]: {[Link], [Link]},
[Link]: {[Link], [Link]},
[Link]: set(),
[Link]: set(),
}
return other in transitions[self]

# ============ VALUE OBJECT (frozen dataclass) ============


@dataclass(frozen=True, slots=True)
class Money:
amount: float
currency: str = "PKR"

def __post_init__(self):
if [Link] < 0:
raise ValueError("Money cannot be negative")

def __add__(self, other):


if isinstance(other, Money):
if [Link] != [Link]:
raise ValueError("Currency mismatch")
return Money([Link] + [Link], [Link])
return NotImplemented

def __radd__(self, other):


return self if other == 0 else self.__add__(other) # enables sum()

def __mul__(self, n):


if isinstance(n, (int, float)):
return Money([Link] * n, [Link])
return NotImplemented

__rmul__ = __mul__

def __str__(self):
return f"{[Link]:,.2f} {[Link]}"

# ============ MIXIN ============


class ReprMixin:
def __repr__(self):
args = ", ".join(f"{k}={v!r}" for k, v in vars(self).items()
if not [Link]("_"))
return f"{type(self).__name__}({args})"

# ============ ABC ============


class Discount(ABC):
"""Open/Closed: add new discounts without editing existing code."""
@abstractmethod
def apply(self, subtotal: Money) -> Money: ...

@property
def label(self) -> str:
return type(self).__name__

class NoDiscount(Discount):
def apply(self, subtotal): return subtotal

class PercentOff(Discount):
def __init__(self, pct):
if not 0 <= pct <= 100:
raise ValueError("pct must be 0-100")
[Link] = pct
def apply(self, subtotal):
return Money([Link] * (1 - [Link] / 100), [Link])
@property
def label(self): return f"{[Link]}% off"

class BulkDiscount(Discount):
def __init__(self, threshold: Money, off: Money):
[Link], [Link] = threshold, off
def apply(self, subtotal):
if [Link] >= [Link]:
return Money([Link] - [Link], [Link])
return subtotal

# ============ PROTOCOL (structural typing) ============


class Repository(Protocol):
def save(self, obj) -> None: ...
def all(self) -> list: ...

class InMemoryRepo:
def __init__(self): self._items = []
def save(self, obj): self._items.append(obj)
def all(self): return list(self._items)

# ============ ENTITY ============


class Product(ReprMixin):
price = Positive() # descriptors, declared once
stock = Positive()

def __init__(self, sku, name, price, stock=0):


[Link], [Link] = sku, name
[Link] = price # goes through the descriptor
[Link] = stock

@property
def value(self) -> Money:
return Money([Link]) * [Link]

@property
def in_stock(self) -> bool:
return [Link] > 0

def reserve(self, qty):


if qty > [Link]:
raise OutOfStockError([Link], qty, [Link])
[Link] -= qty

def __eq__(self, other):


if not isinstance(other, Product): return NotImplemented
return [Link] == [Link]
def __hash__(self):
return hash([Link])

@dataclass
class LineItem:
product: Product
qty: int

def __post_init__(self):
if [Link] <= 0:
raise ValueError("qty must be positive")

@property
def total(self) -> Money:
return Money([Link]) * [Link]

# ============ AGGREGATE + OBSERVER ============


class Order(ReprMixin):
def __init__(self, discount: Discount | None = None):
[Link] = str(uuid.uuid4())[:8]
[Link] = [Link]()
self._items: list[LineItem] = []
self._state = [Link]
self._discount = discount or NoDiscount()
self._observers = []

# --- observer ---


def subscribe(self, fn):
self._observers.append(fn)
return fn # usable as a decorator
def _emit(self, event, **data):
for fn in list(self._observers):
fn(event, data)

# --- state machine ---


@property
def state(self): return self._state

def transition(self, new: OrderState):


if not self._state.can_go_to(new):
raise InvalidTransition(
f"{self._state.name} -> {[Link]} not allowed")
old, self._state = self._state, new
self._emit("[Link]", old=[Link], new=[Link])
return self

# --- container protocol ---


def __len__(self): return len(self._items)
def __iter__(self) -> Iterator[LineItem]: return iter(self._items)
def __getitem__(self, i): return self._items[i]
def __contains__(self, product): return any([Link] == product
for li in self._items)
def __bool__(self): return bool(self._items)

def add(self, product: Product, qty=1):


if self._state is not [Link]:
raise InvalidTransition("Can only add items to a DRAFT order")
[Link](qty) # raises OutOfStockError
for li in self._items:
if [Link] == product: # merge duplicates
[Link] += qty
break
else:
self._items.append(LineItem(product, qty))
self._emit("[Link]", sku=[Link], qty=qty)
return self # fluent

# --- money ---


@property
def subtotal(self) -> Money:
return sum(([Link] for li in self._items), Money(0))
@property
def total(self) -> Money:
return self._discount.apply([Link])
@property
def savings(self) -> Money:
return Money([Link] - [Link])

def receipt(self) -> str:


lines = [f"Order {[Link]} [{self._state.name}]", "-" * 42]
for li in self:
[Link](f"{[Link]:<20} x{[Link]:<3} {[Link]:>14}")
[Link]("-" * 42)
[Link](f"{'Subtotal':<24}{[Link]:>18}")
if [Link]:
[Link](f"{self._discount.label:<24}{'-' + str([Link]):>18}")
[Link](f"{'TOTAL':<24}{[Link]:>18}")
return "\n".join(lines)

# ============ CONTEXT MANAGER ============


@contextmanager
def atomic(order: Order):
"""Roll the order back if anything in the block fails."""
snapshot = [Link](order._items)
stock_snapshot = {[Link]: [Link] for li in order._items}
try:
yield order
except ShopError:
order._items = snapshot
for li in order._items:
if [Link] in stock_snapshot:
[Link] = stock_snapshot[[Link]]
raise
finally:
pass

# ============ SERVICE (dependency injection) ============


class Checkout:
def __init__(self, repo: Repository):
[Link] = repo # injected -> testable

def complete(self, order: Order) -> Order:


if not order:
raise ShopError("Cannot checkout an empty order")
[Link]([Link])
[Link](order)
return order

# ============ GENERATOR ============


def low_stock(products, threshold=5):
"""Lazy — scans a million products in constant memory."""
yield from (p for p in products if [Link] < threshold)

# ============ DEMO ============


if __name__ == "__main__":
pen = Product("SKU1", "Blue Pen", 50, stock=100)
book = Product("SKU2", "Notebook", 350, stock=20)
bag = Product("SKU3", "School Bag", 1800, stock=3)

order = Order(discount=PercentOff(10))

@[Link]
def audit(event, data):
print(f" [audit] {event} {data}")

[Link](pen, 10).add(book, 2).add(bag, 1)


print()
print([Link]())
print()
print(f"items={len(order)} truthy={bool(order)} has_pen={pen in order}")

repo = InMemoryRepo()
Checkout(repo).complete(order)
print(f"\nstate={[Link]} saved={len([Link]())}")

# rollback demo
order2 = Order()
try:
with atomic(order2):
[Link](pen, 5)
[Link](bag, 99) # OutOfStockError -> rollback
except OutOfStockError as e:
print(f"\nRolled back: {e}")
print(f"order2 items after rollback: {len(order2)}")

print(f"\nLow stock: {[[Link] for p in low_stock([pen, book, bag])]}")

try:
[Link]([Link])
except InvalidTransition as e:
print(f"Blocked: {e}")

Expected output
[audit] [Link] {'sku': 'SKU1', 'qty': 10}
[audit] [Link] {'sku': 'SKU2', 'qty': 2}
[audit] [Link] {'sku': 'SKU3', 'qty': 1}

Order 3f8a1c2b [DRAFT]


------------------------------------------
Blue Pen x10 500.00 PKR
Notebook x2 700.00 PKR
School Bag x1 1,800.00 PKR
------------------------------------------
Subtotal 3,000.00 PKR
10% off -300.00 PKR
TOTAL 2,700.00 PKR

items=3 truthy=True has_pen=True


[audit] [Link] {'old': 'DRAFT', 'new': 'PAID'}

state=PAID saved=1

Rolled back: SKU3: requested 99, only 2 available


order2 items after rollback: 0

Low stock: ['School Bag']


Blocked: PAID -> DRAFT not allowed

What the capstone demonstrates

Concept Where

Custom exception hierarchy ShopError and subclasses

Descriptors Positive on [Link]/stock

Enum with behaviour OrderState.can_go_to

Frozen dataclass + slots Money

Operator overloading + __radd__ Money.__add__ enabling sum()

Mixin ReprMixin

ABC / Open-Closed Discount hierarchy

Protocol / structural typing Repository

Properties value , total , savings

Container dunders Order.__len__/__iter__/__contains__

__eq__ + __hash__ contract Product

Observer pattern subscribe / _emit

Fluent interface [Link](...).add(...)

Context manager atomic() rollback

Generator low_stock()

Dependency injection Checkout(repo)

State machine [Link]

Exercises to extend it
1. Add a StackedDiscount(Discount) that composes several discounts.
2. Make Order picklable — note the observer list holds functions.
3. Add __slots__ to Product — what breaks, and why?
4. Replace Positive with a generic Range(min, max) descriptor.
5. Write unit tests with a FakeRepo ; assert OutOfStockError carries the right attributes.
6. Add __init_subclass__ to Discount that auto-registers each subclass by label.
7. Convert atomic() to a class-based context manager with __enter__ / __exit__ .
8. Add an async checkout using __aenter__ / __aexit__ .

Where to go next
Python docs — Data model: the definitive dunder reference ( [Link]/3/reference/[Link] )
Descriptor HowTo Guide in the official docs — by Raymond Hettinger
Fluent Python (Ramalho) — the best deep dive on the data model
Architecture Patterns with Python (Percival & Gregory) — repositories, unit of work, DDD
Read the stdlib source: [Link] , [Link] , [Link] , [Link] . They are excellent, readable OOP.
Final advice: mastery here is not about using every feature. It's knowing that @dataclass handles 80% of your classes, that
composition beats inheritance most of the time, and that a metaclass is almost never the answer. The advanced machinery exists
so that libraries can be simple for you — you rarely need to write it yourself.

You might also like