Decorator Factories and Parameter Capture¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Meta-Programming"]
section["Decorator Design Policies Typing"]
page["Decorator Factories and Parameter Capture"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
Module 05 begins where Module 04 left off: with wrappers that are no longer fixed and generic, but configurable.
The key sentence is:
a decorator factory runs once at definition time, captures configuration, and returns the real decorator that will wrap the function.
That sentence matters because it keeps configuration timing and wrapper timing separate. It also keeps a common mistake visible: policy can become expensive or misleading before the first real call ever happens.
The sentence to keep¶
When you see @decorator_name(config, ...), ask:
what configuration is being captured now, and what wrapper behavior will use it later at call time?
That question keeps parameterized decorators grounded in ordinary rebinding instead of turning them into framework mystique.
Add one more review question beside it:
if this configuration were wrong, when would I notice and who would have to fix it?
Read the shipped factory before writing another¶
Open labs/decorator_policy/retry.py and locate retry. It has three distinct
execution moments:
| Moment | Code that runs | Object or state produced |
|---|---|---|
| factory call | validates exceptions, max_attempts, and backoff_seconds |
an immutable RetryPolicy plus decorate |
| decorator application | receives function |
wrapped, which closes over the function, policy, exception tuple, and sleeper |
| ordinary call | enters the finite loop | a result or the final retryable exception |
Run make decorator-policy-lab and inspect retry.captured_policy. That policy is
available before deliver("INC-42") runs. The output therefore proves capture timing
without relying on a print statement hidden in the factory.
Now change max_attempts to 4 without adding a third delay. The factory raises
ValueError before it receives a function. That is a deliberate definition-time
failure: an incoherent policy never becomes a callable.
A decorator factory has three layers¶
The standard shape is:
- factory
- decorator
- wrapper
The factory captures configuration. The decorator captures the function. The wrapper runs on each call with both available through closures.
factory = retry(
exceptions=(TransientDeliveryError,),
max_attempts=3,
backoff_seconds=(0.1, 0.2),
sleep=record_sleep,
)
decorated = factory(deliver)
result = decorated("INC-42")
That is the full structure. Nothing else is hidden.
The teaching value of this three-layer picture is not only structural. It forces you to name where each responsibility lives:
- the factory owns configuration capture
- the decorator owns rebinding
- the wrapper owns repeated runtime behavior
Trace the captured owners¶
graph TD
factory["Factory call<br/>retry(...)"]
decorator["Returned decorator"]
apply["decorator(func)"]
wrapper["wrapper closes over policy,<br/>exceptions, sleeper, and function"]
caller["later call"]
factory --> decorator --> apply --> wrapper --> caller
Caption: policy is validated and captured once; the finite call loop consumes it later.
@factory(arg) is just another desugaring¶
This:
@retry(
exceptions=(TransientDeliveryError,),
max_attempts=3,
backoff_seconds=(0.1, 0.2),
)
def deliver(incident_id: str) -> str:
...
means:
decorator = retry(
exceptions=(TransientDeliveryError,),
max_attempts=3,
backoff_seconds=(0.1, 0.2),
)
def deliver(incident_id: str) -> str:
...
deliver = decorator(deliver)
So the timing is:
retry(...)validates and captures its policy once- the resulting decorator wraps
deliveronce - later calls run the wrapper many times
That separation becomes important when configuration work is heavy or when the decorator captures policy that should be visible to review.
It also becomes important when factory code has side effects. Logging, registry writes, environment reads, or expensive setup at factory time all happen before ordinary business calls begin.
Definition-time surprises to catch early¶
The phrase "it only runs once" is not enough. One-time work can still be expensive or surprising.
Use this table during review:
| Factory behavior | Why it matters |
|---|---|
| reads environment or process state | the captured policy may differ across import contexts |
| performs heavy setup | the cost appears at definition or import time, not first call |
| mutates a registry or global surface | the wrapper now has a broader ownership story than local configuration |
| validates configuration aggressively | failures appear before the wrapped callable is ever invoked |
Different applications get independent configuration¶
Apply retry twice: once for (ConnectionError,) with two attempts, and once for
(TimeoutError,) with four. Each wrapped callable exposes its own
__retry_policy__. The policies are immutable values, not one mutable global settings
dictionary.
That is one of the big benefits of this pattern: per-use configuration without global state.
It is also one of the first places where learners should ask whether "per-use" is still the right owner. Some configuration is local and healthy. Some is the beginning of policy fragmentation across call sites.
Factories are already policy surfaces¶
This is where Module 05 starts raising the review bar.
A decorator factory does not only capture a prefix or logging level. It can also capture:
- retry counts
- timeout durations
- rate limits
- cache sizes
- validation modes
That means the factory boundary is often where a wrapper stops being generic and starts owning policy.
So the review question is not only "does this work?" It is also:
should this policy really live in a decorator closure?
That is the first design judgment Module 05 wants you to practice. A captured retry budget, timeout duration, validation mode, or cache size is not harmless only because the code still fits in one screen.
Factories still need wrapper transparency¶
Even with configuration involved, the usual wrapper rules remain:
- forward
*args, **kwargsunless the design has a reason not to - preserve metadata with
functools.wraps - keep definition-time and call-time work distinct
If a factory captures complex policy but loses the callable's public identity, the design is already harder to trust.
Transparency also includes policy visibility. A well-preserved __name__ does not rescue
a wrapper whose captured settings are too broad or too hidden to review cleanly.
Over-parameterization is a warning sign¶
Factories can grow unwieldy fast:
- too many boolean flags
- too many interacting knobs
- too much policy branching hidden in wrapper code
When that happens, the decorator may be competing with a small explicit object or service configuration API. Module 05 will come back to that boundary directly in the final core.
Use this quick comparison when the parameter list starts growing:
| If the captured knobs mostly... | Better default owner |
|---|---|
| label one call boundary narrowly | decorator factory may still be the right fit |
| coordinate retries, limits, or validation modes across many call sites | explicit object or service becomes stronger |
| need shared operational inspection | explicit owner is usually clearer |
| interact in ways reviewers must test combinatorially | decorator closure is becoming too hidden |
Common factory failure modes¶
These are the mistakes to catch before the module moves into heavier policy:
| Failure mode | Why it weakens the design | Repair move |
|---|---|---|
| treating factory timing like call timing | hides when configuration work or failure really happens | desugar the decorator and name the once-only steps |
| capturing too many booleans and modes | policy becomes hard to inspect from call sites | replace the flag bundle with an explicit owner or configuration object |
| allowing configuration to imply stronger guarantees than the wrapper delivers | readers overtrust the surface | state the exact supported behavior and non-goals |
| preserving metadata but hiding captured policy | tooling can inspect the callable but humans still cannot inspect the ownership story | document or externalize the policy surface |
Prove the timing claim¶
Run:
python -m unittest \
tests.test_decorator_policy_evidence.DecoratorPolicyEvidenceTests.test_retry_factory_rejects_incoherent_policy_before_decoration \
tests.test_decorator_policy_evidence.DecoratorPolicyEvidenceTests.test_retry_wrapper_exposes_policy_and_preserves_callable_evidence
The first test proves configuration is rejected at factory time. The second proves the
captured policy remains inspectable while functools.wraps preserves the original
callable surface. Neither test proves retry is safe for non-idempotent work; that
judgment belongs to the next core.
Review rules for decorator factories¶
When reviewing a decorator factory, keep these questions close:
- what configuration is captured once at definition time?
- what later wrapper behavior depends on that configuration?
- does each application get independent captured state as intended?
- is the wrapper still transparent enough for tools and reviewers?
- have the parameters grown large enough that an explicit object would be clearer?
- what would break first if the captured configuration were wrong or inconsistent across call sites?
Exit check for this page¶
Before moving on, make sure you can do all of these:
- name the responsibility of the factory, the decorator, and the wrapper separately
- explain one concrete effect that can happen at definition time before any business call
- state when per-use configuration stays healthy and when it starts fragmenting policy
- reject one decorator factory whose captured parameter surface should become an explicit owner instead
What to practice from this page¶
Try these before moving on:
- Desugar the shipped
retry(...)application by hand. - Add one invalid policy case to the focused tests and explain why failure belongs at factory time.
- Apply two different policies and prove they do not share configuration.
- Write down one parameterized decorator idea that still feels honest and one that already feels like too much policy for a wrapper.
If those feel ordinary, the next step is to study policy-heavy wrappers that change control flow and error behavior directly.
Continue through Module 05¶
- Previous: Overview
- Next: Resilience and Control-Flow Wrappers
- Practice: Exercises
- Terms: Glossary