Worked Example: Building a Minimal @frozen Class Decorator¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Meta-Programming"]
section["Class Customization Pre Metaclasses"]
page["Worked Example: Building a Minimal `@frozen` Class Decorator"]
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"]
The five core lessons in Module 06 become easier to trust when they meet one class tool that is useful, tempting, and easy to overclaim.
A minimal @frozen decorator is exactly that kind of tool.
Treat this page as a review packet, not as a production recipe. Its main value is that it shows where a post-construction class rule stays honest, where it starts to overstate its power, and what evidence a reviewer should demand before accepting it.
It combines:
- post-construction class transformation
- attribute-boundary control
- explicit limits around mutability
- a design decision about how much policy should live in one decorator
That makes it the right worked example for this module.
Review questions for the page¶
Keep two questions active while reading:
- what exact mutability surface does this decorator own?
- what stronger immutability claim must it refuse?
Keep a third question beside them:
- what first requirement would make this decorator too weak to stay the honest owner?
The incident¶
Assume a team wants a small @frozen decorator for configuration-style classes.
They want it to:
- allow attributes to be assigned during initialization
- reject later reassignment and deletion
- stay readable to teammates who inspect the class
- avoid reaching for metaclasses
Those are reasonable goals. The mistake would be pretending this now creates deep or universal immutability.
That sentence is the real incident. The syntax is easy. The hard part is keeping the class-level claim narrow enough that readers do not confuse "blocks rebinding" with "makes the object universally immutable."
That is why this page remains useful even if you never ship a decorator like this. It teaches how to review seductive class claims without letting convenience language outrun the actual boundary.
Establish the executable baseline¶
Run:
Keep labs/class_customization/frozen_surface.py,
tests/test_frozen_surface_class_decorator.py, and the frozen_surface evidence packet
together. The source identifies the owner, the tests prove success and failure routes,
and the packet exposes the result to a learner.
The first design rule: define frozen at the surface¶
This example uses "frozen" in a deliberately narrow sense:
- instance attributes cannot be reassigned after initialization
- instance attributes cannot be deleted after initialization
This example does not claim:
- deep immutability of nested containers
- protection against every low-level escape hatch
- compile-time enforcement
That boundary keeps the example honest and teachable.
Non-goals to keep visible¶
This worked example is intentionally not trying to deliver:
- deep immutability of nested state
- protection against deliberate low-level bypasses
- field-by-field validation semantics
- class-creation-time enforcement
- framework-grade frozen-class infrastructure
If the design needs those guarantees, the decorator has reached the edge of what this module is trying to teach.
Step 1: choose a post-construction design¶
Because the class already exists before the decorator runs, the decorator can install a small amount of behavior after class creation.
That makes a class decorator a good fit when the rule is:
- opt-in
- uniform across the whole class
- visible in one place
This is exactly the boundary Module 06 is trying to teach.
It also explains why this example belongs before the metaclass material. The policy is installed after class creation and therefore should be reviewed as post-construction class customization, not as class-construction control.
Step 2: allow initialization, then flip the boundary¶
The decorator needs one moment of flexibility during __init__, followed by a stricter
steady state afterward.
One simple design is:
- wrap
__init__ - mark the instance as still initializing before running the original initializer
- mark initialization as complete afterward
- reject
__setattr__and__delattr__once initialization succeeds
That keeps the state transition explicit instead of magical.
Use this state-transition table when reviewing the example:
| Moment | What is allowed |
|---|---|
before wrapped __init__ finishes |
initialization assignments may proceed |
after _surface_frozen_ready becomes true |
normal rebinding and deletion are rejected |
| inside nested mutable objects already stored on the instance | mutation may still occur unless another owner blocks it |
if __init__ raises |
no usable instance returns; the decorator makes no post-failure object claim |
A proof route for the boundary flip¶
Use this route when you test or explain the implementation:
- show one assignment during initialization that succeeds
- show one reassignment after initialization that fails
- show one deletion after initialization that fails
- show one nested mutable object that still changes successfully
- explain why step 4 is part of the intended boundary rather than a bug
Step 3: keep the interception rules narrow¶
The __setattr__ override should reject only the post-initialization mutation boundary.
It should not:
- invent deep copy behavior
- try to freeze class attributes
- claim to secure every internal object graph
If the design needs those guarantees, this decorator is no longer the right owner.
Read the shipped implementation by owner¶
surface_frozen captures the original __init__, __setattr__, and __delattr__.
It installs three replacement methods and one immutable FrozenSurfacePolicy.
| Owner | Responsibility |
|---|---|
| wrapped initializer | open initialization, run the original initializer, close only after success |
installed __setattr__ |
reject ordinary rebinding after the boundary closes |
installed __delattr__ |
reject ordinary deletion after the boundary closes |
FrozenSurfacePolicy |
publish what the decorator blocks and what it deliberately allows |
During initialization, assignments still pass through the original __setattr__. A
focused test records that behavior. Policy installation must not erase an existing
initialization rule merely because the decorator adds a later boundary.
The decorator refuses instances without __dict__ rather than half-supporting slots.
That is a limitation of this teaching implementation, not a limitation of frozen
classes generally.
What this implementation proves and what it does not¶
Use the code block as evidence, not as marketing:
| Claim | Supported by this implementation? | Why |
|---|---|---|
| it installs the rule after class creation | yes | the decorator modifies the finished class object |
| it allows initialization before freezing the surface | yes | wrapped __init__ flips _surface_frozen_ready only after successful setup |
| it blocks later rebinding and deletion of top-level instance attributes | yes | __setattr__ and __delattr__ reject after the ready flag is set |
| it makes nested lists, dicts, or other contained objects immutable | no | stored objects retain their own mutation behavior |
| it prevents every low-level escape hatch | no | the example is not trying to secure the entire object model |
| it requires a metaclass | no | the rule is installed after class creation and does not depend on namespace control |
| it supports slotted-only instances | no | decoration refuses them clearly |
| subclasses cannot weaken the rule | no | an overriding __setattr__ can replace the boundary |
Read that table as a review contract, not as decoration. If a future version of the decorator wants to promise more, both the implementation and the proof burden have to grow.
Why this version shows the right boundary¶
This decorator is useful because it keeps every important choice visible:
- the class is transformed after creation
- initialization still happens through the original constructor
- the mutation boundary is enforced through normal attribute hooks
- the policy is small enough to review in one pass
That is the kind of post-construction customization Module 06 is aiming for.
A review card for adapting the example¶
If you change this decorator, answer these before you trust the new version:
| Adaptation question | Why it matters |
|---|---|
| does the meaning of "frozen" stay surface-level or expand? | the review claim changes immediately if the meaning widens |
| is the initialization window still explicit? | hidden state transitions weaken reviewability |
| do subclass overrides preserve or bypass the boundary? | inheritance is the first place the policy often drifts |
| does any new helper now own deeper mutability or validation policy? | the decorator may no longer be the only meaningful owner |
Failure surfaces a reviewer should inspect¶
Even this bounded design has pressure points:
| Failure surface | Why it matters |
|---|---|
_surface_frozen_ready state transitions |
the rule is only as clear as the moment the boundary flips |
| nested mutable objects | readers may overclaim deep immutability if this stays implicit |
| inheritance and subclass overrides | later classes may weaken or bypass the surface rule |
| low-level attribute bypasses | the pattern is educational surface control, not a security mechanism |
| inflated naming in docs or comments | truthful code can still become dishonest through language |
Where the boundary shows up immediately¶
This decorator is intentionally surface-level.
For example:
@surface_frozen
class Settings:
def __init__(self, tags):
self.tags = tags
settings = Settings(["core", "api"])
settings.tags.append("admin") # still allowed
The list object inside tags is still mutable. The decorator blocks rebinding
settings.tags, but it does not freeze the list itself.
That is not a bug in the example. That is the exact boundary.
This example therefore teaches a precise language habit: say "surface immutability" when you mean blocked rebinding, and reserve broader language for broader owners.
Subclass pressure to keep visible¶
Subclassing is the first place this design often gets weakened or overclaimed:
- a subclass may add helper methods that mutate nested state indirectly
- a subclass may override
__setattr__or__delattr__and erase the boundary - a subclass may change initialization order and make
_surface_frozen_readyharder to reason about
That does not make the example bad. It means the example is honest about staying a minimal class-level tool rather than pretending to be a full immutability framework.
The suite proves this weakness directly: MutableDeliveryConfig overrides
__setattr__ and successfully rebinds endpoint. A limitation backed by a test is
harder to erase with optimistic prose.
Why this does not need a metaclass¶
Nothing here depends on class-creation-time namespace control.
The decorator works because the needed policy can still be installed after the class already exists:
- wrap the initializer
- override attribute mutation hooks
- return the modified class
That is strong evidence that a metaclass would be unnecessary escalation for this case.
Proof ledger¶
| Claim | Focused test |
|---|---|
| initialization succeeds before mutation closes | test_initialization_succeeds_before_rebinding_and_deletion_are_blocked |
| nested mutation and low-level bypass remain possible | test_policy_names_mutability_and_bypass_limits |
| original attribute policy runs during initialization | test_original_attribute_policy_still_runs_during_initialization |
| constructor signature and provenance survive wrapping | test_wrapped_initializer_preserves_signature_and_provenance |
| slotted-only classes are refused | test_slotted_classes_are_refused_instead_of_half_supported |
| subclass override can replace the boundary | test_subclass_can_replace_the_boundary_as_the_policy_warns |
Together these tests define the teaching product. They do not prove security, deep immutability, hash safety, thread safety, or sealed inheritance.
Evidence packet to leave behind¶
If you adapt this example, leave behind a packet another reviewer can inspect quickly:
- the exact meaning of "frozen" in the codebase
- the point in time when mutation becomes blocked
- one example of mutation that is still allowed
- one sentence explaining why a metaclass is unnecessary here
- one sentence naming the first condition that would justify a stronger owner
Add one more packet note:
- one concrete adaptation you would reject because it widens the claim faster than the evidence
Questions to ask during review¶
When you see a frozen-class pattern like this, review it with these questions:
- does the decorator define frozen narrowly enough to stay truthful?
- is initialization still allowed explicitly before the object becomes frozen?
- are reassignment and deletion both covered?
- is the design pretending to guarantee deep immutability when it only controls the instance surface?
- would a plain explicit class be clearer if only one class needs this behavior?
- what subclass or nested-state case would be the first place this pattern becomes too weak?
What this example makes clear about Module 06¶
This worked example ties the module together:
- class decorators can install post-construction behavior
- dataclass-style convenience is different from immutability policy
- attribute control lives at the
__setattr__and__delattr__boundary - the smallest honest owner matters more than using the most powerful tool
That is the durable takeaway. The decorator is here as a clean case study in class customization boundaries, not as a universal immutability recipe.
Exit check for this page¶
Before leaving the worked example, make sure you can do all of these:
- explain why the rule belongs to a class decorator rather than a metaclass
- define frozen narrowly enough to stay honest
- name one mutation the decorator blocks and one it still allows
- say what missing requirement would justify a stronger owner than this pattern
- locate the focused test for every frozen-surface claim you make
Continue through Module 06¶
- Previous: Class Customization Boundaries
- Next: Exercises
- Reference: Exercise Answers
- Terms: Glossary