Annotations document expected types. They are not enforced at runtime — they exist for editors, linters, and type checkers like mypy.
def greet(name: str, times: int = 1) -> str:
return f"hi {name} " * times
age: int = 30 # variable annotation
# Built-in generics (Python 3.9+)
nums: list[int] = [1, 2, 3]
scores: dict[str, int] = {"a": 1}
pair: tuple[int, str] = (1, "x")
# "Optional" / union types (Python 3.10+ uses the | operator)
def find(x: int) -> str | None: # returns a str OR None
return None
from typing import Optional, Callable, Any
val: Optional[int] = None # same as: int | None
cb: Callable[[int], str] = str # a function taking int, returning str
anything: Any = "escape hatch" # disables type checking for this name
# Final: declares a constant — enforced by TYPE CHECKERS only; the
# interpreter never blocks the reassignment (Python has no true const)
from typing import Final
MAX_SIZE: Final = 100
MAX_SIZE = 200 # runs fine; mypy reports an error
# For runtime-protected constants use an Enum: members can't be
# reassigned (Color.RED = 99 -> AttributeError) — see Enums below.Python has no truly private members — privacy is by convention:
_name— a hint meaning "internal, please don't touch". Nothing enforces it.__name— triggers name mangling: Python rewrites it to_ClassName__name, mainly to avoid accidental clashes in subclasses (not real hiding).
class Account:
def __init__(self):
self.owner = "Alice" # public
self._balance = 100 # "internal" by convention
self.__pin = 1234 # mangled to _Account__pin
a = Account()
a.owner # "Alice"
a._balance # 100 -> still accessible; underscore is just a signal
# a.__pin # AttributeError
a._Account__pin # 1234 -> the real (mangled) nameThe Pythonic way to add getters/setters/validation while keeping plain attribute syntax (no parentheses on access).
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self): # getter: read like an attribute
return self._radius
@radius.setter
def radius(self, value): # setter: runs on assignment
if value < 0:
raise ValueError("radius must be >= 0")
self._radius = value
@property
def area(self): # computed, read-only (no setter defined)
return 3.14159 * self._radius ** 2
c = Circle(5)
c.radius # 5 -> calls the getter (no parentheses!)
c.radius = 10 # calls the setter (validates first)
c.area # 314.159 -> recomputed each access
# c.area = 1 # AttributeError: can't set (read-only)Attributes can be added or removed on instances (and classes) at runtime —
they live in a __dict__.
class Dog:
pass
d = Dog()
d.name = "Rex" # add an attribute at runtime
d.name # "Rex"
del d.name # remove it
d.__dict__ # {} -> instance attributes are stored here
# Adding to the CLASS affects all instances — even existing ones
existing = Dog()
Dog.species = "canine" # add a class attribute at runtime
existing.species # "canine" -> the already-created instance sees it
Dog().species # "canine" -> and so does a brand-new one
del Dog.species # remove it again (gone for all instances)
# Methods are just CALLABLE attributes — one can be added at runtime
Dog.speak = lambda self: "woof"
d.speak() # "woof" -> existing instances get it too
# Functions are objects too, and DO accept arbitrary attributes
def greet():
return "hi"
greet.calls = 0 # e.g. tag metadata onto the function
greet.__dict__ # {'calls': 0}
# Built-in ("primitive") types are sealed, for two separate reasons:
# their instances carry NO __dict__ (nowhere to store an attribute —
# also what __slots__ does, below), and the types themselves are
# immutable, so they can't be monkey-patched either.
# (5).x = 1 # AttributeError: ...no __dict__ for setting attributes
# int.x = 1 # TypeError: cannot set 'x' attribute of
# # immutable type 'int'
# str.shout = f # TypeError — no monkey-patching built-ins
# The way to "extend" a built-in is to subclass it
class MyStr(str):
def shout(self):
return self.upper() + "!"
MyStr("hi").shout() # "HI!"
# __slots__ PREVENTS arbitrary attributes (less memory, fixed names)
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
# p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
# Point objects also have no __dict__Inspect and manipulate attributes by name at runtime.
class User:
def __init__(self, name):
self.name = name
def greet(self):
return f"hi {self.name}"
u = User("Alice")
hasattr(u, "name") # True
getattr(u, "name") # "Alice"
getattr(u, "age", 0) # 0 -> default returned if attribute is missing
setattr(u, "age", 30) # same as: u.age = 30
delattr(u, "age") # same as: del u.age
# Enumerate attributes & methods
dir(u) # list ALL names (attributes + methods + inherited)
vars(u) # {'name': 'Alice'} -> instance attrs (its __dict__)
u.__dict__ # same as vars(u)
User.__dict__ # the class's own members (methods live here)
# Only the "own" (non-dunder) names:
[a for a in dir(u) if not a.startswith("__")] # ['greet', 'name']
# Just the callable attributes (i.e. the methods):
[a for a in dir(u) if callable(getattr(u, a))
and not a.startswith("__")] # ['greet']
# Type checks
isinstance(u, User) # True
isinstance(5, (int, float)) # True -> tuple means "any of these types"
issubclass(bool, int) # True -> bool is a subclass of int
type(u) is User # True -> exact type, ignores inheritancePython has no interface keyword, but abc provides the equivalent: a base
class that defines required methods. Subclasses must implement every
@abstractmethod, or they can't be instantiated.
from abc import ABC, abstractmethod
class Shape(ABC): # inherit from ABC
@abstractmethod
def area(self): # subclasses MUST define this
...
def describe(self): # concrete methods are allowed too
return f"area = {self.area()}"
# shape = Shape() # TypeError: can't instantiate abstract class
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self): # required implementation
return self.side ** 2
sq = Square(4)
sq.area() # 16
sq.describe() # "area = 16"Often you don't even need this — thanks to duck typing, any object with the right methods works. ABCs are for when you want to enforce that contract.
The bridge between duck typing and ABCs: a Protocol describes a shape,
and for the type checker any class with matching methods satisfies it — no
inheritance, nothing declared, and it works retroactively for classes you
don't own. ABCs are nominal (must inherit, enforced at runtime); Protocols
are structural (checker-time only, nothing enforced at runtime).
from typing import Protocol, runtime_checkable
class Quacker(Protocol):
def quack(self) -> str: ... # the required shape
class Duck: # note: does NOT inherit from Quacker
def quack(self) -> str:
return "quack"
def talk(q: Quacker) -> str:
return q.quack()
talk(Duck()) # "quack" -> mypy approves: Duck has the right shape
# talk(42) # mypy: incompatible type "int"; expected "Quacker"
# # (and AttributeError at runtime — no quack())
# isinstance against a Protocol needs @runtime_checkable
# (it checks that the method NAMES exist — not their signatures)
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class File:
def close(self) -> None:
pass
isinstance(File(), Closeable) # True -> has a close() method
isinstance(42, Closeable) # FalseAn iterator implements __iter__ (returns the iterator) and __next__
(returns the next value, or raises StopIteration when done). This is what
for loops use under the hood. (Generators are the shortcut for this.)
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # an iterator returns itself
def __next__(self):
if self.current <= 0:
raise StopIteration # signals "no more values"
self.current -= 1
return self.current + 1
for n in Countdown(3): # 3, 2, 1
print(n)
# Splitting iterable from iterator lets you loop multiple times.
# Here __iter__ returns a FRESH iterator on each call:
class Numbers:
def __init__(self, data):
self.data = data
def __iter__(self):
return iter(self.data) # delegate to the list's own iterator
nums = Numbers([1, 2, 3])
list(nums) # [1, 2, 3]
list(nums) # [1, 2, 3] again -> reusablewith works on any object implementing the context manager protocol:
__enter__ runs at entry, __exit__ at exit — always, even when the
block raises (built-in try/finally). open() on the basics sheet is one;
here is how to write your own.
class Managed:
def __enter__(self):
print("enter")
return self # the value bound by `as`
def __exit__(self, exc_type, exc_value, traceback):
print("exit") # runs even if the block raised
return False # False -> let any exception propagate
with Managed() as m:
print("inside")
# enter / inside / exit
# __exit__ receives the exception (or None, None, None on success);
# returning True SWALLOWS it
class Suppress:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
return exc_type is ValueError # suppress ValueError only
with Suppress():
raise ValueError("handled")
print("still running") # the exception never escaped the with
# The generator shortcut: contextlib turns a generator function into a
# context manager — code before yield = __enter__, after = __exit__
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>") # entry
try:
yield name # the with-block runs here; `as` gets name
finally:
print(f"</{name}>") # exit — finally guards against errors
with tag("b") as t:
print("bold text")
# <b> / bold text / </b>Auto-generate __init__, __repr__, __eq__, etc. from typed fields —
great for plain data containers.
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
y: int = 0 # default value
tags: list = field(default_factory=list) # mutable default -> factory
p = Point(1, 2)
p # Point(x=1, y=2, tags=[]) -> generated __repr__
p == Point(1, 2) # True -> generated __eq__
p.x # 1
# frozen=True makes instances immutable (also hashable)
@dataclass(frozen=True)
class Config:
name: str
debug: bool = False
cfg = Config("prod")
# cfg.debug = True # FrozenInstanceErrorA fixed set of named constant values — clearer and safer than bare strings or magic numbers.
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
Color.RED # <Color.RED: 1>
Color.RED.name # "RED"
Color.RED.value # 1
Color(2) # <Color.GREEN: 2> -> look up a member by value
Color["RED"] # <Color.RED: 1> -> look up by name
list(Color) # [<Color.RED: 1>, <Color.GREEN: 2>, <Color.BLUE: 3>]
Color.RED == Color.RED # True -> compare by identity
Color.RED == 1 # False -> a member is NOT its raw value
# auto() assigns the values for you
from enum import auto
class Status(Enum):
PENDING = auto() # 1
ACTIVE = auto() # 2
DONE = auto() # 3Two ways an object turns into text:
__repr__— for developers: unambiguous, ideally something that could recreate the object. Used by the REPL, the debugger, and when an object appears inside a container (e.g. printing a list of objects).__str__— for end users: readable/friendly. Used byprint(),str(), and f-strings.
# 1. NEITHER defined -> ugly default from object
class Bare:
def __init__(self, x):
self.x = x
str(Bare(1)) # "<__main__.Bare object at 0x...>" (unhelpful)
# 2. ONLY __repr__ defined -> str() FALLS BACK to it
class ReprOnly:
def __init__(self, x):
self.x = x
def __repr__(self):
return f"ReprOnly(x={self.x})"
r = ReprOnly(1)
repr(r) # "ReprOnly(x=1)"
str(r) # "ReprOnly(x=1)" -> falls back to __repr__
print(r) # ReprOnly(x=1)
print([r]) # [ReprOnly(x=1)] -> containers always use __repr__
# 3. ONLY __str__ defined -> repr() stays the UGLY default (no fallback!)
class StrOnly:
def __str__(self):
return "friendly"
s = StrOnly()
str(s) # "friendly"
repr(s) # "<__main__.StrOnly object at 0x...>" -> still ugly
# print([s]) # uses repr -> shows the ugly form, NOT "friendly"
# 4. BOTH defined -> each context picks the right one
class Temp:
def __init__(self, c):
self.c = c
def __repr__(self):
return f"Temp({self.c})" # for developers
def __str__(self):
return f"{self.c}°C" # for users
t = Temp(20)
str(t) # "20°C"
repr(t) # "Temp(20)"Rule of thumb: if you only define one, define __repr__ — str() falls
back to it, but never the reverse.
import threading
def worker(n):
print(f"working on {n}")
t = threading.Thread(target=worker, args=(1,))
t.start()
t.join() # wait for the thread to finish
# A Lock protects shared state from concurrent access
lock = threading.Lock()
with lock: # only one thread inside at a time
passKey caveat: CPython has a Global Interpreter Lock (GIL), so threads do not run Python bytecode in parallel. Threading only speeds up I/O-bound work (network, disk, waiting), where threads sit idle. For CPU-bound work, use multiprocessing instead.
Separate processes sidestep the GIL and give true parallelism — best for CPU-bound work. Each process has its own memory.
from multiprocessing import Pool
def square(n):
return n * n
# The __main__ guard is REQUIRED for multiprocessing to work safely
if __name__ == "__main__":
with Pool(4) as pool:
results = pool.map(square, [1, 2, 3, 4]) # [1, 4, 9, 16]
print(results)import asyncio
async def fetch(n): # 'async def' creates a coroutine
await asyncio.sleep(0.1) # non-blocking pause (yields control)
return n * 2
async def main():
# gather schedules coroutines concurrently and waits for all of them
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
return results # [2, 4, 6]
asyncio.run(main()) # starts the event loop and runs main()Key idea: async is concurrency, not parallelism — a single thread that
cooperatively switches tasks at each await. Ideal for handling many I/O
waits at once (e.g. hundreds of network requests), not for CPU-bound work.
The modern, object-oriented way to handle file paths (preferred over
string manipulation and most of the older os.path).
from pathlib import Path
p = Path("folder") / "sub" / "file.txt" # build paths with the / operator
p.name # "file.txt"
p.stem # "file" name without the suffix
p.suffix # ".txt" the extension
p.parent # Path("folder/sub")
p.parts # ('folder', 'sub', 'file.txt')
Path.cwd() # current working directory
Path.home() # user's home directory
p.with_suffix(".md") # Path("folder/sub/file.md")
# Filesystem operations (shown commented; they touch real files):
# p.exists() / p.is_file() / p.is_dir()
# p.read_text() / p.write_text("hi")
# Path("data").mkdir(exist_ok=True) # create a directory
# for child in Path(".").iterdir(): ... # list a directory
# for py in Path(".").glob("*.py"): ... # match a pattern