Warm-up · Activity 1 of 7
Warm-up from module I2: an instance assigns an attribute that the class also has. What does this print?
class Box:
size = 1
b = Box()
b.size = 2
print(b.size, Box.size)// A1.5 · ~30 min · Advanced
After this lesson you can write reusable managed attributes as descriptors, predict which of descriptor and instance dictionary wins a lookup, and explain what property really is.
You will be able to
Warm-up · Activity 1 of 7
class Box:
size = 1
b = Box()
b.size = 2
print(b.size, Box.size)Predict · Activity 2 of 7
class Ten:
def __get__(self, instance, owner):
return 10
class A:
x = Ten()
print(A().x, A.x)Practice · Activity 3 of 7
class Field:
def ____(self, owner, name):
self.private = "_" + namePractice · Activity 4 of 7
class Both:
def __get__(self, instance, owner):
return "descriptor"
def __set__(self, instance, value):
pass # ignores the value
class A:
x = Both()
a = A()
a.x = "instance"
a.__dict__["x"] = "dict"
print(a.x, a.__dict__["x"])Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
class A:
@property
def x(self):
return "property"
a = A()
a.__dict__["x"] = "dict"
print(a.x)Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Positive checks every assignment to price and qty, for any class that uses it. __set_name__ gives each instance its own storage name, so vars(item) shows _price and _qty. total is a read-only property computed from both. Try setting item.price = -1, or reading LineItem.price.
main.py
from typing import Any
class Positive:
"""A data descriptor that only accepts numbers greater than zero."""
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
self.private = "_" + name
def __get__(self, instance: object, owner: type | None = None) -> Any:
if instance is None:
return self # accessed on the class: LineItem.price
return getattr(instance, self.private)
def __set__(self, instance: object, value: float) -> None:
if value <= 0:
raise ValueError(f"{self.name} must be > 0, got {value}")
setattr(instance, self.private, value)
class LineItem:
price = Positive()
qty = Positive()
def __init__(self, name: str, price: float, qty: int) -> None:
self.name = name
self.price = price # calls Positive.__set__
self.qty = qty
@property
def total(self) -> float:
return self.price * self.qty
item = LineItem("tea", 4.5, 2)
print(item.total)
print(vars(item))
try:
item.qty = 0
except ValueError as err:
print(err)
print(type(LineItem.__dict__["price"]).__name__, type(LineItem.__dict__["total"]).__name__)
Run it with
python main.pyOutput
9.0
{'name': 'tea', '_price': 4.5, '_qty': 2}
qty must be > 0, got 0
Positive propertyTab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercise 1 of 2
Finish the descriptor Bounded(low, high). __set_name__ stores values under "_" + name, __get__ returns the descriptor itself on class access and the stored value otherwise, and __set__ raises ValueError for values outside low..high (inclusive). Thermostat uses it twice with different ranges.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Save both the name (for messages) and the private name "_" + name in __set_name__.
In __get__, check instance is None first; otherwise use getattr(instance, self.private).
In __set__, test self.low <= value <= self.high before calling setattr(instance, self.private, value).
One way to solve it. Yours can look different and still pass the checks.
from typing import Any
class Bounded:
"""A number that must stay between low and high (inclusive)."""
def __init__(self, low: float, high: float) -> None:
self.low = low
self.high = high
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
self.private = "_" + name
def __get__(self, instance: object, owner: type | None = None) -> Any:
if instance is None:
return self
return getattr(instance, self.private)
def __set__(self, instance: object, value: float) -> None:
if not self.low <= value <= self.high:
raise ValueError(f"{self.name} must be between {self.low} and {self.high}")
setattr(instance, self.private, value)
class Thermostat:
target = Bounded(5, 30)
eco = Bounded(5, 20)
def __init__(self, target: float, eco: float) -> None:
self.target = target
self.eco = eco
if __name__ == "__main__":
t = Thermostat(21, 17)
print(t.target, t.eco)
try:
t.target = 40
except ValueError as err:
print(err)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from typing import Any
class Bounded:
"""A number that must stay between low and high (inclusive)."""
def __init__(self, low: float, high: float) -> None:
self.low = low
self.high = high
# Add __set_name__: remember the name and store values under "_" + name.
# Add __get__: return the descriptor itself on class access, else the value.
# Add __set__: raise ValueError outside low..high, else store the value.
class Thermostat:
target = Bounded(5, 30)
eco = Bounded(5, 20)
def __init__(self, target: float, eco: float) -> None:
self.target = target
self.eco = eco
if __name__ == "__main__":
t = Thermostat(21, 17)
print(t.target, t.eco)
try:
t.target = 40
except ValueError as err:
print(err)
test_main.py
from main import Bounded, Thermostat
def test_get_and_set():
"""Values can be read and changed inside the range"""
t = Thermostat(21, 17)
t.target = 25
assert (t.target, t.eco) == (25, 17), f"got {(t.target, t.eco)!r}, expected (25, 17)"
def test_rejects_out_of_range():
"""A value outside low..high raises ValueError"""
t = Thermostat(21, 17)
try:
t.eco = 25
except ValueError:
assert t.eco == 17, f"after the failed assignment eco is {t.eco!r}, expected 17"
return
raise AssertionError("t.eco = 25 was accepted, but eco only allows 5 to 20")
def test_stored_under_private_names():
"""Each value lives in the instance dictionary under _ plus its name"""
t = Thermostat(21, 17)
assert vars(t) == {"_target": 21, "_eco": 17}, f"vars(t) is {vars(t)!r}"
def test_instances_are_independent():
"""Two thermostats keep their own values"""
a = Thermostat(21, 17)
b = Thermostat(10, 8)
assert (a.target, b.target) == (21, 10), f"got {(a.target, b.target)!r}: are values stored on the descriptor?"
def test_class_access_returns_descriptor():
"""Thermostat.target gives the Bounded object itself"""
assert isinstance(Thermostat.target, Bounded), f"Thermostat.target is {Thermostat.target!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
lazy is a non-data descriptor used as a decorator. It already computes the value on each access. Make it compute only once: store the result in the instance dictionary under the attribute name. Because lazy has no __set__, the next lookup finds that entry first and never calls __get__ again.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
__set_name__ already saves the attribute name in self.name.
instance.__dict__[self.name] = value writes straight into the instance dictionary, without any descriptor involved.
Do not add __set__: that would make lazy a data descriptor, and the instance dictionary entry would be ignored.
One way to solve it. Yours can look different and still pass the checks.
from collections.abc import Callable
from typing import Any
class lazy:
"""Computes the value on first access and caches it on the instance."""
def __init__(self, func: Callable[[Any], Any]) -> None:
self.func = func
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
def __get__(self, instance: object, owner: type | None = None) -> Any:
if instance is None:
return self
value = self.func(instance)
instance.__dict__[self.name] = value
return value
class Report:
def __init__(self, rows: list[int]) -> None:
self.rows = rows
self.runs = 0
@lazy
def total(self) -> int:
self.runs += 1
return sum(self.rows)
if __name__ == "__main__":
report = Report([3, 4, 5])
print(report.total, report.total, report.runs)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from collections.abc import Callable
from typing import Any
class lazy:
"""Computes the value on first access and caches it on the instance."""
def __init__(self, func: Callable[[Any], Any]) -> None:
self.func = func
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
def __get__(self, instance: object, owner: type | None = None) -> Any:
if instance is None:
return self
# Store the value so that later lookups never reach __get__ again.
return self.func(instance)
class Report:
def __init__(self, rows: list[int]) -> None:
self.rows = rows
self.runs = 0
@lazy
def total(self) -> int:
self.runs += 1
return sum(self.rows)
if __name__ == "__main__":
report = Report([3, 4, 5])
print(report.total, report.total, report.runs)
test_main.py
from main import Report, lazy
def test_value():
"""total is the sum of the rows"""
assert Report([1, 2, 3]).total == 6, f"total is {Report([1, 2, 3]).total!r}, expected 6"
def test_computed_once():
"""The function runs only on the first access"""
report = Report([1, 2])
for _ in range(3):
report.total
assert report.runs == 1, f"the function ran {report.runs} times, expected once"
def test_cached_in_instance_dict():
"""After the first access the value sits in the instance dictionary"""
report = Report([4])
report.total
assert vars(report).get("total") == 4, f"vars(report) is {vars(report)!r}"
def test_uses_the_attribute_name():
"""The cache key is the name the descriptor was assigned to"""
class Box:
@lazy
def size(self):
return 7
box = Box()
box.size
assert vars(box) == {"size": 7}, f"vars(box) is {vars(box)!r}, expected {{'size': 7}}"
def test_class_access_returns_descriptor():
"""Report.total gives the lazy object itself"""
assert isinstance(Report.total, lazy), f"Report.total is {Report.total!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyclass Product:
@property
def price(self):
return self.price
print(Product().price)
What Python prints
RecursionError: maximum recursion depth exceededWhy, and the fix
Inside the getter, self.price is the property again, so it calls itself until the stack runs out. Keep the value under a different name, such as self._price, and read that in the getter.
class Product:
def __init__(self, price):
self.price = price
@property
def price(self):
return self._price
@price.setter
def set_price(self, value):
self._price = value
Product(5)
What Python prints
AttributeError: property 'price' of 'Product' object has no setterWhy, and the fix
@price.setter returns a new property that has the setter, and the def binds it to the name set_price. The name price still holds the old, read-only property. Give the setter the same name as the getter: def price(self, value).
class Field:
def __set_name__(self, owner, name):
self.private = "_" + name
def __get__(self, instance, owner):
return getattr(instance, self.private)
def __set__(self, instance, value):
setattr(instance, self.private, value)
class P:
age = Field()
print(P.age)
What Python prints
AttributeError: 'NoneType' object has no attribute '_age'Why, and the fix
On class access, Python calls __get__ with instance set to None. Start __get__ with if instance is None: return self, so that P.age gives the descriptor, as help() and other tools expect.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.