What Does "Pythonic" Actually Mean?
Every language has an idiomatic way of doing things. Java code and Python code can both work โ but only one of them looks like it belongs. This lesson explains the difference and why it matters.
"Pythonic" means code that uses Python's features the way they were designed to be used โ not just code that runs in Python. It's the difference between a tourist who can speak the language and a local who thinks in it.
Pythonic code is:
Readable
A stranger should be able to read your function and understand what it does without comments.
Concise
Not short for the sake of cleverness โ short because the language handles the boilerplate.
Expressive
Code that says what it does, not how it does it. The how should be invisible.
Uses the Language
Uses built-ins, standard library, and Python's own protocols rather than reinventing them.
These both work. One is Pythonic. Understanding why is the whole lesson.
# Checking if a list is empty if len(my_list) == 0: print("empty") # Looping with index manually i = 0 while i < len(items): print(items[i]) i += 1 # Getting index + value for i in range(len(items)): print(i, items[i]) # Swapping two values temp = a a = b b = temp
# Checking if a list is empty if not my_list: print("empty") # Looping โ Python handles iteration for item in items: print(item) # Getting index + value for i, item in enumerate(items): print(i, item) # Swapping two values a, b = b, a
The if not my_list difference
Python objects have a concept called truthiness โ every object knows whether it "counts as True" in a boolean context. Empty lists, empty strings, empty dicts, None, and zero all evaluate to False. When you write if not my_list, you're using Python's built-in protocol. When you write if len(my_list) == 0, you're fighting it. The Pythonic version is shorter AND works correctly with any sequence-like object, not just lists.
Python ships with a built-in philosophy document. Type import this in any Python REPL and it prints. These aren't just quotes โ they're design decisions baked into the language.
Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. Readability counts. Special cases aren't special enough to break the rules. Errors should never pass silently. There should be one obvious way to do it. If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea.
You'll make better design decisions in every language if you internalize these. "Errors should never pass silently" is why we'll talk about proper exception handling in Lesson 08.
Dunder Methods โ The Magic Protocol
Methods with double underscores on both sides โ __init__, __str__, __len__ โ are how Python lets your objects plug into the language itself. This is one of Python's most powerful features.
"Dunder" is short for Double UNDERscore. These methods are also called magic methods or special methods. They're not magic โ they're a protocol. When Python evaluates an expression like len(my_obj), it calls my_obj.__len__() behind the scenes. When you write a + b, Python calls a.__add__(b).
This means: if you define these methods on your class, your objects behave like built-in Python objects. Your custom class can support len(), iteration, comparison, arithmetic, indexing, and the with statement โ just by implementing the right dunder methods.
for loops and with statements) ultimately call a dunder method on an object. This is why Python's syntax is so clean โ the language itself delegates to your objects rather than hard-coding behavior for specific types.
| Method | Called whenโฆ | Java equivalent |
|---|---|---|
__init__ | Object is created: Foo() | Constructor |
__str__ | print(obj) or str(obj) | toString() |
__repr__ | REPL display, debugging, repr(obj) | No direct equiv โ more detailed toString |
__len__ | len(obj) | No direct equiv โ implement .size() |
__eq__ | obj1 == obj2 | equals() |
__lt__ | obj1 < obj2 | compareTo() |
__add__ | obj1 + obj2 | No direct equiv |
__contains__ | x in obj | No direct equiv โ implement .contains() |
__iter__ | for x in obj | Implement Iterable |
__getitem__ | obj[key] | No direct equiv |
__enter__ / __exit__ | with obj as x: | Try-with-resources (partial) |
__call__ | obj(args) โ call the object like a function! | No equivalent |
Let's build a Hand class for a card game. By implementing the right dunders, it works natively with len(), in, for, print(), and comparison โ all without calling any methods explicitly.
from dataclasses import dataclass @dataclass class Card: suit: str value: int def __str__(self) -> str: # print(card) โ "Aโ " not "Card(suit='spades', value=14)" symbols = {'spades':'โ ', 'hearts':'โฅ', 'clubs':'โฃ', 'diamonds':'โฆ'} names = {11:'J', 12:'Q', 13:'K', 14:'A'} face = names.get(self.value, str(self.value)) return f"{face}{symbols[self.suit]}" def __repr__(self) -> str: # repr() is for developers โ unambiguous, reproducible return f"Card(suit={self.suit!r}, value={self.value})" def __lt__(self, other: 'Card') -> bool: # Makes cards sortable with sorted() and min()/max() return self.value < other.value class Hand: def __init__(self, cards: list[Card] = None): self._cards = cards or [] # len(hand) โ number of cards def __len__(self) -> int: return len(self._cards) # card in hand โ True/False def __contains__(self, card: Card) -> bool: return card in self._cards # for card in hand: ... โ iterate naturally def __iter__(self): return iter(self._cards) # hand[0] โ first card def __getitem__(self, index: int) -> Card: return self._cards[index] # print(hand) โ readable representation def __str__(self) -> str: return " | ".join(str(c) for c in self._cards) def add(self, card: Card) -> None: self._cards.append(card) # โโ Now your class works like a built-in type โโ hand = Hand([ Card('spades', 14), Card('hearts', 7), Card('clubs', 11), ]) print(hand) # Aโ | 7โฅ | Jโฃ print(len(hand)) # 3 print(Card('spades', 14) in hand) # True print(sorted(hand)) # sorted by value โ works! print(max(hand)) # highest card โ works! for card in hand: # for loop โ works! print(card)
When to use which one
__str__ is for humans โ it's what print() shows. Make it readable and friendly. __repr__ is for developers and debugging โ it should be unambiguous and ideally reproducible (i.e., you could paste the output back into Python and recreate the object). The rule: always define __repr__ on any custom class. If you only define one, Python falls back to __repr__ for both, so it's the more important one.
print(card)โ calls__str__โ"Aโ "- REPL: just type
cardโ calls__repr__โCard(suit='spades', value=14) f"{card!r}"โ forces__repr__even inside f-strings
The __call__ dunder lets you call an object like a function. This sounds weird until you see where it's useful โ validators, middleware, configured function wrappers.
class Multiplier: """A configured callable โ like a function with state.""" def __init__(self, factor: int): self.factor = factor def __call__(self, value: int) -> int: return value * self.factor # Create configured callable objects double = Multiplier(2) triple = Multiplier(3) print(double(5)) # 10 โ called like a function! print(triple(7)) # 21 # Can pass them anywhere a function is expected nums = [1, 2, 3, 4] print(list(map(double, nums))) # [2, 4, 6, 8]
len(my_obj). What does Python actually call behind the scenes?Decorators โ Functions That Wrap Functions
You've seen @property and @staticmethod. Now understand what decorators actually are, how to write your own, and why they're everywhere in Python frameworks.
Decorators build on two things you already know: functions are objects, and functions can take other functions as arguments. Let's build the concept from scratch.
# Functions can return other functions def make_greeter(greeting: str): def greet(name: str) -> str: return f"{greeting}, {name}!" return greet # return the function itself, not a call hello = make_greeter("Hello") yo = make_greeter("Yo") print(hello("Jude")) # "Hello, Jude!" print(yo("Jude")) # "Yo, Jude!" # greet() "closes over" the greeting variable โ this is a CLOSURE
# A wrapper adds behavior before/after calling the original def timer(func): import time def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) # call the original end = time.time() print(f"{func.__name__} took {end-start:.4f}s") return result return wrapper def slow_add(a, b): import time; time.sleep(0.1) return a + b # Manually wrap it slow_add = timer(slow_add) slow_add(3, 4) # slow_add took 0.1003s
# @timer is EXACTLY the same as: slow_add = timer(slow_add) # It's just syntactic sugar โ cleaner to read @timer def slow_add(a: int, b: int) -> int: import time; time.sleep(0.1) return a + b slow_add(3, 4) # automatically timed, every call
When you wrap a function, the wrapper replaces it โ including its name and docstring. This breaks debugging, logging, and documentation tools. Fix it with @functools.wraps.
import functools def timer(func): @functools.wraps(func) # โ ALWAYS add this to your wrappers def wrapper(*args, **kwargs): import time start = time.time() result = func(*args, **kwargs) print(f"{func.__name__} took {time.time()-start:.4f}s") return result return wrapper # Without @functools.wraps: # slow_add.__name__ โ "wrapper" โ BAD # With @functools.wraps: # slow_add.__name__ โ "slow_add" โ CORRECT
import functools, time # โโ 1. Retry decorator โ re-runs on failure โโ def retry(times: int = 3, delay: float = 1.0): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except Exception as e: print(f"Attempt {attempt} failed: {e}") if attempt < times: time.sleep(delay) raise RuntimeError(f"{func.__name__} failed after {times} attempts") return wrapper return decorator @retry(times=3, delay=0.5) def fetch_data(url: str) -> dict: # Automatically retried 3 times on any exception import requests return requests.get(url).json() # โโ 2. Cache/memoize โ remember expensive results โโ from functools import lru_cache @lru_cache(maxsize=128) # built into Python stdlib! def fibonacci(n: int) -> int: if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) # Without @lru_cache: fib(40) calls itself ~100 million times # With @lru_cache: fib(40) calls itself 40 times # โโ 3. Validate arguments โโ def require_positive(func): @functools.wraps(func) def wrapper(*args, **kwargs): for arg in args: if isinstance(arg, (int, float)) and arg <= 0: raise ValueError(f"All arguments must be positive, got {arg}") return func(*args, **kwargs) return wrapper @require_positive def calculate_area(width: float, height: float) -> float: return width * height
Write a @log_calls decorator
Create a decorator that prints the function name, its arguments, and its return value every time it's called. Example output:
โ add(3, 4)โ add returned 7
Then add a parameter so you can do @log_calls(level="DEBUG") and the output includes the log level.
Properties โ Smart Attribute Access
In Java you write getAge() and setAge() for every field. Python's @property decorator lets you access attributes like data while running code behind the scenes.
You start with a simple class. Later you need to add validation, computation, or caching to an attribute. In Java, this requires changing your public API from a field to getter/setter methods โ a breaking change for all callers. In Python, you add @property and nothing that calls your class changes at all.
public class Circle { private double radius; public double getRadius() { return radius; } public void setRadius(double r) { if (r < 0) throw new IllegalArgumentException(); this.radius = r; } // always was getRadius() even // before validation existed }
class Circle: def __init__(self, radius: float): self.radius = radius # uses setter @property def radius(self) -> float: return self._radius @radius.setter def radius(self, value: float): if value < 0: raise ValueError("negative") self._radius = value # callers write: c.radius = 5
import math class Circle: def __init__(self, radius: float): self.radius = radius # this calls the setter below # โโ radius: validated read/write โโ @property def radius(self) -> float: return self._radius @radius.setter def radius(self, value: float) -> None: if value < 0: raise ValueError(f"Radius must be โฅ 0, got {value}") self._radius = value # โโ computed properties โ read-only, no setter โโ @property def area(self) -> float: # Calculated on demand โ not stored return math.pi * self._radius ** 2 @property def circumference(self) -> float: return 2 * math.pi * self._radius @property def diameter(self) -> float: return self._radius * 2 def __repr__(self) -> str: return f"Circle(radius={self._radius})" # โโ Usage โ reads like attribute access, runs like methods โโ c = Circle(5) print(c.radius) # 5 โ property getter print(c.area) # 78.54... โ computed, no ()! print(c.circumference) # 31.41... c.radius = 10 # property setter โ validates print(c.area) # 314.15... โ automatically updated c.radius = -1 # ValueError: Radius must be โฅ 0, got -1
_radius (single underscore). The public property is radius. The underscore signals "don't touch this directly" to other developers โ it's a convention, not enforcement. This is how Python handles encapsulation: trust, not locks.Context Managers โ Guaranteed Cleanup
The with statement is Python's answer to "always clean up, even if something goes wrong." Files, database connections, locks, timers โ anything that needs setup and teardown belongs in a context manager.
# If process_data() raises, # the file never closes! f = open("data.txt") data = f.read() process_data(data) f.close() # might never run
# File ALWAYS closes โ # even if an exception is raised with open("data.txt") as f: data = f.read() process_data(data) # f.close() called automatically
with Actually Doeswith block, it calls obj.__enter__() and assigns the result to the as variable. When the block exits โ for any reason, including exceptions โ it calls obj.__exit__(exc_type, exc_val, exc_tb). If __exit__ returns True, the exception is suppressed. If it returns None or False, the exception propagates. This guarantee is what makes context managers safe.
import time class Timer: """Context manager that measures elapsed time.""" def __enter__(self) -> 'Timer': # Called when entering the with block self.start = time.perf_counter() self.elapsed = 0.0 return self # this becomes the 'as' variable def __exit__(self, exc_type, exc_val, exc_tb) -> bool: # Called when leaving the with block (always) self.elapsed = time.perf_counter() - self.start print(f"Elapsed: {self.elapsed:.4f}s") return False # don't suppress exceptions # Usage with Timer() as t: time.sleep(0.25) print("doing work...") # Elapsed: 0.2502s (printed automatically) print(f"Total was: {t.elapsed:.4f}s") # accessible after block
Python's contextlib module gives you a decorator that turns a generator function into a context manager. One function, no class needed โ everything before yield is setup, everything after is cleanup.
from contextlib import contextmanager import sqlite3 @contextmanager def database_connection(db_path: str): """Manage a SQLite connection โ auto-commit or rollback.""" conn = sqlite3.connect(db_path) try: yield conn # โ everything above is __enter__ conn.commit() # only reached if no exception except Exception: conn.rollback() # roll back on error raise # re-raise the exception finally: conn.close() # โ everything in finally is __exit__ # Clean, safe database operations with database_connection("game.db") as conn: conn.execute("INSERT INTO players VALUES (?, ?)", ("Jude", 100)) # auto-committed if success, auto-rolled-back if error, always closed # Another example โ temporarily change directory import os @contextmanager def working_directory(path: str): original = os.getcwd() try: os.chdir(path) yield finally: os.chdir(original) # ALWAYS restore, even on error with working_directory("/tmp"): print(os.getcwd()) # /tmp # Back to original directory automatically
Generators & yield โ Lazy Sequences
A generator produces values one at a time, on demand, without building the whole sequence in memory. Once you understand this, you'll never build a million-item list again.
def first_n_squares(n): results = [] for i in range(n): results.append(i ** 2) return results # 1 million squares = ~8MB of RAM nums = first_n_squares(1_000_000)
def first_n_squares(n): for i in range(n): yield i ** 2 # ~200 bytes regardless of n! # Each value computed only when needed nums = first_n_squares(1_000_000)
Generator functions are pauseable
When Python sees yield in a function, it turns that function into a generator factory. Calling the function returns a generator object โ it doesn't run any code yet. Each time you call next() on the generator (which a for loop does automatically), the function runs until the next yield, produces that value, and then pauses โ preserving all its local variables exactly where it left off. The next next() call resumes from that exact point. When the function returns or runs out of yields, it raises StopIteration and the for loop exits.
# โโ 1. Reading large files line by line โโ def read_large_file(filepath: str): """Read a file one line at a time โ no matter how big it is.""" with open(filepath) as f: for line in f: yield line.strip() # Process a 10GB log file with constant memory: for line in read_large_file("huge.log"): if "ERROR" in line: print(line) # โโ 2. Infinite sequence โโ def counter(start: int = 0): """Count forever โ only compute what you ask for.""" n = start while True: yield n n += 1 from itertools import islice first_10 = list(islice(counter(5), 10)) # [5, 6, 7, 8, 9, 10, 11, 12, 13, 14] # โโ 3. Generator expression (like list comprehension, but lazy) โโ # List comprehension โ builds all results NOW squares_list = [x**2 for x in range(1_000_000)] # uses ~8MB # Generator expression โ computes on demand squares_gen = (x**2 for x in range(1_000_000)) # uses ~200 bytes! # Note: parentheses instead of brackets # Both work identically in a for loop or sum() total = sum(x**2 for x in range(1_000_000)) # no brackets needed inside () # โโ 4. Pipeline โ chain generators together โโ def parse_logs(lines): for line in lines: yield line.split(" | ") def filter_errors(records): for record in records: if len(record) > 1 and record[0] == "ERROR": yield record # Each stage only processes one item at a time โ memory stays flat lines = read_large_file("app.log") records = parse_logs(lines) errors = filter_errors(records) for err in errors: print(err)
[x**2 for x in range(1000000)] and (x**2 for x in range(1000000))?Itertools โ The Most Underused Module
Python's itertools module is a toolkit of lazy, memory-efficient iterators. Most Python developers don't know it well โ which means knowing it well sets you apart.
from itertools import ( chain, islice, groupby, product, combinations, permutations, takewhile, dropwhile, cycle, repeat ) # โโ chain โ flatten multiple iterables into one โโ a = [1, 2, 3] b = [4, 5, 6] c = [7, 8, 9] list(chain(a, b, c)) # [1, 2, 3, 4, 5, 6, 7, 8, 9] # โโ islice โ take first N items from any iterable โโ list(islice(range(1000), 5)) # [0, 1, 2, 3, 4] # โโ groupby โ group consecutive items by a key โโ # IMPORTANT: input must be sorted by the key first! data = [ {"name": "Alice", "dept": "eng"}, {"name": "Bob", "dept": "eng"}, {"name": "Carol", "dept": "hr"}, {"name": "Dave", "dept": "hr"}, ] data.sort(key=lambda x: x["dept"]) for dept, members in groupby(data, key=lambda x: x["dept"]): names = [m["name"] for m in members] print(f"{dept}: {names}") # eng: ['Alice', 'Bob'] # hr: ['Carol', 'Dave'] # โโ product โ cartesian product (nested loops collapsed) โโ colors = ["red", "blue"] sizes = ["S", "M", "L"] list(product(colors, sizes)) # [('red','S'), ('red','M'), ('red','L'), ('blue','S'), ...] # โโ combinations โ all unique pairs โโ players = ["Alice", "Bob", "Carol", "Dave"] list(combinations(players, 2)) # All possible 2-player matchups โ 6 pairs # โโ takewhile / dropwhile โ conditional slicing โโ data = [2, 4, 6, 7, 8, 10] list(takewhile(lambda x: x % 2 == 0, data)) # [2, 4, 6] โ stops at 7 list(dropwhile(lambda x: x % 2 == 0, data)) # [7, 8, 10] โ skips until 7 # โโ cycle โ repeat an iterable forever โโ colors_cycle = cycle(["red", "green", "blue"]) list(islice(colors_cycle, 7)) # ['red', 'green', 'blue', 'red', 'green', 'blue', 'red']
Error Handling Done Right
Most Python developers either catch too broadly (hiding bugs) or not at all (crashing users). Here's the correct mental model โ custom exceptions, exception chaining, and when NOT to catch.
Python exceptions form a tree. When you catch a parent, you catch all its children. This matters for deciding what to catch.
BaseException โโโ SystemExit # sys.exit() โ don't catch this โโโ KeyboardInterrupt # Ctrl+C โ don't catch this either โโโ Exception # catch this or its children โโโ ValueError # wrong value type/range โโโ TypeError # wrong type entirely โโโ KeyError # dict key not found โโโ IndexError # list index out of range โโโ AttributeError # attribute doesn't exist on object โโโ FileNotFoundError# subclass of OSError โโโ PermissionError # subclass of OSError โโโ RuntimeError # generic runtime error โโโ StopIteration # iterator exhausted (don't catch)
def load_config(path: str) -> dict: try: with open(path) as f: data = json.load(f) except FileNotFoundError: # Specific โ file doesn't exist print(f"Config not found: {path}. Using defaults.") return {} except json.JSONDecodeError as e: # Specific โ file exists but isn't valid JSON raise ValueError(f"Config file corrupt: {path}") from e # โ "from e" attaches the original exception โ keeps context except PermissionError: # Can't recover โ re-raise with more info raise PermissionError(f"Cannot read config: {path}") else: # Runs ONLY if no exception was raised โ often overlooked! print(f"Config loaded: {len(data)} keys") return data finally: # Runs ALWAYS โ with or without exception print("load_config complete") # โโ What NOT to do โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ # NEVER do this โ hides ALL errors including bugs try: do_something() except: # bare except catches EVERYTHING, even SystemExit pass # NEVER do this โ too broad try: do_something() except Exception: # hides bugs disguised as "errors" print("something went wrong") # no info, no re-raise
For any real application, define your own exception classes. This lets callers catch your errors specifically and gives you meaningful error messages with structured data.
# Define a hierarchy for your app class AppError(Exception): """Base exception for all app errors.""" pass class ValidationError(AppError): """Raised when input data fails validation.""" def __init__(self, field: str, message: str): self.field = field self.message = message super().__init__(f"Validation failed for '{field}': {message}") class NotFoundError(AppError): """Raised when a requested resource doesn't exist.""" def __init__(self, resource: str, identifier): self.resource = resource self.identifier = identifier super().__init__(f"{resource} '{identifier}' not found") # โโ Usage โโ def get_player(player_id: int) -> dict: if not isinstance(player_id, int) or player_id <= 0: raise ValidationError("player_id", "must be a positive integer") player = db.find(player_id) if not player: raise NotFoundError("Player", player_id) return player # โโ Callers can be very specific โโ try: player = get_player(42) except ValidationError as e: print(f"Bad input: {e.field}") except NotFoundError as e: print(f"Missing: {e.resource} {e.identifier}") except AppError as e: print(f"App error: {e}") # catches all app errors
Type Hints Deep Dive
Phase 1 introduced basic type hints. Now go deeper โ Union types, TypeVar, Protocols, TypedDict, Literal, and how to use mypy to catch bugs before runtime.
from typing import ( Optional, Union, Any, TypeVar, Callable, Sequence, Mapping, TypedDict, Protocol, Literal, Final ) # โโ Optional โ value or None (very common) โโ def find_user(user_id: int) -> Optional[dict]: # Python 3.10+ cleaner syntax: dict | None return db.get(user_id) # might be None # โโ Union โ one of several types โโ def parse_id(raw: Union[str, int]) -> int: # Python 3.10+: str | int return int(raw) # โโ Callable โ a function type โโ def apply( func: Callable[[int, int], int], # takes 2 ints, returns int a: int, b: int ) -> int: return func(a, b) # โโ TypeVar โ generic functions โโ T = TypeVar('T') def first(items: list[T]) -> T: # Return type is same T as element type return items[0] first([1, 2, 3]) # inferred: int first(["a", "b"]) # inferred: str # โโ TypedDict โ dict with a known shape โโ class PlayerData(TypedDict): name: str hp: int level: int alive: bool def display_player(p: PlayerData) -> None: # mypy knows p["name"] is a str, p["hp"] is an int print(f"{p['name']} โ {p['hp']} HP") # โโ Literal โ only specific values allowed โโ Direction = Literal["north", "south", "east", "west"] def move(direction: Direction) -> None: print(f"Moving {direction}") move("north") # โ valid move("up") # mypy error: not a valid Direction # โโ Protocol โ structural subtyping ("duck typing with types") โโ class Drawable(Protocol): def draw(self) -> None: ... def render_all(objects: list[Drawable]) -> None: for obj in objects: obj.draw() # Any class with a draw() method satisfies Drawable # โ no explicit inheritance needed. True duck typing + types.
- Install:
pip install mypy - Run on a file:
mypy your_file.py - Run strictly:
mypy --strict your_file.pyโ catches more issues, requires all annotations - Add a
mypy.iniorpyproject.tomlsection to configure per-project settings - In VS Code: install the Pylance extension โ gives you mypy-like feedback inline as you type
# type: ignore sparingly for things you'll fix later. Progress over perfection.Dataclasses โ Structured Data Without Boilerplate
@dataclass is Python's answer to Java Records โ auto-generates __init__, __repr__, __eq__ and more from your field declarations. Use them constantly.
class Player: def __init__(self, name, hp, level): self.name = name self.hp = hp self.level = level def __repr__(self): return (f"Player(name={self.name!r}," f" hp={self.hp}," f" level={self.level})") def __eq__(self, other): if not isinstance(other, Player): return NotImplemented return (self.name == other.name and self.hp == other.hp and self.level == other.level)
from dataclasses import dataclass @dataclass class Player: name: str hp: int level: int # That's it. Python generates: # โ __init__(self, name, hp, level) # โ __repr__ with all fields # โ __eq__ comparing all fields # All correct. All free.
from dataclasses import dataclass, field, asdict, astuple from typing import ClassVar import datetime @dataclass class Task: # Required fields (no default) must come before optional ones title: str priority: int # Optional fields with defaults done: bool = False tags: list[str] = field(default_factory=list) # โ NEVER use default=[] โ mutable defaults are shared! # default_factory creates a NEW list for each instance # Auto-set field โ not in __init__ signature created: str = field( default_factory=lambda: datetime.datetime.now().isoformat(), init=False # excluded from constructor ) # Class variable (shared, not per-instance) MAX_PRIORITY: ClassVar[int] = 5 # You can still add methods normally def complete(self) -> None: self.done = True # __post_init__ โ runs after the generated __init__ def __post_init__(self) -> None: if not 1 <= self.priority <= self.MAX_PRIORITY: raise ValueError( f"Priority must be 1โ{self.MAX_PRIORITY}, got {self.priority}" ) self.title = self.title.strip() # clean up input # โโ Frozen dataclass โ immutable (like Java record) โโ @dataclass(frozen=True) class Point: x: float y: float # frozen=True: auto-generates __hash__, prevents mutation # Can be used in sets and as dict keys p = Point(3.0, 4.0) print(asdict(p)) # {'x': 3.0, 'y': 4.0} โ to dict, free! # p.x = 5 โ FrozenInstanceError โ immutable # โโ Ordered dataclass โ sortable โโ @dataclass(order=True) class Score: value: int player_name: str scores = [Score(85, "Jude"), Score(92, "Ana"), Score(78, "Max")] print(sorted(scores)) # sorted by value, then name โ free!
tags: list = [] in a dataclass. That single [] is created once at class definition time and shared between ALL instances โ modifying one modifies all. Always use field(default_factory=list) for mutable defaults like lists and dicts.field(default_factory=list) instead of = [] for a list field in a dataclass?Rebuild the Task Manager โ Pythonically
Take the Phase 1 task manager and rebuild it from scratch using every concept from Phase 2. Same functionality, dramatically better code.
The Phase 1 project was functional. This version should be Pythonic. Every lesson from Phase 2 should appear somewhere in the final code.
โ Dataclasses โ Task, Tag, Priority using
@dataclass with proper defaults
โ Properties โ validated
priority setter, computed is_overdue property
โ Dunder methods โ
__str__, __repr__, __eq__, __lt__ on Task so they sort naturally
โ Context manager โ file-based persistence using
@contextmanager
โ Generator โ
pending_tasks() yields tasks lazily
โ Custom exceptions โ
TaskNotFoundError, ValidationError
โ Decorators โ
@log_calls on mutating methods
โ Type hints โ full annotation including
Optional, Literal, and TypedDict
from __future__ import annotations from dataclasses import dataclass, field, asdict from contextlib import contextmanager from typing import Iterator, Literal, Optional from pathlib import Path import json, datetime, functools, logging logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger(__name__) # โโโ Custom Exceptions โโโโโโโโโโโโโโโโโโโโโโโโโโโ class TaskError(Exception): pass class TaskNotFoundError(TaskError): def __init__(self, task_id: str): super().__init__(f"Task not found: {task_id!r}") class ValidationError(TaskError): def __init__(self, field_name: str, msg: str): super().__init__(f"{field_name}: {msg}") # โโโ Logging decorator โโโโโโโโโโโโโโโโโโโโโโโโโโ def log_mutation(func): @functools.wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) log.info(f"{func.__name__} called") return result return wrapper # โโโ Priority type โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ Priority = Literal[1, 2, 3, 4, 5] PRIORITY_LABELS = {1:"low", 2:"normal", 3:"high", 4:"urgent", 5:"critical"} # โโโ Task dataclass โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ @dataclass(order=True) class Task: title: str _priority: int = field(default=2, repr=False) done: bool = field(default=False, compare=False) tags: list[str] = field(default_factory=list, compare=False) due: Optional[str] = field(default=None, compare=False) created: str = field( default_factory=lambda: datetime.date.today().isoformat(), init=False, compare=False ) def __post_init__(self) -> None: if not self.title.strip(): raise ValidationError("title", "cannot be blank") self.title = self.title.strip() @property def priority(self) -> int: return self._priority @priority.setter def priority(self, value: int) -> None: if not 1 <= value <= 5: raise ValidationError("priority", "must be 1โ5") self._priority = value @property def is_overdue(self) -> bool: if not self.due: return False return datetime.date.fromisoformat(self.due) < datetime.date.today() def __str__(self) -> str: status = "โ" if self.done else ("!" if self.is_overdue else "โ") plabel = PRIORITY_LABELS[self._priority] due_str = f" due:{self.due}" if self.due else "" return f"[{status}] {self.title} [{plabel}]{due_str}" # โโโ Context manager for file I/O โโโโโโโโโโโโโโโโ @contextmanager def task_store(path: Path) -> Iterator[list[Task]]: tasks: list[Task] = [] if path.exists(): raw = json.loads(path.read_text()) tasks = [Task(**r) for r in raw] try: yield tasks finally: path.write_text(json.dumps([asdict(t) for t in tasks], indent=2)) # โโโ TaskManager โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ class TaskManager: def __init__(self, path: str = "tasks.json"): self._path = Path(path) self._tasks: list[Task] = self._load() def _load(self) -> list[Task]: if not self._path.exists(): return [] return [Task(**r) for r in json.loads(self._path.read_text())] def _save(self) -> None: self._path.write_text( json.dumps([asdict(t) for t in self._tasks], indent=2)) @log_mutation def add(self, title: str, priority: int = 2, tags: list[str] = None, due: Optional[str] = None) -> Task: task = Task(title=title, _priority=priority, tags=tags or [], due=due) self._tasks.append(task) self._save() return task def pending(self) -> Iterator[Task]: """Generator โ yields pending tasks by priority (highest first).""" for task in sorted(self._tasks, reverse=True): if not task.done: yield task def overdue(self) -> Iterator[Task]: """Generator โ yields only overdue pending tasks.""" return (t for t in self.pending() if t.is_overdue) def __len__(self) -> int: return len(self._tasks) def __iter__(self): return iter(self._tasks) def __repr__(self) -> str: return f"TaskManager({len(self)} tasks)" if __name__ == "__main__": mgr = TaskManager() mgr.add("Learn Phase 3 โ The Ecosystem", priority=5) mgr.add("Ship Death Road prototype", priority=4, due="2026-07-01") mgr.add("Read The Pragmatic Programmer", priority=2) print(f"\n{mgr}\n") for task in mgr.pending(): print(task)
After the baseline works, push further:
- Add a
search(query: str)method that uses a generator to filter across title and tags - Add a
@dataclassTaskStatswith computed fields (total, done, pending, overdue counts) usingitertools - Write a
bulk_complete(*titles: str)method using*argsโ mark multiple tasks done at once - Add a
@retry(times=3)decorator to the_save()method to handle I/O failures gracefully
requests, pydantic, typer, and the libraries that make Python fast to ship with.