Python from First Principles
A structured, no-nonsense course for beginners and developers switching to Python. We start from how the machine actually works and build up from there.
About this course
Most Python tutorials teach you syntax. This course teaches you to think in Python. We cover the language from its foundations, building genuine understanding rather than pattern-matching recipes.
Each lesson is a self-contained article with real code examples, exercises, and clear explanations. No videos. No sign-ups. Just text you can read at your own pace.
Getting Started
What is Python?
A brief history, design philosophy, and why Python reads almost like English.
Python was created by Guido van Rossum and first released in 1991. Guido wanted a language that was easy to read, had a clean syntax, and would be fun to write. He named it after Monty Python's Flying Circus — not the snake.
The Zen of Python
Python has a set of guiding principles baked into the language itself. You can read them any time by running:
import this
The most important ones: Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. Readability counts. These aren't just poetry — they explain why Python code looks the way it does.
Where Python is used
- Data science & ML — NumPy, pandas, PyTorch, TensorFlow
- Web backends — Django, FastAPI, Flask
- Automation & scripting — system tasks, file processing, bots
- Scientific computing — simulations, research pipelines
- DevOps & tooling — Ansible, AWS CLI, build scripts
Interpreted vs. compiled
Python is an interpreted language — your code is read and executed line by line by the Python interpreter, rather than compiled into machine code ahead of time. This makes iteration fast and errors easy to inspect, at the cost of some raw speed.
Exercise
Open a terminal and run python3 --version. What version do you have? Then run python3 -c "import this" and read through the Zen. Which principle resonates most with you?
Setting Up & the REPL
Installing Python, using the interactive shell, and your first program.
Python 3.12+ ships on most systems. Check with python3 --version. If you need to install it, grab the official package from python.org or use your system's package manager.
The REPL
REPL stands for Read-Eval-Print Loop. Launch it by typing python3 in your terminal. You'll see a >>> prompt — type any expression and Python evaluates it immediately:
# In the REPL: >>> 2 + 2 4 >>> "hello".upper() 'HELLO' >>> type(3.14) <class 'float'>
The REPL is your best friend for exploring ideas. Use it constantly. Press Ctrl+D (or exit()) to quit.
Your first script
Create a file called hello.py and add:
print("Hello, world!")
Run it with python3 hello.py. That's it — you've written and executed a Python program.
Editors
VS Code with the Python extension is the most popular choice. PyCharm is excellent if you want a full IDE. For quick experiments, the REPL or Replit (browser-based) work great — no install needed.
python3, not python, unless you've explicitly configured an alias. On many systems python still points to Python 2.Exercise
In the REPL, compute 17 ** 2 (17 squared), then 2 ** 10 (2 to the 10th). Try calling help(str) to see the string docs. Type q to exit help.
Variables & Types
How Python names objects, dynamic typing, and the type() function.
In Python, variables are names that point to objects. When you write x = 42, you're creating an integer object with value 42 and binding the name x to it. Python figures out the type from the value — you never declare types explicitly.
x = 42 # int name = "Alice" # str pi = 3.14159 # float active = True # bool nothing = None # NoneType print(type(x)) # <class 'int'>
Dynamic typing
Python is dynamically typed — the same name can point to different types over its lifetime. This is flexible but requires discipline: type errors only surface at runtime.
x = 10 x = "now a string" # totally valid, x is rebound
Everything is an object
In Python, everything is an object — integers, strings, functions, classes, even None. That means everything has methods and attributes you can access with dot notation.
n = -7 print(n.bit_length()) # 3 — ints have methods!
Naming conventions
- Use
snake_casefor variables and functions:user_name,total_count - Use
UPPER_CASEfor constants:MAX_RETRIES = 3 - Use
PascalCasefor class names:UserProfile
a = b, both names point to the same object — no copy is made. This matters when working with mutable objects.Exercise
Create a variable for your name, age, and whether you've used Python before. Print the type() of each. Then try reassigning your age to a string — does Python complain?
Numbers & Strings
Arithmetic, string formatting with f-strings, and the immutability of strings.
Numbers
Python has three numeric types: int (arbitrary precision), float (64-bit double), and complex. The key operators:
10 / 3 # 3.333... (true division) 10 // 3 # 3 (floor division) 10 % 3 # 1 (modulo / remainder) 2 ** 8 # 256 (exponentiation)
Strings
Strings are immutable sequences of characters. Use single or double quotes — Python treats them identically. Triple quotes for multi-line strings.
greeting = "Hello" name = 'world' poem = """Line one Line two Line three"""
f-strings (the right way to format)
Since Python 3.6, f-strings are the standard way to embed values into strings. Prefix with f and put expressions in {}:
name = "Alice" age = 30 print(f"My name is {name} and I am {age} years old.") print(f"Next year: {age + 1}") # expressions work too print(f"Pi: {3.14159:.2f}") # format specifiers
String immutability
You cannot change a character in a string in place. Instead, string methods return new strings:
s = "hello" s.upper() # "HELLO" — returns a new string print(s) # still "hello" — s is unchanged
0.1 + 0.2 != 0.3 in Python (and every language using IEEE 754 floats). Use the decimal module or round() when precision matters.Exercise
Write an f-string that prints: "If I invest £1000 at 5% for 10 years, I'll have £X" — calculate X using the compound interest formula 1000 * (1.05 ** 10), formatted to 2 decimal places.
Control & Functions
Control Flow
if/elif/else, truthiness, and how Python evaluates conditions.
Python uses indentation (4 spaces by convention) to define code blocks — there are no braces. The if statement is the foundation of branching logic:
score = 72 if score >= 90: print("A") elif score >= 70: print("B") elif score >= 50: print("C") else: print("Fail")
Truthiness
In Python, any value can be evaluated as a boolean. These are all falsy: 0, 0.0, "", [], {}, None, False. Everything else is truthy. This lets you write clean conditions:
name = input("Enter name: ") if name: # truthy if non-empty print(f"Hello, {name}!") else: print("No name given.")
Comparison & logical operators
x = 5 x > 3 and x < 10 # True (use 'and', not '&&') x == 5 or x == 6 # True (use 'or', not '||') not x == 5 # False (use 'not', not '!') 3 < x < 10 # True (chained comparisons!)
Ternary expressions
label = "even" if x % 2 == 0 else "odd"
Exercise
Write a function classify_temp(celsius) that returns "freezing" (below 0), "cold" (0–15), "comfortable" (15–25), or "hot" (above 25). Test it with several values.
Loops
for loops, while loops, enumerate, zip, and when to use each.
Python has two loop constructs: for (iterates over a sequence) and while (runs while a condition is true).
for loops
fruits = ["apple", "banana", "cherry"] for fruit in fruits: print(fruit) # over a range of numbers: for i in range(5): # 0, 1, 2, 3, 4 print(i)
enumerate — when you need the index
for i, fruit in enumerate(fruits): print(f"{i}: {fruit}") # 0: apple, 1: banana...
zip — iterate multiple sequences together
names = ["Alice", "Bob"] scores = [95, 87] for name, score in zip(names, scores): print(f"{name}: {score}")
while loops
n = 1 while n < 100: n *= 2 print(n) # 128
break and continue
for n in range(10): if n == 3: continue # skip 3 if n == 7: break # stop at 7 print(n)
for over while whenever you're iterating over a known sequence. Reserve while for situations where you don't know how many iterations you'll need.Exercise
Write a loop that prints the first 10 Fibonacci numbers. Then rewrite it using while. Which feels more natural?
Functions
Defining functions, arguments, return values, and *args/**kwargs.
Functions are the primary unit of reuse in Python. Define them with def, give them a name, list parameters, and use return to send a value back.
def greet(name): """Return a personalised greeting.""" return f"Hello, {name}!" print(greet("Alice")) # Hello, Alice!
Default arguments
def power(base, exponent=2): return base ** exponent power(3) # 9 (exponent defaults to 2) power(2, 10) # 1024
*args and **kwargs
def total(*numbers): return sum(numbers) total(1, 2, 3, 4, 5) # 15 — any number of args def configure(**settings): for k, v in settings.items(): print(f"{k} = {v}") configure(debug=True, port=8080)
Lambda functions
For short one-liners, you can use lambda — an anonymous function:
square = lambda x: x ** 2 nums = [3, 1, 4, 1, 5] sorted(nums, key=lambda x: -x) # sort descending
def is read by tools like help() and IDEs. It's not optional in real code.Exercise
Write a function describe_list(items, label="items") that prints "You have N label" for any list passed to it. Call it with and without the label keyword argument.
Scope & Closures
The LEGB rule, closures in practice, and why they matter.
LEGB: how Python resolves names
When you use a name, Python searches in this order: Local → Enclosing → Global → Built-in.
x = "global" def outer(): x = "enclosing" def inner(): print(x) # finds "enclosing" via E step inner() outer() # prints "enclosing"
Closures
A closure is an inner function that remembers the variables from its enclosing scope, even after the outer function has finished executing:
def make_multiplier(factor): def multiply(n): return n * factor # 'factor' is captured return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(5)) # 10 print(triple(5)) # 15
Closures are the mechanism behind decorators, callbacks, and factory functions. Understanding them unlocks a large portion of idiomatic Python.
global. Using the global keyword to modify module-level variables from inside a function is a code smell. Pass data as arguments and return it instead.Exercise
Write a make_counter() function that returns a closure. Each time you call the returned function, it should increment and return a count starting at 1. Create two independent counters and verify they don't share state.
Error Handling
Exceptions, try/except/finally, and the principle of asking forgiveness.
Python uses exceptions for error handling. When something goes wrong, an exception is raised. If not caught, it propagates up and crashes the program with a traceback.
try / except
try: result = 10 / int(input("Divisor: ")) print(result) except ZeroDivisionError: print("Can't divide by zero!") except ValueError: print("Please enter a number.") finally: print("Done.") # always runs
EAFP vs LBYL
Python culture favours EAFP (Easier to Ask Forgiveness than Permission) over LBYL (Look Before You Leap):
# LBYL (un-Pythonic): if "key" in my_dict: value = my_dict["key"] # EAFP (Pythonic): try: value = my_dict["key"] except KeyError: value = None
Raising exceptions
def set_age(age): if age < 0: raise ValueError(f"Age cannot be negative: {age}") return age
except: catches everything including KeyboardInterrupt — almost always a bug.Exercise
Write a safe_divide(a, b) function that returns a / b, but handles division by zero by returning None and printing a warning. What other exception might you want to handle?
Data Structures
Lists & Tuples
Mutable vs immutable sequences, slicing, and when tuples beat lists.
Lists — ordered, mutable sequences
primes = [2, 3, 5, 7, 11] primes.append(13) # [2, 3, 5, 7, 11, 13] primes.pop() # removes and returns 13 primes[0] # 2 (zero-indexed) primes[-1] # 11 (last element)
Slicing
letters = ['a', 'b', 'c', 'd', 'e'] letters[1:3] # ['b', 'c'] (start inclusive, end exclusive) letters[:2] # ['a', 'b'] letters[::2] # ['a', 'c', 'e'] (every 2nd) letters[::-1] # ['e', 'd', 'c', 'b', 'a'] (reversed)
Tuples — ordered, immutable
Tuples look like lists but use parentheses and cannot be modified. Use them for data that shouldn't change: coordinates, RGB colours, database rows.
point = (10, 20) x, y = point # unpacking r, g, b = 255, 128, 0 # also unpacking
Exercise
Create a list of 5 cities. Slice out the middle 3. Then reverse the whole list in place using .reverse(). Can you do it without mutating the original, using a slice instead?
Dictionaries
Key-value pairs, hash maps under the hood, and dict methods.
Dictionaries map keys to values. Since Python 3.7, they maintain insertion order. Keys must be hashable (strings, numbers, tuples work; lists don't).
user = { "name": "Alice", "age": 30, "active": True, } user["email"] = "alice@example.com" # add key user.get("phone", "N/A") # safe get with default user.pop("active") # remove key
Iterating
for key in user: # keys only print(key) for key, val in user.items(): # key-value pairs print(f"{key}: {val}")
Merging dicts (Python 3.9+)
defaults = {"timeout": 30, "retries": 3} overrides = {"timeout": 60} config = defaults | overrides # {"timeout": 60, "retries": 3}
Exercise
Write a function word_count(text) that returns a dictionary mapping each word to the number of times it appears. Use .get() or collections.defaultdict.
Sets
Membership testing, deduplication, and set algebra operations.
A set is an unordered collection of unique elements. It's backed by a hash table, making membership testing O(1) — far faster than scanning a list.
tags = {"python", "web", "api"} "python" in tags # True — instant lookup tags.add("backend") tags.discard("web") # remove, no error if absent # Deduplicate a list: dupes = [1, 2, 2, 3, 3, 3] unique = list(set(dupes)) # [1, 2, 3]
Set algebra
a = {1, 2, 3, 4} b = {3, 4, 5, 6} a | b # union: {1, 2, 3, 4, 5, 6} a & b # intersection: {3, 4} a - b # difference: {1, 2} a ^ b # symmetric diff: {1, 2, 5, 6}
Exercise
Given two lists of student names (morning class and afternoon class), use sets to find: students in both classes, students in only the morning class, and all unique students across both.
Comprehensions
List, dict, and set comprehensions — writing expressive loops in one line.
Comprehensions are a concise, readable way to build collections from iterables. They're one of Python's most beloved features.
List comprehensions
# Traditional: squares = [] for x in range(10): squares.append(x ** 2) # Comprehension: squares = [x ** 2 for x in range(10)] # With a filter: evens = [x for x in range(20) if x % 2 == 0]
Dict and set comprehensions
words = ["hello", "world", "python"] # Dict: word → length lengths = {w: len(w) for w in words} # {'hello': 5, 'world': 5, 'python': 6} # Set: unique first letters initials = {w[0] for w in words} # {'h', 'w', 'p'}
Exercise
Given a list of temperatures in Celsius, use a list comprehension to convert them all to Fahrenheit (F = C * 9/5 + 32). Then use a dict comprehension to create a mapping of celsius → fahrenheit.
Object-Oriented Python
Classes & Objects
The class syntax, __init__, instance attributes, and methods.
A class is a blueprint for creating objects. Objects bundle data (attributes) and behaviour (methods) together.
class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.balance = balance self._history = [] # _ signals "internal" def deposit(self, amount): self.balance += amount self._history.append(f"+{amount}") def __repr__(self): return f"BankAccount({self.owner!r}, {self.balance})" acc = BankAccount("Alice", 100) acc.deposit(50) print(acc) # BankAccount('Alice', 150)
self is not magic. It's just the first argument to every instance method — Python passes the instance automatically when you call obj.method(). You could name it anything, but self is universal convention.Exercise
Build a Stack class with push(item), pop(), peek(), and is_empty() methods. Raise an exception if you try to pop from an empty stack.
Inheritance
Subclassing, method resolution order, and when to prefer composition.
Inheritance lets a class reuse and extend another class's behaviour:
class Animal: def __init__(self, name): self.name = name def speak(self): raise NotImplementedError class Dog(Animal): def speak(self): return f"{self.name} says Woof!" class Cat(Animal): def speak(self): return f"{self.name} says Meow!"
super()
class SavingsAccount(BankAccount): def __init__(self, owner, rate=0.05): super().__init__(owner) # call parent __init__ self.rate = rate
Exercise
Create a Shape base class with an area() method that raises NotImplementedError. Subclass it with Circle and Rectangle, each implementing their own area().
Dunder Methods
Making your classes feel native with __repr__, __eq__, __len__, and friends.
Dunder (double-underscore) methods let your objects hook into Python's built-in operators and functions. They're the mechanism behind Python's data model.
class Vector: def __init__(self, x, y): self.x, self.y = x, y def __repr__(self): # repr(v), print(v) return f"Vector({self.x}, {self.y})" def __add__(self, other): # v1 + v2 return Vector(self.x + other.x, self.y + other.y) def __eq__(self, other): # v1 == v2 return self.x == other.x and self.y == other.y def __len__(self): # len(v) — must return int import math return int(math.hypot(self.x, self.y))
__repr__. It's used in the REPL, debuggers, and error messages. A good repr makes debugging dramatically faster.Exercise
Extend the Vector class with __sub__ (subtraction), __mul__ (scalar multiply), and __abs__ (magnitude). Make instances sortable by implementing __lt__.
Dataclasses
Removing boilerplate with @dataclass, frozen instances, and field defaults.
The @dataclass decorator auto-generates __init__, __repr__, and __eq__ from class-level annotations. It eliminates a huge amount of boilerplate.
from dataclasses import dataclass, field @dataclass class Product: name: str price: float tags: list[str] = field(default_factory=list) p = Product("Keyboard", 79.99) p.tags.append("peripherals") print(p) # Product(name='Keyboard', price=79.99, tags=['peripherals'])
Frozen dataclasses (immutable)
@dataclass(frozen=True) class Point: x: float y: float p = Point(1.0, 2.0) p.x = 5 # FrozenInstanceError!
field(default_factory=list) — never use a mutable default like tags: list = []. That default would be shared across all instances.Exercise
Create a @dataclass for a Student with name, grade (0–100), and a list of subjects. Add a method is_passing() that returns True if grade ≥ 50. Dataclasses can have regular methods too!
Protocols
Structural subtyping, duck typing formalized, and the Protocol class.
Python's traditional duck typing says: if an object has the right methods, it works. Protocol (Python 3.8+) makes this structural subtyping explicit and checkable by static type checkers.
from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ... class Circle: def draw(self): print("Drawing circle") class Square: def draw(self): print("Drawing square") def render(shape: Drawable) -> None: shape.draw() render(Circle()) # works! render(Square()) # works!
Neither Circle nor Square inherits from Drawable — they simply implement the required method. The type checker verifies this at analysis time without runtime overhead.
Exercise
Define a Serializable protocol with to_json() -> str and from_json(cls, data: str) methods. Implement it for a User dataclass, then write a function that accepts any Serializable and round-trips it.
The Python Ecosystem
Modules & Packages
How imports work, creating packages, and the __init__.py convention.
Any .py file is a module. Import it with import. A directory of modules with an __init__.py file is a package.
# math_utils.py def square(n): return n ** 2 # main.py import math_utils print(math_utils.square(5)) # 25 # or import specific names: from math_utils import square print(square(5)) # alias to avoid clashes: import numpy as np
Package structure
myproject/
__init__.py # makes it a package
models.py
utils/
__init__.py
formatting.py
from . import sibling) are useful inside packages but confusing in scripts.Exercise
Create a small package called geometry with two modules: shapes.py (your Circle and Rectangle classes) and utils.py (a function to compare areas). Import and use them from a separate script.
File I/O
Reading and writing files, context managers, and pathlib.
Reading and writing files
# Always use 'with' — it closes the file automatically with open("data.txt", "r", encoding="utf-8") as f: content = f.read() # whole file as string lines = f.readlines() # list of lines with open("output.txt", "w") as f: f.write("Hello\n") f.writelines(["line1\n", "line2\n"])
pathlib — the modern way
from pathlib import Path p = Path("data") / "report.txt" # path joining p.exists() # True/False p.read_text() # reads entire file p.write_text("hi") # writes file p.stem # "report" (no extension) p.suffix # ".txt" for f in Path(".").glob("*.py"): print(f)
pathlib.Path for all file system operations. It's cross-platform, readable, and returns Path objects you can chain.Exercise
Write a script that reads a CSV file of names and scores, computes the average score, and writes a summary to a new file using pathlib. Handle the case where the file doesn't exist.
The Standard Library
A tour of the batteries-included modules every Python developer should know.
Python's standard library is vast. Here are the modules you'll reach for constantly:
collections
from collections import Counter, defaultdict, deque c = Counter(["a", "b", "a", "c", "a"]) c.most_common(2) # [('a', 3), ('b', 1)]
itertools
from itertools import chain, groupby, islice list(chain([1,2], [3,4], [5])) # [1,2,3,4,5] list(islice(range(100), 5)) # [0,1,2,3,4]
datetime
from datetime import datetime, timedelta now = datetime.now() tomorrow = now + timedelta(days=1) now.strftime("%Y-%m-%d") # "2026-03-09"
json
import json data = {"name": "Alice", "scores": [95, 87]} text = json.dumps(data, indent=2) back = json.loads(text)
csv, sqlite3, http, email, logging, unittest…Exercise
Use collections.Counter to find the 5 most common words in a text string. Then use json to save the result to a file and load it back.
Virtual Environments & pip
Managing dependencies cleanly with venv, pip, and requirements files.
A virtual environment is an isolated Python installation for your project. It prevents dependency conflicts between projects.
Creating and using a venv
# Create python3 -m venv .venv # Activate (macOS/Linux) source .venv/bin/activate # Activate (Windows) .venv\Scripts\activate # You'll see (.venv) in your prompt pip install requests flask # Save dependencies pip freeze > requirements.txt # Reproduce on another machine pip install -r requirements.txt
Modern alternatives
For larger projects, consider poetry or uv — they manage dependencies, virtualenvs, and lock files in a single tool with much better UX than plain pip.
Exercise
Create a new project folder, set up a venv, install requests, and write a script that fetches JSON from a public API (e.g. https://httpbin.org/get) and prints the response. Add .venv/ to your .gitignore.
Advanced Python
Generators
Lazy evaluation, the yield keyword, and infinite sequences.
A generator is a function that yields values one at a time, pausing execution between each. It doesn't compute all values upfront — it generates them on demand.
def count_up(start=0): n = start while True: yield n # pauses here, resumes on next() n += 1 counter = count_up() print(next(counter)) # 0 print(next(counter)) # 1
Generator expressions
# Like list comprehensions but lazy: total = sum(x**2 for x in range(10**6)) # Doesn't build a million-item list in memory
Why generators matter
Processing a 10GB log file line by line with a generator uses constant memory. Loading it all into a list would exhaust RAM instantly. Generators are the Pythonic solution to working with large or infinite data streams.
Exercise
Write a generator fibonacci() that yields Fibonacci numbers indefinitely. Then use itertools.islice to print the first 20. Compare its memory usage conceptually to building a list of 10,000 Fibonacci numbers.
Decorators
Writing your own decorators, stacking them, and using functools.wraps.
A decorator is a function that takes a function and returns a modified version. The @decorator syntax is just syntactic sugar for func = decorator(func).
import time from functools import wraps def timer(func): @wraps(func) # preserves func's name & docstring def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() print(f"{func.__name__} took {end-start:.4f}s") return result return wrapper @timer def slow_sum(n): return sum(range(n)) slow_sum(10_000_000) # prints timing automatically
Stacking decorators
@timer @cache def fib(n): ... # Applied bottom-up: cache first, then timer
@wraps in your decorators. Without it, the wrapped function loses its __name__, __doc__, and signature — which breaks help(), debuggers, and introspection.Exercise
Write a @retry(times=3) decorator that re-runs a function up to 3 times if it raises an exception. Test it on a function that randomly fails. This requires a decorator factory — a function that returns a decorator.
Type Annotations
Using mypy, generics, Optional, Union, and the philosophy of gradual typing.
Type annotations don't change runtime behaviour — they're hints for humans and tools like mypy and your IDE. Python checks them at analysis time, not execution time.
def greet(name: str, times: int = 1) -> str: return (f"Hello, {name}! " * times).strip()
Common type patterns
from typing import Optional, Union def find_user(id: int) -> Optional[str]: # str or None ... def process(x: Union[int, float]) -> float: # int or float ... # Python 3.10+ syntax (preferred): def find_user(id: int) -> str | None: ...
Generics
def first[T](items: list[T]) -> T | None: return items[0] if items else None # Type checker knows first([1,2,3]) returns int # and first(["a","b"]) returns str
Any.Exercise
Take your BankAccount class from Module 4 and add full type annotations. Install mypy and run it against your file. Fix any type errors it reports.
async / await
Concurrency without threads: the event loop, coroutines, and asyncio patterns.
Asyncio enables concurrency by running one thread that switches between tasks whenever one is waiting for I/O. Perfect for web requests, database calls, and any I/O-bound work.
import asyncio async def fetch(url: str) -> str: print(f"Fetching {url}...") await asyncio.sleep(1) # simulates network wait return f"Response from {url}" async def main(): # Run 3 fetches concurrently: results = await asyncio.gather( fetch("http://api.example.com/a"), fetch("http://api.example.com/b"), fetch("http://api.example.com/c"), ) for r in results: print(r) asyncio.run(main())
All three fetches run concurrently — total time ~1s, not ~3s.
async function can only be awaited from another async function. Once you start using it, you tend to use it throughout. Use asyncio.run() as the entry point.Exercise
Install httpx (an async-capable HTTP library). Rewrite the fetch example to make real HTTP requests to https://httpbin.org/delay/1 concurrently. Time the sequential vs concurrent versions.
Testing with pytest
Writing tests that document your code, fixtures, parametrize, and TDD in practice.
pytest is the de-facto Python testing framework. Tests are just functions starting with test_ — no boilerplate, no inheritance.
# test_math_utils.py from math_utils import square def test_square_positive(): assert square(4) == 16 def test_square_zero(): assert square(0) == 0 def test_square_negative(): assert square(-3) == 9
Parametrize — test many cases at once
import pytest @pytest.mark.parametrize("n, expected", [ (2, 4), (3, 9), (0, 0), (-4, 16) ]) def test_square(n, expected): assert square(n) == expected
Fixtures — reusable setup
@pytest.fixture def account(): return BankAccount("Test", 100) def test_deposit(account): account.deposit(50) assert account.balance == 150
Final Exercise
Write a full test suite for your BankAccount class covering: deposits, withdrawals (with insufficient funds), and the string representation. Use fixtures for the account setup and parametrize for multiple deposit amounts. Run it with pytest -v.