The Everyday Python Toolkit
The small, sharp tools experienced Python programmers reach for constantly — introspection (getattr / isinstance), * and ** unpacking, lambdas, the walrus, comprehensions, decorators, lru_cache & partial, dataclasses, ABCs vs Protocols, asyncio + httpx, and tenacity retries. Each with the gotcha that bites and the interview answer.
After this you should be able to
- Choose the right everyday Python tool
- Explain the trade-off behind a concise implementation
A ground-up lesson on the Python features and libraries you keep bumping into: introspection, unpacking, protocols and dataclasses, decorators, caching, concurrent HTTP, and resilient calls. Built for real code and interview answers alike.
Hand-copyable, telegraphic. Margin markers: ⭐ trick · ⚠ pitfall · 💡 tip. Everything here is derived from the Detailed Notes below.
Introspection#
getattr(self,"do_"+cmd)() instead of if/elif chain
⚠hasattr swallows all errors
as False (Py3) → hides bugs in properties
Type checks#
type(x)==T ≠ isinstance → rejects
subclasses; prefer isinstance
💡bool is subclass of int →
isinstance(True,int)==True
doodle: ladder of classes; isinstance = "on this rung or below"
lambda#
key= in sorted/max/min
⚠late binding: [lambda: i for i in ...]
all see final i → use i=i
Unpacking * / **#
[*a,*b] merge · {**d1,**d2} merge (right
wins)
a,*rest = seq → capture middle/tail
doodle: box tipped over, items pouring into a bigger box
Ellipsis ...#
Ellipsis object; a real
value, not syntax
stub body: def f(): ... · type stubs ·
Callable[..., int]
⚠... ≠ pass in behavior
only by convention; both no-op in a body
Function signatures#
* → everything after is keyword-only
/ → everything before is positional-only
⚠mutable default def f(x=[]) → shared
across calls; use None
Walrus :=#
while (line:=f.readline()): ·
[y for x in xs if (y:=g(x))]
💡avoids calling g(x) twice in a
comprehension
Comprehensions#
[f(x) for x in xs if cond] list · { } set/dict ·
( ) generator
gen exp = lazy, one-pass, memory-cheap
⚠nested order = same as nested for-loops,
left→right
Decorators#
f = deco(f); wraps a fn to add
behavior
pattern: def deco(f): @wraps(f) def w(*a,**k): ...; return w
⭐@wraps(f) keeps name/docstring → don't
skip it
deco with args = deco factory (3 nested fns)
doodle: gift box (fn) → wrapping paper (wrapper) → same box inside
Caching & partial#
@cache = unbounded (3.9+)
⚠args must be hashable (no list/dict); cache holds
refs → leaks
partial(f, a) → freeze some args → new
callable
dataclasses#
field(default_factory=list) for mutable defaults
frozen=True → immutable + hashable; slots=True →
less memory
⚠plain x: list = [] class attr = shared
→ use default_factory
ABC vs Protocol#
asyncio + httpx#
asyncio.run(main()) starts loop; gather(*tasks) =
concurrent
⭐concurrency ≠ parallelism: 1 thread, overlaps
waiting (I/O)
⚠one blocking call (time.sleep, requests) freezes
the whole loop
httpx: async with AsyncClient() as c: await c.get(url) · reuse
client
doodle: 1 waiter taking many orders while kitchen cooks
tenacity retries#
stop_after_attempt(n) · wait_exponential() ·
retry_if_exception_type()
⭐exponential + jitter → avoids thundering-herd retry
storms
⚠only retry transient errors
(timeouts/5xx), not 4xx/ValueError
Ground-up teaching notes. Each concept: plain definition → intuition/analogy → real use → figure where it helps → why it matters.
01The Hook#
You are reading someone else's Python and you hit this: a function that calls
getattr(self, "do_" + command)(), decorated with @retry, that
awaits an httpx client inside an async def, and takes
*args, **kwargs. None of it is advanced computer science. It is a dozen small, sharp tools
that experienced Python programmers reach for constantly, and that tutorials rarely explain
together.
The gap is not knowing what each does in isolation, it is knowing why you'd choose it and where it bites you. Miss the mutable-default trap or the async blocking trap and your code works in the demo and fails in production. This guide stitches these tools into one mental model: Python lets you treat code, arguments, and types as data you can inspect, reshape, wrap, and defer. Everything below is a variation on that theme.
02Introspection: getattr, setattr, hasattr#
Ask an object about its attributes using a name written as a string, at runtime, instead of hard-coding the dot access.
Normally you write obj.color. But what if the attribute name only becomes known while the
program runs, for example it came from a config file, a web request, or a user command? You cannot write
obj. followed by a variable. That is what these three built-ins are for, the
"spread-the-load box" of attribute access, they turn the name into an ordinary value you can
compute.
obj.room101 is walking straight to a
room you already know. getattr(hotel, room_number) is handing the clerk a slip of paper
with the room number on it, the number can be anything, decided a second ago.# getattr(object, name[, default])
color = getattr(car, "color") # same as car.color
color = getattr(car, "color", "black") # default if missing, no crash
# the name is computed, that is the whole point
field = "do_" + command
getattr(self, field)() # call self.do_start(), self.do_stop()...
setattr(car, "color", "red") # car.color = "red", name as string
if hasattr(car, "turbo"): ... # True/False, does the attr exist?if cmd == "start": ... elif cmd == "stop": ... chain collapses into one line: name
your methods do_<command> and call
getattr(self, "do_" + cmd, self.unknown)(). This is exactly how Python's own
cmd and http.server modules route commands. It scales without editing the
dispatcher.
hasattr returns False for any exception raised while
accessing the attribute, not just AttributeError. If a @property throws a
KeyError because of a genuine bug, hasattr quietly reports "no such attribute"
and you chase the wrong problem. Prefer getattr(obj, name, sentinel) and compare, or catch
the specific error.
getattr, not a giant conditional. Bonus
points for mentioning that getattr with a default is safer and faster to read than
try/except AttributeError.
03isinstance & honest type checks#
Ask "is this value one of these types (or a subtype)?" and get back True or False.
The "is-this-the-right-kind-of-thing check" (a runtime type test) answers whether a value belongs to a class or any of its subclasses. Pass a tuple of types to mean "any of these".
isinstance asks "are you a member,
or a member of any affiliated club?" — subclasses count. type(x) == T is the
bouncer who only admits people holding this exact card and turns away affiliated members.
if isinstance(x, int): ...
if isinstance(x, (int, float)): ... # any-of: int OR float
# why not type()?
class Admin(User): ...
a = Admin()
isinstance(a, User) # True — an Admin IS a User
type(a) == User # False — exact match only, ignores inheritanceisinstance(True, int) is True because bool subclasses
int. So a function guarded by isinstance(x, int) will happily accept
True as the number 1. If you must reject booleans, check type(x) is int or add
and not isinstance(x, bool). Classic interview gotcha.
isinstance branches to decide
behavior often means you should be using polymorphism, a Protocol (§13), or
functools.singledispatch. Reserve isinstance for boundary validation and
genuine either-or type handling.
04lambda#
A throwaway, unnamed function written in one line, holding a single expression.
A "one-line unnamed function" (a lambda) is just a function you don't bother to name because you'll use
it once, right where you define it. Its body is a single expression whose value is returned
automatically, no return, no statements.
def is a filed,
titled document you reference by name. A lambda is a sticky note you scribble, use immediately, and
throw away.sorted(people, key=lambda p: p.age) # sort by age
max(words, key=lambda w: len(w)) # longest word
add = lambda a, b: a + b # works, but a def is clearerfuncs = [lambda: i for i in range(3)] gives three functions that all return 2.
Lambdas capture the variable i, not its value at creation time, and by the time you call
them the loop has finished with i == 2. Fix by binding a default:
lambda i=i: i. This trips up nearly everyone once.
def then). The idiomatic home for a
lambda is an inline key=, a small callback, or a default factory. If your lambda needs a
conditional or spans logic, it wants to be a real function.
05The * and ** spread operators#
Pour the contents of a collection into another place: * spreads a
sequence's items, ** spreads a dict's key/value pairs.
You have a box of things and want its contents, not the box, poured into a function call, a new
list, or a new dict. That is unpacking, the "tip-the-box-out" operator. * works on any
iterable (list, tuple, set, generator); ** works on mappings (dicts) and spreads them as
keyword arguments.
[*bag1, *bag2] is
dumping two bags into one pile. f(*items) is handing each item over individually rather
than passing the whole bag.pair = (3, 4)
point(*pair) # point(3, 4) — items become positional args
merged = [*a, *b, *c] # flatten several iterables into one list
combined = {**base, **overrides} # merge dicts; right side wins on clashes
first, *rest = [1,2,3,4] # first=1, rest=[2,3,4] — capture the tail
*init, last = [1,2,3,4] # init=[1,2,3], last=4
defaults = {"timeout": 5}
requests.get(url, **defaults) # same as requests.get(url, timeout=5){**a, **b} is the clean way to merge dicts into a new one (later keys win). Since Python
3.9 you can also write a | b. Both leave the originals untouched, unlike
a.update(b) which mutates a in place.
06The ... (Ellipsis)#
Three dots are a real, singleton object called Ellipsis, not special
syntax, most often used as a "nothing here yet / anything goes" placeholder.
Newcomers assume ... is magic. It is simply a built-in constant, like None or
True, that evaluates to the one Ellipsis object. Because it reads as "to be
filled in," Python culture uses it as a tidy stand-in in a few specific places.
def fetch(url: str) -> bytes: ... # stub body — "implement later", like pass
class Reader(Protocol):
def read(self, n: int) -> bytes: ... # interface declaration, no logic
Callable[..., int] # typing: "any arguments" -> int
arr[..., 0] # NumPy: "all remaining axes" then index 0... and pass both do nothing, but ... is by
convention "intentional stub / defined elsewhere" (common in .pyi type stubs), while
pass means "deliberately empty". Don't read ... as "the rest is omitted from
view", it is not a code-folding marker, it evaluates to the actual Ellipsis object.
Protocol definitions you'll see ... everywhere; recognizing
it as "no implementation here, just the shape" prevents confusion. And knowing it's a genuine object
explains why x = ... is valid and why libraries (NumPy, typing) can give it special
meaning.
07Flexible signatures: *args, **kwargs,
and the bare * / /#
Let a function accept an unknown number of extra positional arguments
(*args) or extra keyword arguments (**kwargs), and control how callers are
allowed to pass them.
Inside a definition, the stars do the reverse of §5: instead of spreading a collection out, they
gather leftover arguments up. *args collects extra positional arguments into a
tuple; **kwargs collects extra keyword arguments into a dict. This is what makes wrappers
and decorators able to accept "whatever the real function takes".
*args and **kwargs are the "everything else" tray that scoops up whatever
didn't have a folder.
def log_call(*args, **kwargs):
print("positional:", args) # a tuple
print("keyword:", kwargs) # a dict
log_call(1, 2, mode="fast")
# positional: (1, 2) keyword: {'mode': 'fast'}
# control the calling convention:
def connect(host, /, port, *, timeout):
...
# host : positional-only (before /)
# port : either way (between / and *)
# timeout: keyword-only (after *) -> connect("db", 5432, timeout=3)/ and bare * markers partition a signature into three calling
zones.def add(item, bag=[]): looks innocent but the list is created once, when the
function is defined, and shared by every call that relies on the default. Call it twice and the second
call sees the first call's items. Always use bag=None then bag = bag or [] (or
if bag is None:) inside. This is the single most-asked Python gotcha in interviews.
def wrapper(*args, **kwargs): return func(*args, **kwargs). The stars gather on the way in
and spread on the way out, so the wrapper is signature-agnostic. This is the backbone of every decorator
(§10).
08The walrus operator := +#
Assign a value to a name and use that value in the same expression, in one step.
Normal assignment (x = 5) is a statement, it can't sit inside an if or a
while condition. The walrus operator (named for its := "eyes and tusks" look,
introduced in Python 3.8) is an expression that both assigns and yields the value, so you can
capture a result exactly where you test it.
# before: compute twice, or an extra line
n = len(data)
if n > 10: print(n)
# after: capture inside the test
if (n := len(data)) > 10:
print(n)
while (chunk := f.read(1024)): # read until empty, no priming read
process(chunk)
# star use: avoid calling g(x) twice in a comprehension
results = [y for x in xs if (y := g(x)) is not None]if and once in the output. [y for x in xs if (y := g(x))] computes
g(x) once and reuses it. That is the case where it clearly beats the alternatives,
elsewhere use it sparingly to keep expressions readable.
09Comprehensions & generator expressions +#
Build a list, set, dict, or lazy stream in one expression by describing what you want, not looping and appending.
A comprehension is a compact "for-each that produces a collection". The brackets decide the type:
[] list, {} set or dict, () a generator (a lazy,
one-item-at-a-time stream that never builds the whole thing in memory).
squares = [x*x for x in nums] # list
uniq = {w.lower() for w in words} # set
by_id = {u.id: u for u in users} # dict
stream = (x*x for x in nums) # generator — lazy, no list built
total = sum(x*x for x in big_file) # memory-cheap over huge input
grid = [(r, c) for r in rows for c in cols] # nested = same order as nested loopssum, any, max,
or a for loop, use ( ) not [ ]. The generator never allocates the
full list, so it handles multi-gigabyte inputs in constant memory. Switching one pair of brackets can
turn a memory crash into a clean stream.
[... for a in A for b in B] the loops read left-to-right, same as if you nested them,
people often reverse it mentally. Also, a generator is exhausted after one pass: iterate it twice and
the second time yields nothing.
10Decorators +#
A function that takes another function and returns a new one with extra behavior wrapped
around it, applied with the @ line above a definition.
A decorator is "wrapping paper for a function". @timing above def work(): is
pure shorthand for work = timing(work). The decorator receives the original function,
builds a replacement that does something before/after (log, time, cache, retry, check permissions), and
hands that replacement back under the same name.
from functools import wraps
def timing(func):
@wraps(func) # keep func's name/docstring — don't skip!
def wrapper(*args, **kwargs): # accept ANY signature (§7)
start = time.perf_counter()
result = func(*args, **kwargs) # call the real function
print(f"{func.__name__} took {time.perf_counter()-start:.3f}s")
return result
return wrapper
@timing
def work(): ...
# work = timing(work)work is rebound to a wrapper that sandwiches the original call.
Decorators that take arguments#
To write @retry(times=3) you need one more layer: an outer function that takes the
arguments and returns a real decorator. Three nested functions, argument layer → decorator layer →
wrapper layer.
def retry(times): # 1) takes the arg
def decorator(func): # 2) the real decorator
@wraps(func)
def wrapper(*a, **k): # 3) the replacement
for _ in range(times):
try: return func(*a, **k)
except Exception: pass
return wrapper
return decorator
@retry(times=3)
def flaky(): ...@wraps(func), the decorated function reports the wrapper's name
(wrapper) and loses its docstring and signature. This breaks help text, debuggers, and
anything that introspects the function. It's the most common decorator mistake, and an easy point to
lose in an interview. @wraps copies the metadata across.
@lru_cache,
@dataclass, @retry, framework routes like @app.get, they're all
this one pattern. Understanding the "function returns a function" chain is the key to reading modern
Python.
11Caching (lru_cache / cache) &
partial
#
@lru_cache remembers a function's results keyed by its arguments so repeat
calls are instant; partial pre-fills some arguments to make a new, simpler function.
These both live in functools. Caching (memoization) is "don't recompute what you already
computed", the decorator stores a mapping from arguments to result. partial is "freeze a
couple of arguments now, supply the rest later".
partial is a coffee machine with your milk
and sugar pre-set, you only press "go".from functools import lru_cache, cache, partial
@lru_cache(maxsize=128) # keep 128 most-recent results
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
@cache # Py3.9+, unbounded lru_cache(maxsize=None)
def settings(): ...
fib.cache_info() # hits / misses / current size — inspect it!
fib.cache_clear() # wipe the cache
# partial: freeze arguments
basetwo = partial(int, base=2) # a new fn: parse binary strings
basetwo("1010") # 10@lru_cache keys on the arguments, so they must be hashable: passing a
list or dict raises TypeError. And @cache /
maxsize=None never evicts, it holds every result (and references to those objects) forever,
which quietly leaks memory in long-running services. Bound the size, or clear it.
@lru_cache line makes each n compute once, dropping it to linear time, a
favorite interview demonstration of memoization. Just remember the cache persists across calls, which is
usually what you want but occasionally surprising in tests.
12dataclasses +#
A decorator that writes the boilerplate (__init__, __repr__,
__eq__) for a "just holds data" class straight from its type annotations.
Most small classes exist only to bundle a few fields. Writing the constructor, the repr, and equality by
hand is tedious and error-prone. @dataclass reads your annotated fields and generates all
of it, you just declare the shape.
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
y: int = 0 # default value
p = Point(3, 4)
print(p) # Point(x=3, y=4) <- free __repr__
Point(3,4) == Point(3,4) # True <- free __eq__
@dataclass(frozen=True, slots=True) # immutable + hashable + memory-lean
class Config:
name: str
tags: list = field(default_factory=list) # mutable default, done righttags: list = [] in a dataclass, it would share one list across all
instances (the §7 trap again). The dataclass machinery actually raises an error for this and directs you
to field(default_factory=list), which creates a fresh list per instance. Same rule for
dicts and sets.
frozen=True makes instances immutable and automatically hashable (usable as dict keys / set
members); slots=True skips the per-instance __dict__ for lower memory and
faster attribute access. Together they give you an efficient, safe value object, a strong thing to
mention when asked "how would you model an immutable record?"
13ABCs & Protocols +#
Two ways to define "what a type must be able to do." An ABC enforces it by inheritance; a Protocol enforces it by shape (which methods exist).
Sometimes you want to say "anything I accept here must have a .read() method." There are two
philosophies. An Abstract Base Class (ABC) is nominal, a class only counts if
it explicitly inherits from the ABC and implements its abstract methods. A Protocol is
structural, a class counts if it merely has the right methods, no inheritance or
declaration required. This is duck typing made checkable.
from abc import ABC, abstractmethod
class Shape(ABC): # nominal: subclasses MUST inherit
@abstractmethod
def area(self) -> float: ...
class Circle(Shape): # must implement area() or can't instantiate
def area(self): return 3.14 * self.r**2
from typing import Protocol, runtime_checkable
@runtime_checkable
class Readable(Protocol): # structural: no inheritance needed
def read(self, n: int) -> bytes: ...
def load(src: Readable): ... # accepts ANY object that has .read()| Aspect | ABC | Protocol |
|---|---|---|
| Conformance | By inheritance (declared) | By structure (has methods) |
| Coupling | Class knows the base | Class knows nothing |
| Best for | Your own hierarchy, shared base logic | Accepting third-party / duck-typed objects |
| Enforced when | Instantiation (can't create if abstract) | Type-check time; runtime only if @runtime_checkable |
@runtime_checkable Protocol lets isinstance(x, Readable) work, but it only
verifies the method exists, not that its arguments or return type match. So it can pass for an
object whose read takes different arguments. Use Protocols mainly for static type-checking
(mypy/pyright); treat runtime checks as a rough guard.
14asyncio & httpx#
A way to run many I/O-bound tasks that spend most of their time waiting (network, disk) on a single thread, by letting each one hand control back to a scheduler whenever it would otherwise block.
Most of the time a program spends waiting on a web request, it is doing nothing, just waiting for bytes.
asyncio exploits that: while one request waits, the event loop runs another.
async def marks a function as a coroutine (a pausable function);
await is the point where it says "I'm about to wait, let someone else run".
httpx is a modern HTTP client that speaks this async language (and a sync one too).
import asyncio, httpx
async def fetch(client, url):
r = await client.get(url) # await = "pause me while this I/O runs"
return r.status_code
async def main(urls):
async with httpx.AsyncClient() as client: # reuse ONE client (pools connections)
tasks = [fetch(client, u) for u in urls]
return await asyncio.gather(*tasks) # run them concurrently, collect results
asyncio.run(main(urls)) # entry point: starts & stops the event loopawait fetch(a); await fetch(b) is still sequential, you awaited A fully before
starting B. The concurrency comes from launching them together:
await asyncio.gather(fetch(a), fetch(b)). Ten 200 ms requests take ~200 ms total instead of
2 s. This "make tasks first, gather second" shape is the single most important async pattern to
internalize.
time.sleep(), the
requests library, a heavy CPU loop, stops every other task, not just the current
one. Use await asyncio.sleep(), an async client like httpx.AsyncClient, and
push CPU-bound work to asyncio.to_thread() or a process pool. Mixing sync blocking into
async is the number-one async bug.
httpx is preferred over requests for async: requests is
synchronous and would block the loop, while httpx offers a true async client with
connection pooling.
15tenacity: retries done right#
A library that automatically re-runs a function when it fails, with configurable limits, waits, and conditions, so you don't hand-roll retry loops.
Networks blip. A request that fails once often succeeds a moment later. Instead of writing
for _ in range(3): try/except everywhere, tenacity gives you a
@retry decorator (§10 in action) whose behavior you assemble from small policy pieces: how
many attempts, how long to wait between them, and which errors are worth retrying.
from tenacity import (retry, stop_after_attempt,
wait_exponential, retry_if_exception_type)
import httpx
@retry(
stop=stop_after_attempt(5), # give up after 5 tries
wait=wait_exponential(multiplier=1, max=10), # 1s,2s,4s,8s,10s...
retry=retry_if_exception_type(httpx.TransportError), # only transient errors
reraise=True, # raise the real error, not RetryError
)
async def get(client, url):
r = await client.get(url)
r.raise_for_status()
return r.json()wait_random_exponential) desynchronizes clients so they
don't all hit at once. This is the difference between helping a service recover and re-crashing it.
400 Bad Request, a 404, a ValueError, or an auth
failure just repeats a guaranteed failure and wastes time, or worse, re-sends a non-idempotent action (a
duplicate payment). Always scope retry_if_exception_type to errors that can plausibly
succeed on a retry, and be careful retrying non-idempotent POSTs.
tenacity's @retry works on both sync and async def functions
transparently, and it composes with the httpx.AsyncClient pattern from §14. A resilient
production HTTP call is usually: one reused async client, a @retry with exponential backoff
scoped to transport/5xx errors, plus a per-request timeout. Mentioning that full stack signals
real-world experience.
16Interview & Practice Q&A#
The follow-ups this material naturally invites. Read each answer aloud in 2–4 sentences.
Why prefer isinstance(x, T) over type(x) == T?
isinstance respects inheritance, it returns True for subclasses, which is
almost always what you want (an Admin should count as a User).
type(x) == T demands an exact class match and silently rejects subclasses. Use exact
type comparison only in the rare case where subclasses genuinely must be excluded, and
watch the bool-is-an-int edge case.
Explain the mutable default argument trap and how to fix it.
def f(x=[]) creates a single list shared by every call that
uses the default, causing state to leak between calls. The fix is to default to None
and create the mutable object inside the body:
def f(x=None): x = [] if x is None else x. Dataclasses enforce the same rule via
field(default_factory=list).
What does @wraps do and why does it matter?
functools.wraps copies the wrapped function's metadata,
__name__, __doc__, signature, onto the wrapper the decorator returns.
Without it, the decorated function reports the wrapper's identity, breaking help text, logging,
debuggers, and introspection tools. It's a one-line fix that every well-written decorator includes.
Concurrency vs parallelism, and where does asyncio fit?
asyncio provides
concurrency on a single thread, ideal for I/O-bound work like many network calls, but it does not
parallelize CPU work. For CPU-bound workloads you need multiprocessing (or threads for
releasing-the-GIL C extensions), not asyncio.Why can a single blocking call ruin an async program?
await points.
A synchronous blocking call, time.sleep, requests.get, a tight CPU loop,
never yields control, so it stalls the entire loop and every other task waits behind it. Use
async-native equivalents (asyncio.sleep, httpx.AsyncClient) or offload
blocking work with asyncio.to_thread.When should you NOT use @lru_cache?
TypeError; when the function has side effects or must return fresh results each
call; and for unbounded caches in long-running processes, where holding every result forever leaks
memory. If arguments have huge cardinality, the cache brings little benefit and much cost, bound
maxsize and consider cache_clear().
ABC vs Protocol, when do you reach for each?
What makes a good retry policy?
What's the difference between *args at a call site versus in a definition?
*args gathers extra positional arguments into a
tuple (and **kwargs gathers extra keywords into a dict). At a call site,
*seq spreads a sequence into separate positional arguments (and **d
spreads a dict into keyword arguments). Same symbols, opposite directions, gather when defining,
spread when calling, which is exactly why decorators use
def wrapper(*a, **k): func(*a, **k).
17Key Takeaways#
- One theme underlies most of this: Python lets you treat names, arguments,
functions, and types as data you can inspect (
getattr/isinstance), reshape (*/**), wrap (decorators), and defer (...,lambda, async). - The stars are direction-sensitive: in a definition they gather leftover arguments; at a call site they spread a collection out. Decorators exploit both at once.
- The two biggest gotchas are the mutable default argument and lambda/loop late
binding. Both come from "evaluated once / captured by reference" and both have
clean fixes (
Nonesentinel;i=idefault). - Decorators are one pattern, function-returns-function, and everything from
@lru_cacheto@dataclassto@retryis a variation. Always keep@wraps. - Choose your "contract" tool by philosophy: ABC for declared inheritance, Protocol for structural duck typing, dataclass for pure data holders.
- Async is concurrency, not parallelism: it overlaps I/O waiting on one thread.
gatheris where the speedup lives; a single blocking call ruins it;httpx.AsyncClientis the async-native HTTP client. - Resilience is a policy, not a loop:
tenacitygives you bounded attempts, exponential backoff with jitter, and error-scoped retries, only for transient, idempotent failures.