Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/knowledge/python/closures-lexical-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
tags:

- python
- closures
- scope
- functions

---

# Closures and Lexical Scope in Python

Python resolves names by **lexical (static) scope**: where a function is *written*
determines which variables it can see — not where it is *called* from. A closure is what
you get when an inner function keeps using variables from the enclosing function after
that function has returned.

## The LEGB Rule

When Python looks up a name inside a function it searches, in order:

1. **L**ocal — the current function.
1. **E**nclosing — any enclosing function scopes, innermost first.
1. **G**lobal — the module level.
1. **B**uilt-in — `len`, `print`, etc.

Only functions, classes, and modules create scopes. `if`/`for`/`while`/`with` blocks do
**not**, so a variable assigned in a loop body is visible after the loop.

## Closures

```python
def make_counter():
count = 0

def increment():
nonlocal count
count += 1
return count

return increment


c = make_counter()
c(), c(), c() # 1, 2, 3
```

`make_counter` has returned, yet `count` survives: `increment` holds a reference to it
through a **cell**. You can inspect this:

```python
c.__closure__[0].cell_contents # 3
c.__code__.co_freevars # ('count',)
```

Each call to `make_counter()` creates a new, independent cell — two counters do not share
state.

## `nonlocal` and `global`

Assignment makes a name **local to the whole function** at compile time. So this fails:

```python
def outer():
x = 0
def inner():
x += 1 # UnboundLocalError: x is treated as local
inner()
```

`nonlocal x` says "this name lives in the nearest enclosing function scope"; `global x`
says "module scope". Reading a free variable needs no declaration — only rebinding does.
Mutating an object (`items.append(...)`) is not rebinding, so it needs no `nonlocal`.

## The Classic Gotcha: Late Binding

Closures capture the **variable**, not its value at definition time:

```python
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] -- all see the final i
```

Fixes: bind the value as a default argument, or use `functools.partial`:

```python
funcs = [lambda i=i: i for i in range(3)] # [0, 1, 2]
```

## Where Closures Are Used

- **Function factories** and configuration ("make me a validator with this threshold").
- **Callbacks** that need a bit of context without a full class.
- **[Decorators](decorators.md)** — the wrapper closes over the decorated function and any
decorator arguments.
- Lightweight private state; a class is usually clearer once there is more than one
piece of state or more than one operation.

## Key Takeaways

- Scope is decided by where code is written (LEGB), not by the call site.
- Closures capture variables via cells; rebinding them requires `nonlocal`.
- Late binding bites in loops — capture with a default argument.
- Only functions/classes/modules create scope; blocks don't.

## Related Articles

- [Python Tips & Tricks](python-tips.md) — a short intro to scopes and closures.
- [Decorators](decorators.md)
139 changes: 139 additions & 0 deletions docs/knowledge/python/context-managers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
tags:

- python
- context-managers
- resources
- contextlib

---

# Context Managers in Python

A context manager guarantees that setup and teardown code run as a pair, no matter how
the block in between exits — normally, via `return`, or via an exception. It is Python's
answer to "always release this resource", and the `with` statement is its syntax.

## What `with` Actually Does

```python
with open("data.txt") as f:
data = f.read()
```

is roughly equivalent to:

```python
mgr = open("data.txt")
f = type(mgr).__enter__(mgr)
try:
data = f.read()
except BaseException as exc:
if not type(mgr).__exit__(mgr, type(exc), exc, exc.__traceback__):
raise
else:
type(mgr).__exit__(mgr, None, None, None)
```

Two methods make up the protocol:

- `__enter__(self)` runs on entry; its return value is bound to the name after `as`.
- `__exit__(self, exc_type, exc, tb)` runs on exit, always. If the block raised, the
exception details are passed in; if it did not, all three are `None`. **Returning a
truthy value from `__exit__` suppresses the exception**; returning `None`/`False` lets
it propagate.

## Class-Based Context Managers

```python
import time


class Timer:
def __enter__(self) -> "Timer":
self.start = time.perf_counter()
return self

def __exit__(self, exc_type, exc, tb) -> None:
self.elapsed = time.perf_counter() - self.start
# returning None -> exceptions still propagate


with Timer() as t:
sum(range(10_000_000))
print(t.elapsed)
```

## Generator-Based: `contextlib.contextmanager`

For most cases a generator is shorter. Everything before `yield` is `__enter__`, the
yielded value is the `as` target, and everything after is `__exit__`:

```python
from contextlib import contextmanager


@contextmanager
def transaction(conn):
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
```

The gotcha: **an exception in the `with` body is re-raised at the `yield`**. If you want
cleanup to happen on failure you need `try`/`finally` (or `except`) around the `yield` —
without it, code after `yield` is skipped when the body raises.

## Useful `contextlib` Tools

- `suppress(*exceptions)` — swallow specific exceptions:
`with suppress(FileNotFoundError): os.remove(path)`.
- `closing(obj)` — call `obj.close()` on exit for objects that have `close()` but are
not context managers themselves.
- `redirect_stdout(target)` — temporarily redirect `print` output.
- `ExitStack` — manage a *dynamic* number of context managers, or register arbitrary
cleanup callbacks:

```python
from contextlib import ExitStack

with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
# all files are closed on exit, in reverse order, even if one open() failed
```

- `nullcontext()` — a no-op stand-in, handy for "optionally lock/trace":
`with (lock if needs_lock else nullcontext()):`.

## Multiple Managers and Async

Multiple managers can share one `with`; they are entered left to right and exited in
reverse:

```python
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())
```

For async resources (DB connections, HTTP sessions) the protocol is `__aenter__` /
`__aexit__`, used with `async with`; `contextlib.asynccontextmanager` is the generator
equivalent. See [asyncio concurrency](asyncio-concurrency.md).

## Key Takeaways

- `with` = guaranteed `__exit__`, including on exceptions and early `return`.
- `__exit__` returning truthy **swallows** the exception — do it deliberately.
- Prefer `@contextmanager` for simple cases; always wrap `yield` in `try`/`finally` when
cleanup must run on error.
- Reach for `ExitStack` when the number of resources is only known at runtime.

## Related Articles

- [Decorators](decorators.md) — `@contextmanager` is itself a decorator.
- [Metaprogramming & Dunder Methods](metaprogramming-dunder-methods.md) — the data model
protocol `__enter__`/`__exit__` belong to.
122 changes: 122 additions & 0 deletions docs/knowledge/python/decorators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
tags:

- python
- decorators
- functools
- closures

---

# Decorators in Python

A decorator is a callable that takes a function (or class) and returns a replacement,
usually a wrapper that adds behavior. The `@` syntax is sugar:

```python
@decorator
def f(): ...

# is exactly
def f(): ...
f = decorator(f)
```

It happens once, at definition time. Decorators rely on
[closures](closures-lexical-scope.md) to remember the original function.

## A Well-Behaved Decorator

```python
import functools
import time


def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")

return wrapper
```

Always use **`functools.wraps`**: it copies `__name__`, `__doc__`, `__module__`, and sets
`__wrapped__`, so debugging, introspection, and `help()` still point at the original
function instead of "wrapper". Forward `*args, **kwargs` so any signature works, and
return the result.

## Decorators With Arguments

`@retry(times=3)` means `retry(times=3)` is called first and must *return* the real
decorator — so you need three nested levels:

```python
def retry(times: int = 3, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
return wrapper
return decorator


@retry(times=5, exceptions=(ConnectionError,))
def fetch(): ...
```

## Stacking and Order

Decorators apply bottom-up (closest to the function first) but the resulting wrappers run
top-down:

```python
@a
@b
def f(): ... # f = a(b(f)); calling f() enters a's wrapper first
```

Order matters: `@login_required` above `@cache` means auth is checked before the cache;
reverse them and cached results skip the check.

## Class-Based and Class Decorators

A class with `__call__` can be a decorator, which is convenient when it needs state
(call counts, a cache). A decorator can also receive a *class* and return it modified
(e.g. `@dataclass`) — often a lighter alternative to a metaclass; see
[Metaprogramming & Dunder Methods](metaprogramming-dunder-methods.md).

## Decorating Methods and Async Functions

- A plain function wrapper works on methods because `self` arrives in `*args`.
- For `async def`, the wrapper must itself be `async def` and `await` the call —
otherwise you return an un-awaited coroutine and skip timing/retry logic entirely.

## Built-in Decorators Worth Knowing

- `@property`, `@staticmethod`, `@classmethod`
- `@functools.lru_cache` / `@functools.cache` — memoization (arguments must be hashable;
on methods it also keeps `self` alive)
- `@functools.singledispatch` — dispatch on argument type
- `@contextlib.contextmanager` — see [context managers](context-managers.md)
- `@dataclasses.dataclass`

## Key Takeaways

- `@dec` is `f = dec(f)`, run at definition time.
- Use `functools.wraps`, forward `*args, **kwargs`, return the result.
- Decorator with arguments = a factory returning the decorator (three levels).
- Stacking applies bottom-up, executes top-down.

## Related Articles

- [Closures and Lexical Scope](closures-lexical-scope.md)
- [Context Managers](context-managers.md)
Loading
Loading