Why Python, and why go deep?
Python is the language of the AI engineering stack. Not because it's the best language โ it isn't โ but because the entire ecosystem converged on it. NumPy, PyTorch, FastAPI, LangChain, OpenAI's SDK โ all Python. When you're working as an AI engineer, every tool you reach for will be Python. The community decided, and that decision is final for this decade.
But here's the thing about Python that trips up AI-assisted coders: Python is easy to write badly and hard to read when written badly. AI tools generate syntactically correct Python constantly. Your job โ especially given what your role needs โ is to read that output and know whether it's good Python or just working Python. There's a big difference.
This module goes deeper than "variables and loops." You already know those. We're going to the parts where real Python diverges from beginner Python: proper OOP, type safety, error discipline, and the idioms that make a codebase readable six months later.
Object-Oriented Programming done right
OOP in Python gets misused constantly. Here's what you actually need to know:
Classes: what they're for
A class bundles state (data) and behaviour (methods) that belong together. The test: if you find yourself passing the same three variables into every function, they want to be a class.
# Bad โ passing the same state around everywhere
def get_flight_status(flight_number, departure, arrival):
...
def delay_flight(flight_number, departure, arrival, minutes):
...
# Good โ state lives in one place
class Flight:
def __init__(self, number: str, departure: datetime, arrival: datetime):
self.number = number
self.departure = departure
self.arrival = arrival
def delay(self, minutes: int) -> None:
self.departure += timedelta(minutes=minutes)
self.arrival += timedelta(minutes=minutes)
The dunder methods you must know
Python's "dunder" (double underscore) methods make your classes work with the language itself. These aren't advanced โ they're what makes your code readable:
__init__โ constructor, called when you create an instance__repr__โ what you see when youprint()an object or inspect it in a debugger. Always implement this. It saves hours of debugging.__str__โ human-readable string. Falls back to__repr__if not defined.__eq__โ what==means for your objects__len__โ whatlen(obj)returns__iter__/__next__โ makes your object iterable in aforloop
class Task:
def __init__(self, title: str, priority: int = 1):
self.title = title
self.priority = priority
self.done = False
def __repr__(self) -> str:
status = "โ" if self.done else "โ"
return f"Task({status} [{self.priority}] {self.title!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Task):
return NotImplemented
return self.title == other.title
Inheritance: use sparingly
Inheritance is tempting but frequently overused. The rule: prefer composition over inheritance. If you're inheriting more than one level deep ("this class extends that class which extends that other class"), you've probably made a design mistake. Flat is better than nested.
When to actually use inheritance: when objects share both state and behaviour, and there's a genuine "is-a" relationship. A PriorityTask is a Task. An Employee is a Person. Use it for that; reach for composition for everything else.
Type hints โ write them everywhere
Python is dynamically typed, which means the interpreter won't stop you from passing a string where you meant an integer. Type hints don't fix that at runtime, but they do three things that matter enormously:
- Your editor catches bugs before you run anything โ a function that expects
intgets called withstr? Your IDE underlines it immediately. - Code is self-documenting โ you never have to wonder what a function takes as arguments.
- AI tools generate better code โ when your codebase has type hints, Cody can infer what types flow where and generate more correct completions.
from typing import Optional, List, Dict
# Without type hints โ what does this return?
def find_user(user_id, active_only=True):
...
# With type hints โ immediately clear
def find_user(
user_id: int,
active_only: bool = True
) -> Optional["User"]:
"""Return User by ID, or None if not found."""
...
Key types to know: str, int, float, bool, None, Optional[X] (might be None), List[X], Dict[K,V], Tuple[X,Y], Union[X,Y]. In Python 3.10+: use X | None instead of Optional[X].
Exception handling โ the right way
This is one of the most common places AI-generated code goes wrong, and one of the easiest flaws to spot in a review.
except: or except Exception: without re-raising or logging is almost always wrong. It silently swallows errors. A function that "works" but silently eats exceptions will cause mysterious failures at 3am.
# โ Wrong โ silently swallows ALL errors including KeyboardInterrupt
try:
result = process_data(data)
except:
pass
# โ Wrong โ too broad, and eating the error
try:
result = process_data(data)
except Exception:
result = None
# โ Right โ catch specific exceptions, log them, re-raise or return meaningful state
try:
result = process_data(data)
except ValueError as e:
logger.error(f"Invalid data format: {e}")
raise
except IOError as e:
logger.error(f"File operation failed: {e}")
return None # acceptable if callers handle None
Custom exceptions
Define custom exceptions for your domain โ they make error handling explicit and readable. The caller can catch InsufficientFundsError and handle it specifically, rather than catching ValueError and guessing what went wrong.
class AppError(Exception):
"""Base exception for this application."""
class NotFoundError(AppError):
def __init__(self, resource: str, id_: int):
self.resource = resource
self.id_ = id_
super().__init__(f"{resource} with id={id_} not found")
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(f"Validation failed for '{field}': {message}")
Comprehensions โ readable, not clever
List, dict, and set comprehensions are one of Python's strengths. They're concise and fast. But they become unreadable when abused.
# โ Good โ clear and concise
active_users = [u for u in users if u.is_active]
email_map = {u.id: u.email for u in users}
unique_domains = {u.email.split("@")[1] for u in users}
# โ Bad โ nested comprehension that requires re-reading three times
result = [item for sublist in [[x*2 for x in row] for row in matrix] for item in sublist]
# โ Better โ just use a loop when it's complex
result = []
for row in matrix:
for x in row:
result.append(x * 2)
Rule of thumb: if a comprehension doesn't fit on one line without squinting, replace it with a loop.
Generators โ lazy is good
A generator is a function that produces values one at a time instead of building a full list in memory. This matters when you're processing large files, database results, or streams.
# Without generator โ loads ALL lines into memory at once
def read_big_file(path: str) -> List[str]:
with open(path) as f:
return f.readlines() # 10GB file = 10GB in RAM
# With generator โ yields one line at a time
def read_big_file(path: str):
with open(path) as f:
for line in f:
yield line.strip()
# Caller code is identical either way
for line in read_big_file("huge.log"):
process(line)
Spot this pattern in AI-generated code: if a function builds a list and returns it, ask yourself โ does the caller actually need all of it at once? If not, it should probably be a generator.
The mutable default argument trap
This is one of Python's most famous gotchas, and AI tools still generate this bug regularly. Learn to spot it.
# โ Bug โ the list is created ONCE when the function is defined
# Every call that doesn't pass `items` shares the SAME list
def add_item(item: str, items: List[str] = []) -> List[str]:
items.append(item)
return items
add_item("a") # ['a']
add_item("b") # ['a', 'b'] โ 'a' is still there!
# โ Correct โ use None as default, create fresh list inside
def add_item(item: str, items: Optional[List[str]] = None) -> List[str]:
if items is None:
items = []
items.append(item)
return items
Any time you see def func(arg=[]): or def func(arg={}):, that's a bug waiting to happen. Same for def func(arg=SomeClass()). Flag it immediately in a code review.
Decorators โ what they actually are
Decorators sound scary but they're just functions that wrap other functions. You'll see them constantly in FastAPI, Flask, and testing frameworks. You need to read them, not necessarily write them from scratch.
# A decorator is just a function that takes a function and returns a function
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
# @log_call is syntactic sugar for: greet = log_call(greet)
@log_call
def greet(name: str) -> str:
return f"Hello, {name}"
greet("John")
# Calling greet
# greet returned Hello, John
In FastAPI you'll write @app.get("/users") โ that's a decorator registering your function as an HTTP route handler. In pytest you'll use @pytest.fixture. You don't need to build these, but you must be able to read them and understand what they're doing.
Enums โ stop using magic strings
Using raw strings for a finite set of options is a common code smell AI tools produce. When you see status = "active" compared against if status == "actve": (typo!), you've found the bug. Enums eliminate this class of error entirely.
from enum import Enum, auto
# โ Magic strings โ typo-prone, no IDE completion
def process(status: str): # "active", "inactive", "pending"???
if status == "actve": # typo, silently wrong
...
# โ Enum โ self-documenting, IDE-completable, typo-proof
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
def process(status: Status) -> None:
if status == Status.ACTIVE: # IDE autocompletes, can't typo
...