Metaclasses · Descriptors · Memory · Profiling · C-Extensions
Flask · FastAPI · Microservices · Docker · CI/CD · Cloud
__slots__, dynamic attributes.__slots__8Unit V is the expert/production unit — it takes you from "writing Python" to engineering production systems in Python. The hardest sections are metaclasses and descriptors (§I–II); go slowly, trace every example by hand. The web/deployment sections (§VI–IX) are the highest-value for placement interviews. Every snippet is tested on Python 3.11+.
In Python, classes are themselves objects. Every class is an instance of some metaclass — normally type. Understanding this chain is the key to metaprogramming.
class Student:
pass
s = Student()
print(type(s)) # <class '__main__.Student'> — s is an instance of Student
print(type(Student)) # <class 'type'> — Student is an instance of type
print(type(type)) # <class 'type'> — type is an instance of itself
# The chain:
# object ← type
# ↑ ↑
# | |
# Student ── instance ── s
If type(obj) is a class, then type(that_class) is its metaclass. By default this is type.
type()The type(name, bases, dict) form creates a new class at runtime.
# Normal class definition
class Dog:
species = "Canis familiaris"
def bark(self):
return "Woof!"
# Equivalent dynamic creation
Dog2 = type("Dog2", (), {
"species": "Canis familiaris",
"bark": lambda self: "Woof!"
})
d = Dog2()
print(d.bark()) # Woof!
print(Dog2.__name__) # Dog2
print(Dog2.__bases__) # (<class 'object'>)
A metaclass is a class whose instances are classes. Subclassing type lets you hook into class creation to enforce rules, auto-register classes, inject methods, or validate attributes.
class RegistryMeta(type):
registry = {}
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
if name != "Base": # skip the base itself
RegistryMeta.registry[name] = cls
return cls
class Base(metaclass=RegistryMeta):
pass
class EmailSender(Base):
def send(self): return "email sent"
class SMSSender(Base):
def send(self): return "sms sent"
print(list(RegistryMeta.registry.keys()))
# ['EmailSender', 'SMSSender']
# Plugin pattern: pick a sender by name at runtime
sender_cls = RegistryMeta.registry["EmailSender"]
print(sender_cls().send()) # email sent
class NamingMeta(type):
def __new__(mcs, name, bases, namespace):
if not name[0].isupper():
raise TypeError(f"Class name '{name}' must be PascalCase")
for attr in namespace:
if attr.startswith("get_") and not callable(namespace[attr]):
raise TypeError(f"Method '{attr}' must be callable")
return super().__new__(mcs, name, bases, namespace)
class GoodClass(metaclass=NamingMeta): # OK
def get_value(self): return 42
# class badClass(metaclass=NamingMeta): # TypeError
# pass
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 Logger(metaclass=SingletonMeta):
def __init__(self):
self.messages = []
a, b = Logger(), Logger()
print(a is b) # True
__init_subclass__ — the Lighter AlternativeSince Python 3.6, most metaclass use-cases can be replaced by __init_subclass__, which is simpler and composes better.
class Base:
registry = {}
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Base.registry[cls.__name__] = cls
class Alpha(Base): pass
class Beta(Base): pass
print(Base.registry.keys())
# dict_keys(['Alpha', 'Beta'])
__set_name__ and Descriptor-Aware Classesclass Field:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Model:
name = Field()
email = Field()
m = Model()
m.name = "Aarav"
m.email = "aarav@example.com"
print(m.name, m.email) # Aarav aarav@example.com
| Use case | Example |
|---|---|
| Plugin registries | Django, Flask extensions |
| ORM models | SQLAlchemy declarative base, Django models |
| API schema generation | Pydantic, dataclasses |
| Enforcing invariants | Final classes, sealed hierarchies |
| Auto-registration | Command handlers, test discovery |
| Interface/ABC enforcement | abc.ABCMeta |
If a class inherits from multiple bases with incompatible metaclasses, Python raises TypeError: metaclass conflict. Resolve by defining a metaclass that inherits from all parent metaclasses:
class CombinedMeta(MetaA, MetaB):
pass
class MyClass(BaseA, BaseB, metaclass=CombinedMeta):
pass
Metaclasses are powerful but make code harder to read and debug. Prefer __init_subclass__, class decorators, or descriptors unless you specifically need to intercept class creation.
__slots__A descriptor is any object that implements one or more of __get__, __set__, or __delete__. When assigned as a class attribute, it intercepts attribute access on instances.
| Protocol method | Triggered by |
|---|---|
__get__(self, obj, objtype) | Reading the attribute: obj.x |
__set__(self, obj, value) | Assigning the attribute: obj.x = v |
__delete__(self, obj) | Deleting the attribute: del obj.x |
| Type | Implements | Precedence |
|---|---|---|
| Data descriptor | __get__ + __set__ (and/or __delete__) | Beats instance __dict__ |
| Non-data descriptor | Only __get__ | Loses to instance __dict__ |
Attribute lookup order: data descriptor → instance dict → non-data descriptor → class dict → __getattr__.
class PositiveNumber:
"""Descriptor that only accepts positive numbers."""
def __set_name__(self, owner, name):
self.name = name
self.private = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private, None)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name} must be numeric")
if value <= 0:
raise ValueError(f"{self.name} must be positive")
setattr(obj, self.private, value)
class Product:
price = PositiveNumber()
quantity = PositiveNumber()
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
def total(self):
return self.price * self.quantity
p = Product("Laptop", 50000, 3)
print(p.total()) # 150000
try:
p.price = -100
except ValueError as e:
print("Error:", e) # Error: price must be positive
@property is a descriptorclass Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.celsius = 30
print(t.fahrenheit) # 86.0
property is a built-in data descriptor; it is the most common descriptor you will use.
class CachedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = self.func(obj)
obj.__dict__[self.name] = value # cache on the instance
return value
class Expensive:
def __init__(self, n):
self.n = n
@CachedProperty
def computed(self):
print("Computing...")
return sum(i ** 2 for i in range(self.n))
e = Expensive(1_000_000)
print(e.computed) # Computing... then value
print(e.computed) # no recompute (cached)
print(e.computed) # cached
Because CachedProperty is a non-data descriptor (only __get__), the value stored in obj.__dict__ shadows it on subsequent lookups.
__slots__ — Memory-Optimised ClassesBy default, every instance stores its attributes in a __dict__ (a hash table). For classes with millions of instances or a fixed set of attributes, __slots__ eliminates the per-instance dict, reducing memory and speeding up attribute access.
import sys
class PointDict:
def __init__(self, x, y):
self.x = x
self.y = y
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
a = PointDict(1, 2)
b = PointSlots(1, 2)
print("dict-based:", sys.getsizeof(a) + sys.getsizeof(a.__dict__))
# ~152 bytes
print("slots-based:", sys.getsizeof(b))
# ~56 bytes — nearly 3× smaller
# a.z = 3 # OK
# b.z = 3 # AttributeError: 'PointSlots' object has no attribute 'z'
| Rule | Detail |
|---|---|
| Cannot add new attributes | Only those listed in __slots__ |
No __dict__ per instance | Unless you add "__dict__" to __slots__ |
| Inheritance | A subclass without __slots__ reintroduces __dict__ |
| Class variables can't collide | Cannot share a name with a slot |
| Weak references | Add "__weakref__" to slots if needed |
class Base:
__slots__ = ("x",)
class Child(Base):
__slots__ = ("y",) # must redeclare; do NOT repeat "x"
c = Child()
c.x = 1
c.y = 2
print(c.x, c.y) # 1 2
# Child without __slots__ would reintroduce __dict__ and lose memory savings.
class BadChild(Base):
pass # no __slots__ → has __dict__
b = BadChild()
b.z = 99 # allowed (dict exists)
print(b.__dict__) # {'z': 99}
| Scenario | Recommended |
|---|---|
| Millions of small, fixed-shape objects | __slots__ |
| Data validation on assignment | Descriptor or @property |
| Lazy evaluation of a computed attribute | Non-data descriptor (cached property) |
| Simple read-only attribute | @property |
| Dynamic attribute schema (ORM) | Descriptors + metaclass |
For descriptor questions, always state the difference between data and non-data descriptors and their lookup precedence. For __slots__, mention two benefits (memory, speed) and two limitations (no dynamic attributes, inheritance must redeclare).
CPython tracks the number of references to every object. When the count drops to zero, the object is deallocated immediately.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (a + temp reference inside getrefcount)
b = a
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
Object is freed when refcount == 0. sys.getrefcount() always reports one extra because its own argument is a temporary reference.
Reference counting cannot free cycles (a.ref = b; b.ref = a) because each object keeps the other alive. CPython includes a generational garbage collector that periodically detects and breaks such cycles.
import gc
# Check GC is enabled
print(gc.isenabled()) # True
# The three generations
print(gc.get_threshold()) # (700, 10, 10) default
# Force a collection
collected = gc.collect()
print("Collected:", collected)
# Disable (rarely needed)
gc.disable()
gc.enable()
# Inspect objects tracked by GC
gc.set_debug(gc.DEBUG_LEAK)
| Generation | Purpose | Collected when |
|---|---|---|
| Gen 0 | Newly created objects | Allocation count exceeds threshold 0 |
| Gen 1 | Survivors of gen 0 | After N collections of gen 0 |
| Gen 2 | Long-lived objects | After M collections of gen 1 |
weakref — Non-Owning ReferencesA weak reference lets you reference an object without preventing its garbage collection. Ideal for caches, observer lists and back-references.
import weakref
class Node:
def __init__(self, name):
self.name = name
n = Node("root")
r = weakref.ref(n)
print(r()) # <Node object at ...>
print(r().name) # root
del n
print(r()) # None — object was collected
# WeakValueDictionary — auto-removes dead entries
cache = weakref.WeakValueDictionary()
class Cached:
def __init__(self, key): self.key = key
c = Cached("a")
cache["a"] = c
print(len(cache)) # 1
del c
print(len(cache)) # 0 (auto-removed)
import weakref
class Child:
def __init__(self, parent, name):
self.parent = weakref.ref(parent) # weak back-reference
self.name = name
class Parent:
def __init__(self, name):
self.name = name
self.children = []
def add(self, child_name):
c = Child(self, child_name)
self.children.append(c)
return c
p = Parent("root")
p.add("child1")
p.add("child2")
# Even after deleting p, no cycle keeps it alive
# del p # parent and children are freed together
__del__ and Finalisers__del__
__del__ is called when an object is collected, but timing is unpredictable and it can resurrect objects or hide bugs. Prefer weakref.finalize or context managers for cleanup.
import weakref
class Resource:
def __init__(self, name):
self.name = name
self.finalizer = weakref.finalize(self, self._cleanup, name)
@staticmethod
def _cleanup(name):
print(f"Cleaning up {name}")
r = Resource("db-connection")
del r # Clean up printed exactly once, deterministically
import sys, tracemalloc
# Object size
print(sys.getsizeof([1, 2, 3])) # ~80 bytes for the list container
# Total heap usage with tracemalloc
tracemalloc.start()
data = [str(i) for i in range(100_000)]
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:5]:
print(stat)
tracemalloc.stop()
| Cause | Symptom | Fix |
|---|---|---|
Reference cycles with __del__ | Objects never freed | Avoid __del__; use weakref.finalize |
| Global caches without limits | Memory grows unbounded | functools.lru_cache(maxsize=...) or WeakValueDictionary |
| Event listeners never removed | Subscribers accumulate | Weak references in observer lists |
| Exceptions holding tracebacks | Frame chains kept alive | Use raise ... from None or clear tracebacks |
| Large objects in closures | Closure keeps whole tree | Break references explicitly |
| C extensions not freeing | Slow growth | Audit native code; use tracemalloc |
State clearly: CPython uses reference counting as the primary mechanism plus a generational GC to handle cycles. Mention weakref for caches and back-references, and tracemalloc/sys.getsizeof for measurement.
Use profilers, not intuition. 90% of runtime is usually spent in 10% of the code — optimising anything else is wasted effort.
timeitimport timeit
# Time a single expression (runs it 1,000,000 times)
t = timeit.timeit("'-'.join(str(n) for n in range(100))", number=10_000)
print(f"{t:.4f}s")
# Compare two implementations
setup = "data = list(range(1000))"
a = timeit.timeit("sum(x**2 for x in data)", setup=setup, number=1000)
b = timeit.timeit("[x**2 for x in data]", setup=setup, number=1000)
print(f"generator: {a:.4f}s, list: {b:.4f}s")
# Command-line
# python -m timeit -n 10000 -s "data=list(range(1000))" "sum(x for x in data)"
cProfile — Function-Level Profilingimport cProfile
def slow():
return sum(i ** 2 for i in range(1_000_000))
def fast():
return sum(i * i for i in range(1_000_000))
def main():
for _ in range(3):
slow(); fast()
cProfile.run("main()", sort="cumulative")
# Command line
# python -m cProfile -s cumtime script.py
| Column | Meaning |
|---|---|
ncalls | Number of calls |
tottime | Time in the function itself (excluding subcalls) |
cumtime | Cumulative time including subcalls |
percall | Average time per call |
line_profiler — Line-by-Line Timingpip install line_profiler
# In code:
from line_profiler import LineProfiler
def process(data):
result = []
for x in data:
result.append(x ** 2)
return sum(result)
profiler = LineProfiler()
profiler.add_function(process)
profiler.enable_by_count()
process(list(range(100_000)))
profiler.print_stats()
# Or use the kernel: kernprof -l -v script.py
memory_profiler — Line-by-Line Memorypip install memory_profiler
# Decorate a function
from memory_profiler import profile
@profile
def build_large():
a = [i for i in range(1_000_000)]
b = [i * 2 for i in a]
del a
return b
build_large()
# Command-line
# python -m memory_profiler script.py
| Optimisation | Gain | Notes |
|---|---|---|
Use built-ins (sum, map, any) | High | Implemented in C |
| Use list/dict/set comprehensions | Medium–High | Avoid manual loops for construction |
| Use generators for large streams | Memory | Avoid materialising intermediate lists |
Cache expensive calls (lru_cache) | High | Only for pure functions |
Use __slots__ | Memory + speed | For many small objects |
Use str.join instead of += in a loop | High | Strings are immutable |
| Use sets/dicts for membership tests | High | \(O(1)\) vs \(O(n)\) |
| Move work outside loops | Medium | Loop-invariant hoisting |
| Prefer local variables | Low–Medium | Faster than global lookups |
Use array / NumPy for numeric data | Very high | Contiguous memory, vectorised C |
import timeit
# 1. Join vs += in a loop
setup = "words = ['a'] * 1000"
t1 = timeit.timeit("s=''\nfor w in words: s += w", setup=setup, number=1000)
t2 = timeit.timeit("''.join(words)", setup=setup, number=1000)
print(f"+=: {t1:.4f}s join: {t2:.4f}s") # join is 5–10× faster
# 2. Set vs list membership
setup = "data = list(range(10_000)); target = 9999"
t1 = timeit.timeit("target in data", setup=setup, number=10_000)
t2 = timeit.timeit("target in set(data)", setup=setup + "\ns = set(data)",
number=10_000)
print(f"list: {t1:.4f}s set: {t2:.4f}s")
# 3. Local vs global name lookup
setup = "x = 0\n"
t1 = timeit.timeit("for _ in range(1000): x", setup=setup, number=1000)
t2 = timeit.timeit("def f():\n y=0\n for _ in range(1000): y\nf()",
setup=setup, number=1000)
lru_cachefrom functools import lru_cache
import timeit
def fib_plain(n):
if n <= 1: return n
return fib_plain(n - 1) + fib_plain(n - 2)
@lru_cache(maxsize=None)
def fib_cached(n):
if n <= 1: return n
return fib_cached(n - 1) + fib_cached(n - 2)
# fib_plain(30) would take seconds; fib_cached(30) is instant
print(timeit.timeit("fib_cached(30)", globals=globals(), number=1000))
print(fib_cached.cache_info())
\[ S(N) = \frac{1}{(1-p) + \frac{p}{N}} \]
If only 50% of the code is parallelisable (\(p=0.5\)), the maximum speedup with infinite cores is \(2\times\). This is why profiling must guide parallelisation efforts.
For performance questions, always say: "First profile with cProfile, then use timeit to A/B-test candidate optimisations, then verify the win in production." Mention lru_cache, built-ins, comprehensions and __slots__ as concrete tools.
Pure Python is fast enough for most tasks, but CPU-bound hot loops can be 10–100× slower than C. Python provides several escape hatches:
| Tool | Approach | Effort | Speedup |
|---|---|---|---|
ctypes | Call existing C libraries | Low | High |
cffi | Bind to C at runtime | Medium | High |
| CPython C API | Write C extension modules | High | Very high |
| Cython | Python-like → C | Medium | Very high |
| Numba | JIT-compile numeric functions | Low | Very high |
Rust (pyo3) | Rust extensions | High | Very high |
| NumPy | Vectorised C operations | Low | High (for arrays) |
ctypes — Calling C Without Compiling Python Code# Step 1: write a small C library (libmath.c)
# int square(int x) { return x * x; }
# Compile:
# gcc -shared -fPIC -o libmath.so libmath.c (Linux/macOS)
# cl /LD libmath.c (Windows)
# Step 2: call it from Python
import ctypes
lib = ctypes.CDLL("./libmath.so")
lib.square.argtypes = [ctypes.c_int]
lib.square.restype = ctypes.c_int
print(lib.square(12)) # 144
# Calling libc functions directly
libc = ctypes.CDLL(None)
print(libc.strlen(b"hello")) # 5
Cython is a superset of Python that compiles to C. You can start with unmodified Python code and progressively add type declarations for speed.
# File: primes.pyx
def count_primes(int limit):
cdef int n, d, count = 0
cdef bint is_prime
for n in range(2, limit):
is_prime = True
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
is_prime = False
break
if is_prime:
count += 1
return count
# Build: create setup.py
# from setuptools import setup
# from Cython.Build import cythonize
# setup(ext_modules=cythonize("primes.pyx"))
#
# python setup.py build_ext --inplace
#
# Then: import primes; primes.count_primes(1_000_000)
cdef for C-typed variables inside functions.def f(int x):.cpdef to expose both Python and C-callable versions.nogil blocks to release the GIL for true parallelism.@cython.boundscheck(False) to skip array bounds checks.pip install numba
from numba import njit
import numpy as np
import time
@njit
def sum_squares(n):
total = 0
for i in range(n):
total += i * i
return total
# First call compiles; subsequent calls are native speed
start = time.perf_counter()
print(sum_squares(10_000_000))
print(f"JIT: {time.perf_counter() - start:.3f}s")
# Compare with pure Python
def sum_squares_py(n):
total = 0
for i in range(n):
total += i * i
return total
start = time.perf_counter()
print(sum_squares_py(10_000_000))
print(f"Python: {time.perf_counter() - start:.3f}s")
Typical speedup: 50–200× for numeric loops.
| Situation | Recommended |
|---|---|
| Calling an existing C library | ctypes or cffi |
| Heavy numeric loops with NumPy arrays | Numba (@njit) |
| Mixed Python/C logic, distribution matters | Cython |
| Already using NumPy for arrays | Vectorised NumPy (no extension) |
| Whole-application rewrite for speed | PyPy or Rust (pyo3) |
| Simple, portable, no build step | Pure Python + algorithms |
import numpy as np
import time
from numba import njit
N = 10_000_000
# Pure Python
def pure(n):
s = 0
for i in range(n):
s += i * i
return s
# NumPy vectorised
def numpy_version(n):
arr = np.arange(n, dtype=np.int64)
return int((arr * arr).sum())
# Numba JIT
@njit
def numba_version(n):
s = 0
for i in range(n):
s += i * i
return s
numba_version(1) # warm up
for name, fn in [("Pure", pure), ("NumPy", numpy_version), ("Numba", numba_version)]:
start = time.perf_counter()
fn(N)
print(f"{name:<6}: {time.perf_counter() - start:.3f}s")
# Typical results on a laptop:
# Pure : 1.20 s
# NumPy: 0.06 s
# Numba: 0.015 s
A web framework handles the boilerplate of HTTP: parsing requests, routing, rendering responses, managing sessions and templates. Flask is a micro-framework — minimal core with opt-in extensions.
| Framework | Style | Best for |
|---|---|---|
| Flask | Micro, explicit | APIs, small services, prototypes |
| Django | Batteries-included | Large monoliths, admin-heavy apps |
| FastAPI | Async, type-driven | High-performance APIs |
| Starlette | Async toolkit | Low-level async web apps |
pip install flask
# app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "<h1>Hello, Flask!</h1>"
@app.route("/about")
def about():
return {"course": "INT108", "topic": "Flask"}
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)
Run with python app.py and visit http://localhost:5000/.
from flask import Flask
app = Flask(__name__)
# Path parameter (string by default)
@app.route("/user/<name>")
def user(name):
return f"Hello, {name}!"
# Typed path parameter
@app.route("/post/<int:post_id>")
def show_post(post_id):
return f"Post #{post_id}"
# Float and path converters
@app.route("/price/<float:amount>")
def price(amount):
return f"Price: {amount}"
@app.route("/files/<path:subpath>")
def files(subpath):
return f"File path: {subpath}"
# HTTP methods
@app.route("/items", methods=["GET", "POST"])
def items():
from flask import request
if request.method == "POST":
return {"status": "created"}, 201
return {"items": []}
from flask import Flask, request, jsonify, make_response
app = Flask(__name__)
@app.route("/search")
def search():
# Query string: /search?q=python&limit=10
q = request.args.get("q", "")
limit = request.args.get("limit", 10, type=int)
return {"query": q, "limit": limit}
@app.route("/submit", methods=["POST"])
def submit():
# JSON body
data = request.get_json(silent=True) or {}
name = data.get("name")
return jsonify({"received": name}), 201
@app.route("/custom")
def custom():
response = make_response("Custom body", 202)
response.headers["X-Course"] = "INT108"
response.set_cookie("session_id", "abc123")
return response
# templates/hello.html
<!DOCTYPE html>
<html>
<head><title>{{ title }}</title></head>
<body>
<h1>Hello, {{ name }}!</h1>
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<p>Course: {{ course }}</p>
</body>
</html>
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/hello/<name>")
def hello(name):
return render_template(
"hello.html",
title="Welcome",
name=name,
items=["Python", "Flask", "Jinja2"],
course="INT108",
)
# auth.py
from flask import Blueprint, jsonify
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
@auth_bp.route("/login", methods=["POST"])
def login():
return jsonify({"status": "logged in"})
@auth_bp.route("/logout", methods=["POST"])
def logout():
return jsonify({"status": "logged out"})
# app.py
from flask import Flask
from auth import auth_bp
app = Flask(__name__)
app.register_blueprint(auth_bp)
# Routes available at /auth/login, /auth/logout
import os
from flask import Flask
class Config:
SECRET_KEY = os.getenv("SECRET_KEY", "dev-key")
DEBUG = False
DB_URL = os.getenv("DATABASE_URL", "sqlite:///app.db")
class DevConfig(Config):
DEBUG = True
class ProdConfig(Config):
DEBUG = False
app = Flask(__name__)
app.config.from_object(ProdConfig if os.getenv("ENV") == "prod" else DevConfig)
print(app.config["DEBUG"])
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
tasks = {}
next_id = 1
@app.route("/tasks", methods=["GET"])
def list_tasks():
return jsonify(list(tasks.values()))
@app.route("/tasks/<int:task_id>", methods=["GET"])
def get_task(task_id):
task = tasks.get(task_id)
if not task:
abort(404, description="Task not found")
return jsonify(task)
@app.route("/tasks", methods=["POST"])
def create_task():
global next_id
data = request.get_json(silent=True) or {}
if "title" not in data:
abort(400, description="title is required")
task = {"id": next_id, "title": data["title"], "done": False}
tasks[next_id] = task
next_id += 1
return jsonify(task), 201
@app.route("/tasks/<int:task_id>", methods=["PUT"])
def update_task(task_id):
task = tasks.get(task_id)
if not task:
abort(404)
data = request.get_json(silent=True) or {}
task.update({k: v for k, v in data.items() if k in {"title", "done"}})
return jsonify(task)
@app.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
if task_id not in tasks:
abort(404)
del tasks[task_id]
return "", 204
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": str(e)}), 404
@app.errorhandler(400)
def bad_request(e):
return jsonify({"error": str(e)}), 400
if __name__ == "__main__":
app.run(debug=True)
Flask's dev server is not for production. Use gunicorn (Linux) or waitress (Windows).
pip install gunicorn
# Run with 4 worker processes
gunicorn -w 4 -b 0.0.0.0:8000 app:app
# Waitress (cross-platform)
pip install waitress
waitress-serve --port=8000 app:app
For Flask questions, show: (1) route decorator, (2) request parsing (args, get_json), (3) JSON response via jsonify, (4) correct status codes. Mention that the development server must not be used in production.
| Feature | Flask | FastAPI |
|---|---|---|
| Async support | Limited | Native (async def) |
| Type validation | Manual | Automatic via Pydantic |
| Auto docs | Extensions | Built-in OpenAPI + Swagger + ReDoc |
| Performance | Good | Very high (Starlette + Uvicorn) |
| Dependency injection | Manual | Built-in Depends |
| Learning curve | Low | Medium (needs Pydantic) |
pip install fastapi uvicorn[standard]
from fastapi import FastAPI
app = FastAPI(title="INT108 API", version="1.0.0")
@app.get("/")
async def root():
return {"message": "Hello, FastAPI!"}
# Run: uvicorn main:app --reload
# Docs: http://127.0.0.1:8000/docs
# ReDoc: http://127.0.0.1:8000/redoc
from fastapi import FastAPI, Query, Path
from typing import Optional
app = FastAPI()
@app.get("/items/{item_id}")
async def get_item(
item_id: int = Path(..., gt=0, description="Positive item id"),
q: Optional[str] = Query(None, max_length=50),
limit: int = Query(10, ge=1, le=100),
):
return {"item_id": item_id, "q": q, "limit": limit}
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
app = FastAPI()
class StudentCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
email: EmailStr
age: int = Field(..., ge=15, le=100)
branch: str = "CSE"
class StudentOut(StudentCreate):
id: int
# In-memory store
students: dict[int, StudentOut] = {}
next_id = 1
@app.post("/students", response_model=StudentOut, status_code=201)
async def create_student(payload: StudentCreate):
global next_id
student = StudentOut(id=next_id, **payload.model_dump())
students[next_id] = student
next_id += 1
return student
@app.get("/students", response_model=list[StudentOut])
async def list_students():
return list(students.values())
@app.get("/students/{student_id}", response_model=StudentOut)
async def get_student(student_id: int):
if student_id not in students:
raise HTTPException(status_code=404, detail="Student not found")
return students[student_id]
FastAPI automatically validates the body, returns 422 for invalid input, and generates OpenAPI docs.
import asyncio
from fastapi import FastAPI
app = FastAPI()
@app.get("/slow/{n}")
async def slow(n: int):
await asyncio.sleep(n) # non-blocking
return {"slept": n}
@app.get("/parallel")
async def parallel():
results = await asyncio.gather(
asyncio.sleep(1, result="A"),
asyncio.sleep(1, result="B"),
asyncio.sleep(1, result="C"),
)
return {"results": results} # ~1s total, not 3s
Do not call blocking functions (e.g., requests.get, time.sleep, blocking DB drivers) inside an async def endpoint. Use async libraries (httpx, asyncpg) or declare the endpoint as a regular def so FastAPI runs it in a threadpool.
from fastapi import FastAPI, Depends, Header, HTTPException
app = FastAPI()
def verify_token(x_token: str = Header(...)):
if x_token != "secret-token":
raise HTTPException(status_code=401, detail="Invalid token")
return x_token
def get_db():
# In a real app: yield a DB session
db = {"connected": True}
try:
yield db
finally:
db["connected"] = False
@app.get("/protected", dependencies=[Depends(verify_token)])
async def protected(db=Depends(get_db)):
return {"db": db}
from fastapi import FastAPI, status, HTTPException
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create(item: Item):
return {"created": item.name}
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(item_id: int):
return None
@app.get("/items/{item_id}", responses={404: {"description": "Not found"}})
async def get(item_id: int):
if item_id > 100:
raise HTTPException(404, "Item not found")
return {"id": item_id}
from fastapi import FastAPI, BackgroundTasks
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
print("Starting up: connect to DB")
app.state.cache = {}
yield
# Shutdown
print("Shutting down: close DB")
app.state.cache = None
app = FastAPI(lifespan=lifespan)
def send_email(to: str):
print(f"Sending email to {to}")
@app.post("/signup")
async def signup(email: str, bg: BackgroundTasks):
bg.add_task(send_email, email)
return {"status": "queued"}
from fastapi import FastAPI, Depends
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, sessionmaker, Session
DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
email = Column(String, unique=True, nullable=False)
Base.metadata.create_all(engine)
app = FastAPI()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.post("/users")
def create_user(name: str, email: str, db: Session = Depends(get_db)):
user = User(name=name, email=email)
db.add(user); db.commit(); db.refresh(user)
return {"id": user.id, "name": user.name}
# Development
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Production with multiple workers
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
# Behind Gunicorn (Linux) with uvicorn workers
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
| Aspect | unittest | pytest |
|---|---|---|
| Boilerplate | Class + methods | Plain functions |
| Assertions | self.assertEqual(a, b) | Plain assert a == b |
| Fixtures | setUp/tearDown | Composable @pytest.fixture |
| Parametrisation | Manual loops | @pytest.mark.parametrize |
| Plugins | Few | Rich ecosystem |
| Discovery | test_*.py | test_*.py or *_test.py |
pip install pytest pytest-cov pytest-mock
# test_math.py
import pytest
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
def test_add():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide():
assert divide(10, 2) == 5
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_divide_message():
with pytest.raises(ZeroDivisionError, match="Cannot divide"):
divide(10, 0)
# Run all tests
pytest -v
# With coverage
pytest --cov=mypackage --cov-report=term-missing
# Only failed from last run
pytest --lf
# Stop after first failure
pytest -x
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert a + b == expected
@pytest.mark.parametrize("s, expected", [
("madam", True),
("racecar", True),
("hello", False),
("", True),
])
def test_palindrome(s, expected):
assert (s == s[::-1]) == expected
import pytest
@pytest.fixture
def sample_students():
return [
{"name": "Aarav", "marks": [88, 92, 79]},
{"name": "Diya", "marks": [95, 81, 90]},
]
@pytest.fixture
def db_connection(tmp_path):
"""Uses tmp_path (built-in) for an isolated temporary DB per test."""
db_file = tmp_path / "test.db"
conn = {"file": str(db_file), "open": True}
yield conn
conn["open"] = False # teardown
def test_count(sample_students):
assert len(sample_students) == 2
def test_first_student(sample_students):
assert sample_students[0]["name"] == "Aarav"
def test_db_uses_tmp(db_connection):
assert db_connection["open"]
assert db_connection["file"].endswith("test.db")
| Scope | Lifecycle |
|---|---|
function (default) | One per test function |
class | One per test class |
module | One per test file |
session | One per entire pytest run |
unittest.mockfrom unittest.mock import patch, MagicMock
import requests
def get_user_name(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}", timeout=10)
response.raise_for_status()
return response.json()["name"]
# --- Test ---
def test_get_user_name():
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"name": "Aarav"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
assert get_user_name(1) == "Aarav"
mock_get.assert_called_once_with(
"https://api.example.com/users/1", timeout=10)
Red: write a failing test for the next feature.
Green: write the minimum code to pass.
Refactor: improve the code without breaking tests.
# Step 1 (RED): write tests before code
import pytest
def validate_password(pwd):
raise NotImplementedError
@pytest.mark.parametrize("pwd, ok", [
("short", False),
("alllowercase1!", False),
("ALLUPPERCASE1!", False),
("NoDigits!", False),
("GoodPass123!", True),
])
def test_password_validation(pwd, ok):
assert validate_password(pwd) == ok
# Step 2 (GREEN): implement minimum code
import re
def validate_password(pwd: str) -> bool:
if len(pwd) < 8:
return False
if not re.search(r"[a-z]", pwd):
return False
if not re.search(r"[A-Z]", pwd):
return False
if not re.search(r"\d", pwd):
return False
if not re.search(r"[!@#$%^&*]", pwd):
return False
return True
# Step 3 (REFACTOR): clean up if needed
# FastAPI - TestClient
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello, FastAPI!"}
def test_create_student():
payload = {"name": "Aarav", "email": "a@x.com", "age": 19}
r = client.post("/students", json=payload)
assert r.status_code == 201
data = r.json()
assert data["name"] == "Aarav"
assert "id" in data
# Flask - test client
import pytest
from app import app
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as c:
yield c
def test_home(client):
r = client.get("/")
assert r.status_code == 200
def test_create_task(client):
r = client.post("/tasks", json={"title": "Study"})
assert r.status_code == 201
assert r.get_json()["title"] == "Study"
pytest --cov=mypackage --cov-report=html
# Opens htmlcov/index.html — shows line-by-line coverage.
# Aim for >80% coverage on critical modules, but never chase 100%
# at the cost of meaningful tests.
project/
├── src/mypackage/
│ ├── __init__.py
│ └── core.py
└── tests/
├── conftest.py # shared fixtures
├── test_core.py # unit tests
├── test_api.py # integration tests
└── test_e2e.py # end-to-end tests
| Level | Scope | Speed |
|---|---|---|
| Unit | Single function/class | Fastest |
| Integration | Multiple components together | Medium |
| End-to-end | Full workflow (UI/API + DB) | Slowest |
For testing questions, mention: pytest over unittest, plain assert, fixtures, parametrisation, and mocking external I/O. Demonstrate a full TDD cycle with a small function.
Docker packages an application and its dependencies into a portable image, which can run as a container on any machine with Docker installed. It eliminates "works on my machine" problems.
| Concept | Meaning |
|---|---|
| Image | Read-only template with code + dependencies |
| Container | Running instance of an image |
| Dockerfile | Recipe for building an image |
| Registry | Storage for images (Docker Hub, ECR, GHCR) |
| Volume | Persistent storage mounted into a container |
| Network | Virtual network connecting containers |
# --- Build stage ---
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# --- Runtime stage ---
FROM python:3.12-slim
WORKDIR /app
# Non-root user for security
RUN useradd --create-home appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY src/ ./src/
ENV PATH=/home/appuser/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD \
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker-compose.yml — Multi-Service Appsversion: "3.9"
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 10s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
docker compose up --build
docker compose down -v
| Practice | Why |
|---|---|
| Multi-stage builds | Smaller final images (no build tools) |
| Non-root user | Limits blast radius of a breach |
| Pin base image version | Reproducible builds |
.dockerignore | Avoid copying junk into the image |
| Layer ordering | Copy requirements.txt before code (cache) |
| HEALTHCHECK | Orchestrators know when to restart |
| Small base images | Faster pulls, smaller attack surface (slim, alpine) |
# .dockerignore
.git
.github
__pycache__
*.pyc
.venv
.env
tests/
*.md
.pytest_cache
.mypy_cache
.ruff_cache
# .github/workflows/ci-cd.yml
name: CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -e ".[dev]"
- run: ruff check .
- run: mypy src/
- run: pytest --cov=src --cov-fail-under=80
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
| Platform | Best for | Deploy method |
|---|---|---|
| AWS EC2 / Lightsail | Full control | SSH + Docker |
| AWS ECS / Fargate | Serverless containers | Task definitions |
| AWS Lambda | Functions, event-driven | Zip or container |
| GCP Cloud Run | Containers with autoscaling | Container image |
| Azure App Service | Web apps + APIs | Git or Docker |
| Heroku / Render | Simple deploys | Git push |
| Kubernetes (EKS/GKE/AKS) | Large-scale orchestration | Helm / kubectl |
# Never hardcode. Use secrets from the platform.
# 12-factor app principles:
# - Config in environment
# - Stateless processes
# - Logs as event streams
# - Dev/prod parity
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str = "redis://localhost:6379"
secret_key: str
debug: bool = False
log_level: str = "INFO"
class Config:
env_file = ".env"
settings = Settings()
print(settings.database_url)
| Strategy | How |
|---|---|
| Rolling update | Replace instances gradually |
| Blue-green | Two identical environments; swap traffic |
| Canary | Send a small % of traffic to the new version first |
| Feature flags | Ship code with features hidden behind toggles |
For deployment questions, cover: (1) multi-stage Docker build, (2) non-root user, (3) .dockerignore, (4) environment-based config, (5) CI pipeline running linters + tests + coverage, (6) push to a registry. Mention rollback and health checks.
| Risk | Python-specific mitigation |
|---|---|
| Injection | Parameterised queries, ORMs; never f-strings in SQL |
| Broken Auth | Hash passwords with bcrypt/argon2; use HTTPS; rotate tokens |
| Sensitive Data Exposure | Encrypt at rest; TLS in transit; never log secrets |
| XXE | Disable external entities in XML parsers |
| Broken Access Control | Enforce authz on every endpoint; avoid IDOR |
| Security Misconfig | Turn off debug in production; set secure cookies |
| XSS | Auto-escaping templating (Jinja2); validate output |
| Insecure Deserialisation | Never unpickle untrusted data |
| Vulnerable Dependencies | pip-audit, safety, Dependabot |
| Insufficient Logging | Structured logs + alerting on anomalies |
pip install passlib[bcrypt]
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
h = hash_password("MySecret123!")
print(verify_password("MySecret123!", h)) # True
print(verify_password("wrong", h)) # False
hashlib.md5(password) — MD5/SHA1 are broken for passwords.passlib, bcrypt, argon2.pip install "python-jose[cryptography]" "passlib[bcrypt]"
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
SECRET_KEY = "change-me-in-prod"
ALGORITHM = "HS256"
EXPIRE_MIN = 60
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2 = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
fake_db = {"aarav": {"username": "aarav",
"password": pwd_ctx.hash("secret")}}
def create_token(username: str) -> str:
payload = {
"sub": username,
"exp": datetime.now(timezone.utc) + timedelta(minutes=EXPIRE_MIN),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def current_user(token: str = Depends(oauth2)) -> str:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not username:
raise JWTError()
return username
except JWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
user = fake_db.get(form.username)
if not user or not pwd_ctx.verify(form.password, user["password"]):
raise HTTPException(401, "Bad credentials")
return {"access_token": create_token(form.username), "token_type": "bearer"}
@app.get("/me")
def me(user: str = Depends(current_user)):
return {"user": user}
from pydantic import BaseModel, Field, EmailStr, constr, conint
class Registration(BaseModel):
username: constr(min_length=3, max_length=30, regex=r"^[a-zA-Z0-9_]+$")
email: EmailStr
age: conint(ge=18, le=120)
password: constr(min_length=12)
# FastAPI uses this automatically:
# - returns 422 for invalid payloads
# - rejects SQL-injection attempts at the schema level for typed fields
pip install slowapi
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import FastAPI, Request
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def ratelimit_handler(request: Request, exc: RateLimitExceeded):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
@app.get("/api/data")
@limiter.limit("100/minute")
async def data(request: Request):
return {"ok": True}
# .env (never commit)
DATABASE_URL=postgresql://...
JWT_SECRET=...
# Load with pydantic-settings or python-dotenv
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
jwt_secret: str
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
gitleaks, trufflehog — scan repos for accidental commits.pip install pip-audit safety
# Scan installed packages
pip-audit
# Scan requirements file
pip-audit -r requirements.txt
# Safety (alternative)
safety check
| Area | Action |
|---|---|
| Auth | bcrypt/argon2; JWT with short expiry and refresh tokens |
| Transport | HTTPS only; HSTS header |
| Input | Validate with Pydantic; parameterise SQL; escape output |
| Secrets | Environment variables + secret manager; never in git |
| Dependencies | Pin versions; run pip-audit in CI |
| Headers | CORS, CSP, X-Frame-Options, X-Content-Type-Options |
| Cookies | HttpOnly, Secure, SameSite=Lax |
| Logging | Log auth events; never log secrets or PII |
| Rate limiting | Prevent brute force and scraping |
| Container | Non-root, read-only FS, minimal base image |
| Pillar | Question answered | Tools |
|---|---|---|
| Metrics | How is the system behaving over time? | Prometheus, Grafana, CloudWatch |
| Logs | What exactly happened in this event? | ELK, Loki, Datadog |
| Traces | Where did time go in this request? | Jaeger, Zipkin, OpenTelemetry |
import logging
import json
from datetime import datetime, timezone
class JSONFormatter(logging.Formatter):
def format(self, record):
payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload)
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logger = logging.getLogger("api")
logger.setLevel(logging.INFO)
logger.addHandler(handler)
logger.info("user signed in", extra={"user_id": 42})
Structured (JSON) logs are queryable in Loki/CloudWatch/Datadog, unlike free-form strings.
pip install prometheus-client
from prometheus_client import Counter, Histogram, Gauge, start_http_server
import time
REQUESTS = Counter(
"http_requests_total", "Total HTTP requests",
["method", "endpoint", "status"])
LATENCY = Histogram(
"http_request_duration_seconds", "Request latency",
["endpoint"])
INFLIGHT = Gauge("http_inflight_requests", "In-flight requests")
# Start a /metrics endpoint on port 8000
start_http_server(8000)
def handle_request(endpoint):
INFLIGHT.inc()
start = time.perf_counter()
try:
time.sleep(0.05) # simulate work
REQUESTS.labels("GET", endpoint, "200").inc()
finally:
LATENCY.labels(endpoint).observe(time.perf_counter() - start)
INFLIGHT.dec()
for _ in range(50):
handle_request("/api/data")
| Metric type | Use |
|---|---|
| Counter | Monotonically increasing (requests, errors) |
| Gauge | Can go up and down (in-flight, queue depth) |
| Histogram | Distribution of observations (latency, size) |
| Summary | Like histogram but client-side quantiles |
pip install opentelemetry-api opentelemetry-sdk \
opentelemetry-instrumentation-fastapi \
opentelemetry-exporter-otlp
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
@app.get("/")
async def root():
with trace.get_tracer(__name__).start_as_current_span("custom-work"):
return {"message": "traced"}
from fastapi import FastAPI, Response, status
app = FastAPI()
_state = {"db": True, "cache": True}
@app.get("/health")
async def health():
"""Liveness: is the process alive?"""
return {"status": "ok"}
@app.get("/ready")
async def ready(response: Response):
"""Readiness: can we serve traffic?"""
if not all(_state.values()):
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not ready", "deps": _state}
return {"status": "ready"}
| Endpoint | Purpose |
|---|---|
/health (liveness) | Restart if the process hangs |
/ready (readiness) | Stop routing traffic if deps are down |
/metrics | Prometheus scrape endpoint |
| Concept | Meaning |
|---|---|
| Trace | End-to-end journey of one request across services |
| Span | A single operation within a trace (with start/end, tags) |
| Trace ID | Propagated between services to stitch spans together |
| Context | Span + trace IDs passed via headers (traceparent) |
| Sampling | Keep only a fraction of traces to control cost |
| Rule | Rationale |
|---|---|
| Alert on symptoms, not causes | "Error rate > 1%" beats "CPU > 80%" |
| Every alert must be actionable | Otherwise, alert fatigue sets in |
| Use SLOs and error budgets | Align engineering with business expectations |
| Escalation policy | Who gets paged, when, and how |
| Runbooks | Steps to debug and remediate |
Name the three pillars (metrics, logs, traces), give one tool per pillar, and mention OpenTelemetry as the vendor-neutral instrumentation standard. Include a health-check endpoint in any API you design.
The following project ties together everything in Unit V: FastAPI + Pydantic + SQLAlchemy + pytest + Docker + structured logging + health checks.
task-api/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .dockerignore
├── src/
│ └── taskapi/
│ ├── __init__.py
│ ├── main.py # FastAPI app + routes
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic models
│ ├── database.py # Engine, SessionLocal, Base
│ ├── config.py # Settings (pydantic-settings)
│ ├── logging_config.py # Structured logging
│ └── auth.py # JWT helpers
└── tests/
├── conftest.py
├── test_api.py
└── test_auth.py
# src/taskapi/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///./app.db"
jwt_secret: str = "dev-secret"
jwt_algorithm: str = "HS256"
jwt_expire_min: int = 60
log_level: str = "INFO"
class Config:
env_file = ".env"
settings = Settings()
# src/taskapi/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False}
if settings.database_url.startswith("sqlite") else {},
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# src/taskapi/models.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from .database import Base
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True)
title = Column(String(200), nullable=False)
done = Column(Boolean, default=False)
owner = Column(String(50), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# src/taskapi/schemas.py
from pydantic import BaseModel, Field
from datetime import datetime
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
class TaskUpdate(BaseModel):
title: str | None = None
done: bool | None = None
class TaskOut(BaseModel):
id: int
title: str
done: bool
owner: str
created_at: datetime
class Config:
from_attributes = True
# src/taskapi/main.py
from fastapi import FastAPI, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from .database import Base, engine, get_db
from .models import Task
from .schemas import TaskCreate, TaskUpdate, TaskOut
from .auth import current_user
from .logging_config import configure_logging, logger
Base.metadata.create_all(engine)
configure_logging()
app = FastAPI(title="Task API", version="1.0.0")
@app.middleware("http")
async def log_requests(request: Request, call_next):
logger.info("request.start",
extra={"method": request.method, "path": request.url.path})
response = await call_next(request)
logger.info("request.end",
extra={"status": response.status_code, "path": request.url.path})
return response
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/tasks", response_model=TaskOut, status_code=201)
def create_task(payload: TaskCreate,
db: Session = Depends(get_db),
user: str = Depends(current_user)):
task = Task(**payload.model_dump(), owner=user)
db.add(task); db.commit(); db.refresh(task)
return task
@app.get("/tasks", response_model=list[TaskOut])
def list_tasks(db: Session = Depends(get_db),
user: str = Depends(current_user)):
return db.query(Task).filter(Task.owner == user).all()
@app.get("/tasks/{task_id}", response_model=TaskOut)
def get_task(task_id: int,
db: Session = Depends(get_db),
user: str = Depends(current_user)):
task = db.query(Task).filter(Task.id == task_id,
Task.owner == user).first()
if not task:
raise HTTPException(404, "Task not found")
return task
@app.patch("/tasks/{task_id}", response_model=TaskOut)
def update_task(task_id: int, patch: TaskUpdate,
db: Session = Depends(get_db),
user: str = Depends(current_user)):
task = db.query(Task).filter(Task.id == task_id,
Task.owner == user).first()
if not task:
raise HTTPException(404, "Task not found")
for k, v in patch.model_dump(exclude_unset=True).items():
setattr(task, k, v)
db.commit(); db.refresh(task)
return task
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int,
db: Session = Depends(get_db),
user: str = Depends(current_user)):
task = db.query(Task).filter(Task.id == task_id,
Task.owner == user).first()
if not task:
raise HTTPException(404, "Task not found")
db.delete(task); db.commit()
# src/taskapi/logging_config.py
import logging, json
from datetime import datetime, timezone
from .config import settings
class JSONFormatter(logging.Formatter):
def format(self, record):
out = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"msg": record.getMessage(),
"name": record.name,
}
for key in ("method", "path", "status", "user_id"):
if hasattr(record, key):
out[key] = getattr(record, key)
if record.exc_info:
out["exc"] = self.formatException(record.exc_info)
return json.dumps(out)
def configure_logging():
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(settings.log_level)
logger = logging.getLogger("taskapi")
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from src.taskapi.main import app
from src.taskapi.database import Base, engine, SessionLocal
@pytest.fixture(autouse=True)
def fresh_db():
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def auth_token(client):
# assumes /token endpoint from auth.py
r = client.post("/token", data={"username": "aarav",
"password": "secret"})
return r.json()["access_token"]
# tests/test_api.py
def test_health(client):
assert client.get("/health").json() == {"status": "ok"}
def test_create_task(client, auth_token):
headers = {"Authorization": f"Bearer {auth_token}"}
r = client.post("/tasks", json={"title": "Study FastAPI"}, headers=headers)
assert r.status_code == 201
assert r.json()["title"] == "Study FastAPI"
def test_list_tasks(client, auth_token):
headers = {"Authorization": f"Bearer {auth_token}"}
client.post("/tasks", json={"title": "A"}, headers=headers)
client.post("/tasks", json={"title": "B"}, headers=headers)
r = client.get("/tasks", headers=headers)
assert len(r.json()) == 2
def test_missing_auth(client):
r = client.get("/tasks")
assert r.status_code == 401
# Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --user --no-cache-dir -e .
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY src/ ./src/
ENV PATH=/home/appuser/.local/bin:$PATH PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "src.taskapi.main:app", "--host", "0.0.0.0", "--port", "8000"]
| Item | Status |
|---|---|
| Environment-based configuration (12-factor) | ☐ |
| Secrets via environment / vault | ☐ |
| Structured logs (JSON) | ☐ |
| Health & readiness endpoints | ☐ |
| Metrics endpoint for Prometheus | ☐ |
| CI running lint + types + tests + coverage | ☐ |
| Docker image built and pushed to a registry | ☐ |
| Non-root container user | ☐ |
| Rollback strategy documented | ☐ |
| Rate limiting on public endpoints | ☐ |
Dependency audit (pip-audit) clean | ☐ |
| Backups configured for the database | ☐ |
In a practical exam, lay out your answer as: (1) structure (files/folders), (2) config (env-based), (3) models & schemas, (4) routes, (5) tests, (6) Dockerfile. Even a 50-line skeleton that shows all six layers earns full marks over an unorganised 300-line monolith.
| Concept | Syntax / idiom |
|---|---|
| Dynamic class creation | type("Name", (Base,), {"attr": val}) |
| Custom metaclass | class Meta(type): def __new__(mcs, name, bases, ns): ... |
| Lighter subclass hook | def __init_subclass__(cls): ... |
| Descriptor | __get__, __set__, __delete__ |
| Descriptor owner hook | def __set_name__(self, owner, name): ... |
| Memory-optimised class | __slots__ = ("x", "y") |
| Weak reference | weakref.ref(obj) / WeakValueDictionary |
| Deterministic cleanup | weakref.finalize(obj, fn, args) |
| Goal | Tool |
|---|---|
| Micro-benchmark | timeit.timeit(...) |
| Function profiling | cProfile, python -m cProfile -s cumtime |
| Line profiling | line_profiler, kernprof |
| Memory tracing | tracemalloc, memory_profiler, sys.getsizeof |
| Native speed (numeric) | numba.njit |
| Native speed (mixed) | Cython, Rust (pyo3) |
| Reuse C libraries | ctypes, cffi |
| Cache results | functools.lru_cache |
| Reduce memory per object | __slots__ |
| Task | Flask | FastAPI |
|---|---|---|
| Create app | app = Flask(__name__) | app = FastAPI() |
| Route | @app.route("/x") | @app.get("/x") |
| Path param | /u/<int:id> | /u/{id} + type hint |
| Query | request.args.get() | Function parameter with Query() |
| JSON body | request.get_json() | Pydantic model argument |
| Response | jsonify(...) | return dict |
| Status code | Return tuple or abort() | status_code=, HTTPException |
| Auth | Extension (Flask-JWT-Extended) | OAuth2PasswordBearer |
| Docs | Extension (flask-swagger) | Automatic /docs, /redoc |
| Goal | pytest |
|---|---|
| Basic test | def test_x(): assert f() == expected |
| Expected exception | with pytest.raises(ValueError): |
| Parametrise | @pytest.mark.parametrize("a,b", [...]) |
| Fixture | @pytest.fixture with yield |
| Temporary paths | tmp_path built-in fixture |
| Mock external I/O | unittest.mock.patch |
| API client | TestClient(app) (FastAPI) |
| Coverage | pytest --cov=src --cov-fail-under=80 |
| Task | Command / Pattern |
|---|---|
| Build image | docker build -t myapp:latest . |
| Run container | docker run --rm -p 8000:8000 myapp |
| Compose up | docker compose up --build |
| Multi-stage build | FROM ... AS builder + COPY --from=builder |
| Non-root | RUN useradd ..., USER appuser |
| Cache layer | Copy requirements.txt before source |
| Healthcheck | HEALTHCHECK CMD curl -f http://localhost/health |
| Push to registry | docker push ghcr.io/owner/repo:tag |
| Risk | Mitigation |
|---|---|
| Password storage | passlib[bcrypt] or argon2 |
| Auth tokens | JWT with short TTL + refresh tokens |
| SQL injection | Parameterised queries, ORM |
| Input validation | Pydantic schemas |
| Secrets | Environment variables, secret manager |
| Dependencies | pip-audit, Dependabot, pinned versions |
| Rate limiting | slowapi |
| HTTP headers | CORS, CSP, HSTS |
| Containers | Non-root, read-only rootfs, minimal base |
| Pillar | Python tool | Backend |
|---|---|---|
| Logs | logging + JSONFormatter | Loki, CloudWatch, Datadog |
| Metrics | prometheus_client | Prometheus + Grafana |
| Traces | OpenTelemetry SDK | Jaeger, Tempo, Honeycomb |
| Errors | sentry-sdk | Sentry |
| Profiling (prod) | py-spy | Local / Pyroscope |
type. Say this first, then show a small registry example.__init_subclass__ over metaclasses when possible — it composes better and is easier to reason about.__dict__; non-data descriptors lose to it. State this clearly in any descriptor question.__slots__ saves memory by removing __dict__. Mention that a subclass without slots reintroduces the dict.weakref to break cycles in caches and back-references.cProfile for functions, timeit for micro-benchmarks, tracemalloc for memory..dockerignore, healthcheck. These four signals tell an examiner you understand production.pip-audit in CI.object, type, and a user-defined class. Write a metaclass that logs every class creation.MediumPositiveInt descriptor that only accepts positive integers. Use it in a Product class with a price and quantity attribute.Medium__slots__ reduces memory for a class with 1 million instances. Also explain two limitations of __slots__.Mediumgc.collect() breaks it.HardcProfile. Then optimise it and show a before/after comparison.Hard/, /user/<name>, and a JSON /api/status endpoint. Explain each decorator.Easycalculate_discount(price, percent) that raises ValueError for negative inputs. Use parametrisation and a fixture.Mediumdocker-compose.yml adding PostgreSQL.Hardmain.HardEvery class is an instance of a metaclass (type by default). object is the root of the class hierarchy; type is a subclass of object and its own metaclass.
class LoggingMeta(type):
def __new__(mcs, name, bases, namespace):
print(f"[meta] creating class {name}")
return super().__new__(mcs, name, bases, namespace)
class A(metaclass=LoggingMeta): pass
class B(A, metaclass=LoggingMeta): pass
print(type(A)) # <class '__main__.LoggingMeta'>
print(type(LoggingMeta))# <class 'type'>
print(type(type)) # <class 'type'>
class PositiveInt:
def __set_name__(self, owner, name):
self.name = name
self.private = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private, None)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
if value <= 0:
raise ValueError(f"{self.name} must be positive")
setattr(obj, self.private, value)
class Product:
price = PositiveInt()
quantity = PositiveInt()
def __init__(self, price, quantity):
self.price = price
self.quantity = quantity
def total(self):
return self.price * self.quantity
p = Product(100, 5)
print(p.total()) # 500
try:
p.price = -1
except ValueError as e:
print("Err:", e)
import sys
class WithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
N = 1_000_000
dict_objs = [WithDict(i, i) for i in range(N)]
slots_objs = [WithSlots(i, i) for i in range(N)]
# Approximate memory per instance
print("dict-based (per instance):",
sys.getsizeof(dict_objs[0]) + sys.getsizeof(dict_objs[0].__dict__))
print("slots-based (per instance):",
sys.getsizeof(slots_objs[0]))
# Expected: ~152 B vs ~56 B per instance → ~100 MB saved at 1M objects
# Limitations:
# 1. No dynamic attributes outside the slots list.
# 2. Subclasses without __slots__ reintroduce __dict__ and lose savings.
import gc, sys
class Node:
def __init__(self, name):
self.name = name
self.peer = None
a = Node("A")
b = Node("B")
a.peer = b
b.peer = a # cycle
print("Before del:", sys.getrefcount(a))
del a, b
# At this point both objects are unreachable but still alive because of the cycle.
collected = gc.collect()
print("Collected:", collected) # 2 (or more)
Reference counting frees objects immediately when the count hits zero, but cannot free cycles. The generational GC periodically walks all objects tracked by the GC and breaks cycles by finding objects unreachable from any root.
import cProfile
def primes_naive(n):
result = []
for num in range(2, n):
ok = True
for d in range(2, num):
if num % d == 0:
ok = False
break
if ok:
result.append(num)
return result
def primes_optimised(n):
result = []
for num in range(2, n):
if all(num % d for d in range(2, int(num ** 0.5) + 1)):
result.append(num)
return result
cProfile.run("primes_naive(20000)", sort="cumtime")
cProfile.run("primes_optimised(20000)", sort="cumtime")
# The optimised version only checks up to sqrt(n) and uses short-circuit all(),
# giving ~10× speedup at n=20000.
import numpy as np
import time
from numba import njit
N = 10_000_000
def pure(n):
s = 0
for i in range(n):
s += i * i
return s
def numpy_fn(n):
arr = np.arange(n, dtype=np.int64)
return int((arr * arr).sum())
@njit
def numba_fn(n):
s = 0
for i in range(n):
s += i * i
return s
numba_fn(1) # warm-up
for label, fn in [("Pure", pure), ("NumPy", numpy_fn), ("Numba", numba_fn)]:
t = time.perf_counter()
fn(N)
print(f"{label:<6}: {time.perf_counter() - t:.3f}s")
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/")
def home():
return "Hello, Flask!"
@app.route("/user/<name>")
def user(name):
return f"Hello, {name}!"
@app.route("/api/status")
def status():
return jsonify({"status": "ok", "version": "1.0"})
@app.route binds a URL rule to a view function. Flask extracts path parameters by name and passes them as arguments.
from fastapi import FastAPI, HTTPException, Depends, status
from pydantic import BaseModel
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, sessionmaker, Session
DATABASE_URL = "sqlite:///./notes.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
class Note(Base):
__tablename__ = "notes"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
body = Column(String, default="")
Base.metadata.create_all(engine)
class NoteIn(BaseModel):
title: str
body: str = ""
class NoteOut(NoteIn):
id: int
class Config:
from_attributes = True
app = FastAPI()
def get_db():
db = SessionLocal()
try: yield db
finally: db.close()
@app.post("/notes", response_model=NoteOut, status_code=201)
def create(payload: NoteIn, db: Session = Depends(get_db)):
note = Note(**payload.model_dump())
db.add(note); db.commit(); db.refresh(note)
return note
@app.get("/notes/{note_id}", response_model=NoteOut)
def read(note_id: int, db: Session = Depends(get_db)):
note = db.get(Note, note_id)
if not note:
raise HTTPException(404, "Not found")
return note
@app.put("/notes/{note_id}", response_model=NoteOut)
def update(note_id: int, payload: NoteIn, db: Session = Depends(get_db)):
note = db.get(Note, note_id)
if not note:
raise HTTPException(404, "Not found")
note.title, note.body = payload.title, payload.body
db.commit(); db.refresh(note)
return note
@app.delete("/notes/{note_id}", status_code=204)
def delete(note_id: int, db: Session = Depends(get_db)):
note = db.get(Note, note_id)
if not note:
raise HTTPException(404, "Not found")
db.delete(note); db.commit()
import pytest
def calculate_discount(price, percent):
if price < 0 or percent < 0:
raise ValueError("Inputs must be non-negative")
if percent > 100:
raise ValueError("Percent must be <= 100")
return price * (1 - percent / 100)
@pytest.fixture
def base_price():
return 1000
@pytest.mark.parametrize("percent, expected", [
(0, 1000),
(10, 900),
(25, 750),
(100, 0),
])
def test_discount(base_price, percent, expected):
assert calculate_discount(base_price, percent) == expected
def test_negative_price():
with pytest.raises(ValueError, match="non-negative"):
calculate_discount(-1, 10)
def test_percent_over_100():
with pytest.raises(ValueError, match="<= 100"):
calculate_discount(100, 150)
# Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY src/ ./src/
ENV PATH=/home/appuser/.local/bin:$PATH PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s CMD \
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
version: "3.9"
services:
api:
build: .
ports: ["8000:8000"]
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 10s
volumes:
pgdata:
| Risk | Python mitigation |
|---|---|
| Injection | SQLAlchemy / parameterised queries; never f-strings in SQL |
| Broken Auth | passlib[bcrypt], JWT with short TTL |
| Sensitive Data Exposure | TLS everywhere; secrets via env vars; no PII in logs |
| XXE | defusedxml instead of xml.etree for untrusted XML |
| Broken Access Control | Check ownership on every row before returning it |
| Security Misconfig | Debug off in prod; secure cookie flags |
| XSS | Jinja2 autoescape (on by default); validate output |
| Insecure Deserialisation | Never pickle.load untrusted data; use JSON |
| Vulnerable Deps | pip-audit in CI, Dependabot, pinned versions |
| Insufficient Logging | Structured logs + alerting on auth failures |
import json, logging, time
from prometheus_client import Counter, Histogram, start_http_server
class JSONFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"ts": self.formatTime(record),
"level": record.levelname,
"msg": record.getMessage(),
})
logger = logging.getLogger("api")
logger.setLevel(logging.INFO)
h = logging.StreamHandler()
h.setFormatter(JSONFormatter())
logger.addHandler(h)
REQUESTS = Counter("requests_total", "Total requests", ["path"])
LATENCY = Histogram("request_latency_seconds", "Latency", ["path"])
start_http_server(8000)
def handle(path):
REQUESTS.labels(path).inc()
with LATENCY.labels(path).time():
time.sleep(0.05) # simulated work
logger.info(f"handled {path}")
for _ in range(20):
handle("/api/data")
from fastapi import FastAPI, Response, status
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
app = FastAPI()
_state = {"db": True}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/ready")
def ready(response: Response):
if not all(_state.values()):
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not ready"}
return {"status": "ready"}
@app.get("/metrics")
def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
Pillars: logs (structured JSON events), metrics (Prometheus counters/histograms), traces (OpenTelemetry spans). Each answers a distinct operational question.
# .github/workflows/ci-cd.yml
name: CI/CD
on:
push: { branches: [main] }
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -e ".[dev]"
- run: ruff check .
- run: mypy src/
- run: pytest --cov=src --cov-fail-under=80
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
| Code | Title | Author | Publisher |
|---|---|---|---|
| T-1 | Fundamentals of Python — First Programs | Kenneth A. Lambert | Cengage Learning |
| R-1 | Python Programming: Using Problem Solving Approach | Reema Thareja | Oxford University Press |
| R-2 | Fluent Python (2nd ed.) | Luciano Ramalho | O'Reilly |
| R-3 | Python Cookbook (3rd ed.) | David Beazley & Brian K. Jones | O'Reilly |
| R-4 | Architecture Patterns with Python | Percival & Gregory | O'Reilly |
| R-5 | High Performance Python (2nd ed.) | Gorelick & Ozsvald | O'Reilly |
| R-6 | FastAPI: Modern Python Web Development | Bill Lubanovic | O'Reilly |
| R-7 | Designing Data-Intensive Applications | Martin Kleppmann | O'Reilly |
| R-8 | The Pragmatic Programmer (20th Anniv.) | Hunt & Thomas | Addison-Wesley |
| Code | Resource | Purpose |
|---|---|---|
| RW-1 | docs.python.org/3/reference/datamodel.html | Data model reference (metaclasses, descriptors) |
| RW-2 | docs.python.org/3/library/gc.html | Garbage collection docs |
| RW-3 | docs.python.org/3/library/weakref.html | Weak references |
| RW-4 | docs.python.org/3/library/timeit.html | Timing utilities |
| RW-5 | flask.palletsprojects.com | Flask documentation |
| RW-6 | fastapi.tiangolo.com | FastAPI documentation |
| RW-7 | docs.pytest.org | pytest documentation |
| RW-8 | docs.docker.com | Docker documentation |
| RW-9 | owasp.org/Top10/ | OWASP Top 10 |
| RW-10 | opentelemetry.io/docs/ | OpenTelemetry docs |
__init_subclass__ covers most metaclass use cases with less complexity — prefer it when possible.property is the built-in data descriptor you use most often.__dict__; non-data descriptors lose to it — this ordering enables cached-property patterns.__slots__ eliminates per-instance dicts, reducing memory and speeding attribute access, at the cost of dynamic attributes.weakref to avoid cycles in caches, listeners and back-references.timeit for micro-benchmarks, cProfile for functions, tracemalloc/memory_profiler for memory.ctypes (call C), Rust (rewrite).assert, fixtures, parametrisation, mocking external I/O, coverage gates in CI..dockerignore, healthchecks.main.pip-audit in CI.| Course Outcome | Covered in Sections | Key Deliverables |
|---|---|---|
| CO3 — Advanced functions | I, II | Metaclasses, descriptors, dynamic attributes |
| CO4 — Data structures at scale | III, IV, V | Memory management, profiling, extensions |
| CO5 — OOP & design | I, VI, VII, XII | Metaclasses, web frameworks, production API |
| CO6 — Files, APIs, deployment | VI–XI | Flask, FastAPI, Docker, CI/CD, security, observability |
| Week | Focus | Sections |
|---|---|---|
| 1 | Metaclasses & descriptors | I, II |
| 2 | Memory & profiling | III, IV |
| 3 | C-extensions & speedups | V |
| 4 | Flask web apps | VI |
| 5 | FastAPI & modern APIs | VII |
| 6 | Testing & mocking | VIII |
| 7 | Docker, CI/CD, deployment | IX |
| 8 | Security & observability | X, XI |
| 9 | Capstone project | XII |
Course weightage: ATT 5 + CA 50 + ETP 45. Programming Practice requires solving at least 50% of the assigned coding problems and 50% of the MCQs to be eligible for marks. For Unit V, prioritise (1) metaclass/descriptor understanding, (2) a working FastAPI CRUD service with tests, and (3) a Dockerised deployment.
| Topic | Recommended source |
|---|---|
| Metaclasses & descriptors | Fluent Python, Ch. 21–22 |
| Concurrency (recap) | Fluent Python, Ch. 19–20 |
| Performance | High Performance Python, Ch. 1–3 |
| Web APIs | FastAPI docs + "Architecture Patterns with Python" |
| Deployment | Docker docs + 12-Factor App (12factor.net) |
| Security | OWASP Top 10 + "Web Application Hacker's Handbook" |
| Observability | OpenTelemetry docs + "Observability Engineering" (O'Reilly) |
Expert Python — Metaprogramming, Web & Production Systems
INT108 · L:T:P 3:0:2 · 4 Credits
“Make it work, make it right, make it fast.” — Kent Beck
Congratulations on completing all five units of INT108 · Python Programming.