Skip to content

Properties and Attribute-Boundary Control

Page Maps

graph LR
  family["Python Programming"]
  program["Python Meta-Programming"]
  section["Class Customization Pre Metaclasses"]
  page["Properties and Attribute-Boundary Control"]
  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"]

Properties are the friendliest place in the course to say something very important:

attribute access is still runtime machinery, even when the syntax looks like a plain field.

That is why this page matters in Module 06. It keeps one attribute boundary visible without yet escalating into broader descriptor systems.

This page should also correct a common reading mistake: pleasant attribute syntax often makes the owner disappear. A property is valuable precisely because it puts one owner back on the boundary between stored state and public attribute access.

The sentence to keep

When you see @property, ask:

what invariant or computation is being owned at this one attribute boundary?

That question keeps properties from being mistaken for ordinary stored values.

Keep a second question beside it:

if the same rule appeared on five fields tomorrow, would this still be the right owner?

That second question is what keeps a property honest. A property is not "small, so it is fine." It is correct only when one attribute boundary is the real surface.

A property is still a descriptor

The course has already hinted at this earlier. Here it becomes concrete:

  • instance.attr triggers property getter behavior
  • instance.attr = value triggers property setter behavior when present
  • del instance.attr triggers property deleter behavior when present

So a property is not a special beginner feature separate from descriptor machinery. It is the most approachable descriptor form most Python developers meet.

That is one reason Module 06 places properties before the reusable descriptor pages. Readers need to see that the runtime machinery is already here before the course increases its reuse power.

Begin with the observable conflict

Run make class-customization-lab and read property_boundary. Its DeliveryConfig.endpoint property stores a valid HTTPS URL in _endpoint. The lab then writes an apparently conflicting entry directly:

vars(config)["endpoint"] = "shadowed"

Reading config.endpoint still returns "https://example.test", not "shadowed". That is runtime evidence for data-descriptor precedence. It also reveals the storage story: the property owns public endpoint; _endpoint owns the value.

Properties are data descriptors

This detail matters a lot:

a property is a data descriptor, even when you did not define a custom setter.

That means a property wins over instance dictionary state during lookup and cannot be shadowed the way a non-data descriptor can.

That is one of the most useful lookup corrections Module 06 can make before the descriptor modules arrive.

Use this quick table to keep the lookup consequence concrete:

Situation What wins in normal lookup
property on the class and same-named key in obj.__dict__ the property
plain instance attribute with no descriptor on the class the instance attribute
non-data descriptor with same-named instance attribute the instance attribute

One picture of the lookup boundary

obj.x
  -> data descriptor on the class?
  -> instance storage?
  -> non-data descriptor or plain class attribute?

For properties, the first step wins.

That is why a read-only property still beats obj.__dict__["x"] during normal lookup.

That sentence is worth testing directly when you practice. Readers often believe it only after seeing the failed shadowing attempt.

A proof route for property precedence

Use this route when you need to prove the lookup story to yourself:

  1. define a read-only property on the class
  2. write a same-named key into obj.__dict__
  3. read obj.attr
  4. confirm the property still wins during normal lookup
  5. explain why direct dictionary inspection is not the same as normal attribute access

That route matters because many learners assume "no setter" means "shadowable." The runtime does not agree.

The packet performs the same conflict with a setter-backed property and records both values. The focused test fails if lookup ever returns the same-named instance entry.

A standard validation example

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius must be non-negative")
        self._radius = value

This is a good Module 06 example because:

  • the invariant belongs to one attribute
  • the storage remains explicit
  • the class does not need a wider descriptor framework yet

That is exactly the sort of lower-power ownership decision the module is trying to teach.

The lab uses a domain-shaped rule instead: assigning "http://insecure.test" raises ValueError("endpoint must use https"), and the prior valid endpoint remains visible. The property therefore owns an invariant on every ordinary assignment, not merely a formatted read.

It also shows a review habit worth keeping: the public name and the storage name should tell the same story. If the public boundary is radius, the backing storage should not turn into a confusing second policy surface.

Use this storage review card when a property starts looking busy:

Question Why it matters
does the backing name clearly belong to the public attribute? storage should not become a secret second API
does the setter enforce one rule or several unrelated ones? many policies in one setter usually signal a different owner
does the getter compute, normalize, or expose mutable state? the learner needs to know whether the boundary is over state or behavior

Read-only does not mean shadowable

class ReadOnly:
    @property
    def x(self):
        return 123

Even without a custom setter, this property still wins over instance state during lookup.

That is a crucial correction because many people assume "no setter" means the property is like a method-like computed attribute that can be shadowed. It is not.

Do not turn "cannot be shadowed through normal lookup" into "cannot be bypassed." Directly mutating the backing field, overriding lookup, or changing the class can still change behavior. This proof is about descriptor precedence, not tamper-proof state.

Storage patterns to review explicitly

Properties often look simple while hiding awkward storage choices. Review these patterns plainly:

Pattern Why it matters
dedicated backing field such as _radius keeps the owner story explicit
computed getter with no storage means the attribute is a boundary over behavior, not state
setter that normalizes values before storing makes mutation policy visible at one attribute
read-only property with hidden mutable backing object may still expose mutation through the returned object

Properties can also be extended in subclasses

Another useful pattern is reusing a property from a base class:

class Base:
    @property
    def value(self):
        return 42


class Sub(Base):
    @Base.value.getter
    def value(self):
        return getattr(self, "_value", super().value)

    @Base.value.setter
    def value(self, value):
        self._value = value

This is a good reminder that properties are first-class objects on the class, not only syntax decorations on methods.

It is also a reminder that inheritance can sharpen or blur the owner story. A subclassed property should make the new boundary clearer, not just more surprising.

A debugging route for inherited properties

When a property changes in a subclass, review it in this order:

  1. identify the base-class boundary the original property owned
  2. say whether the subclass is widening, narrowing, or redirecting that boundary
  3. confirm the storage story still makes sense after override
  4. reject the override if the learner would need hidden base-class facts to explain it

Properties are strongest when the boundary is truly one attribute

A property is a great fit when:

  • one field needs validation
  • one computed value needs a read boundary
  • one deletion or mutation rule belongs to one name

A property is a weaker fit when:

  • many fields need the same behavior
  • the design wants reusable field machinery
  • the invariant is no longer about one attribute boundary

Those are clues that later descriptor tools may be the more honest owner.

Compare the lab's property_boundary with descriptor_boundary. The property spells out one HTTPS rule for one attribute. TypedField becomes useful only when the same shallow type-and-storage rule repeats for endpoint and attempts. Reuse pressure, not prestige, justifies the stronger owner.

Use this escalation table when you feel unsure:

Pressure Stay with property? Why
one attribute needs local validation yes the rule is still one boundary wide
one attribute exposes a computed read view yes attribute-shaped access remains honest
several attributes copy the same setter policy no repetition becomes the real smell
the same field rule appears across multiple classes no reuse now matters more than one local boundary

Common overclaims to reject

Reject these sentences when they appear in review:

Overclaim Better replacement
"it's just an attribute" "it is an attribute-shaped access point with runtime behavior behind it"
"read-only means immutable" "read-only means ordinary assignment is blocked at this boundary"
"a property is simpler than a descriptor" "a property is a focused built-in descriptor for one attribute boundary"
"we can keep copying this property pattern" "repetition may be evidence that a reusable descriptor is the clearer owner"

Failure modes for property design

These are the most common ways a property stops being the clearest owner:

Failure mode Why it weakens the design Repair move
copied property logic across many fields the real owner becomes repetition rather than one boundary promote the repeated rule to a descriptor
property returns a mutable object while sounding immutable readers overtrust the surface name the mutability limit explicitly
backing storage names become inconsistent or magical the state boundary gets harder to inspect keep storage explicit and stable
property exists where a plain method would be clearer attribute syntax hides that this is not really attribute-like behavior switch to a method when no attribute boundary is being protected

One more failure mode is worth naming:

Failure mode Why it weakens the design Repair move
calling the property "read-only" while returning a mutable structure without warning the surface sounds safer than it is name the mutability limit and decide whether a different API shape would be clearer

Review rules for properties

When reviewing @property usage, keep these questions close:

  • what one attribute boundary is this property owning?
  • is the storage behind it still explicit and understandable?
  • does the review understand that the property is a data descriptor?
  • is this still a one-attribute rule, or is the code starting to want reusable field machinery?
  • would a plain method be clearer if no true attribute-boundary semantics are needed?
  • can another reviewer point to the exact storage or computation owner without guessing?

Evidence packet for property reviews

Leave this page with a small packet:

  • one lookup trace showing why the property wins over same-named instance state
  • one named attribute boundary and its backing-storage story
  • one sentence explaining why the rule is still local enough to stay a property
  • one rejected escalation note describing what repeated pressure would justify a descriptor

Smallest honest proof route

python -m unittest \
  tests.test_class_customization_evidence.ClassCustomizationEvidenceTests.test_property_packet_proves_data_descriptor_precedence

This proves normal lookup precedence, assignment failure, and backing-storage separation. It does not prove deep immutability, secrecy, thread safety, or protection against deliberate bypass.

Exit check for this page

Before moving on, make sure you can do all of these:

  • explain why a property beats same-named instance state during lookup
  • name one case where a property is the right owner and one where repetition should push you toward a descriptor
  • explain why read-only is weaker than deep immutability
  • describe the backing-storage story for one property in plain language

What to practice from this page

Try these before moving on:

  1. Predict both visible values before running the packet.
  2. Change the valid endpoint and prove a rejected later assignment leaves it unchanged.
  3. Add a read-only property and show that a same-named entry in __dict__ still loses.
  4. Name the exact repetition that would justify moving this rule into a descriptor.

If those feel ordinary, the next step is to use type hints as declarative aids for shallow validation through descriptors.

Continue through Module 06