After this lesson you can mark internal attributes the Python way, predict what name mangling does to __name, and guard an attribute with a property and a validating setter.
vars(obj) shows the attributes stored on an object. What does this print?
class Vault:
def __init__(self):
self.__pin = "1234"
v = Vault()
print([name for name in vars(v)])
Practice · Activity 5 of 7
Match each name, written inside a class, to what it means.
Brain teaser · Activity 6 of 7
Brain teaser. Base and Child both set self.__id. What does this print?
class Base:
def __init__(self):
self.__id = "base"
def base_id(self):
return self.__id
class Child(Base):
def __init__(self):
super().__init__()
self.__id = "child"
def child_id(self):
return self.__id
c = Child()
print(c.base_id(), c.child_id())
Apply · Activity 7 of 7
Mini-task. Write a class Temperature with a celsius property. Its setter raises ValueError for values below -273.15; store the value in self._celsius. Add a read-only property fahrenheit, computed as celsius * 9 / 5 + 32. Temperature(25) gives 25 and 77.0; setting celsius to -300 is refused.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
A product with guarded price and stock
price is a property with a validating setter, so even the price passed to __init__ is checked and rounded. stock is a read-only property: the only way to change it is restock() and sell(), which keep the count consistent. The last line reads lamp._stock directly, to show that the underscore is a convention and not a lock.
main.py
class Product:
"""Price is checked on every change; stock changes only through methods."""
def __init__(self, name: str, price: float, stock: int = 0) -> None:
self.name = name
self.price = price # goes through the setter below
self._stock = stock
@property
def price(self) -> float:
return self._price
@price.setter
def price(self, value: float) -> None:
if value <= 0:
raise ValueError(f"price must be positive, got {value}")
self._price = round(value, 2)
@property
def stock(self) -> int:
"""Read-only: change it with restock() and sell()."""
return self._stock
def restock(self, amount: int) -> None:
self._stock += amount
def sell(self, amount: int) -> None:
if amount > self._stock:
raise ValueError(f"only {self._stock} {self.name} left")
self._stock -= amount
lamp = Product("lamp", 24.999, stock=3)
print(lamp.price, lamp.stock)
lamp.sell(2)
lamp.restock(5)
print(lamp.stock)
try:
lamp.price = -5
except ValueError as err:
print("refused:", err)
try:
lamp.stock = 99 # type: ignore[misc]
except AttributeError as err:
print("refused:", err)
try:
lamp.sell(10)
except ValueError as err:
print("refused:", err)
print(lamp.price, lamp._stock) # the underscore is a convention, not a lock
Run it with
python main.py
Output
25.0 3
6
refused: price must be positive, got -5
refused: property 'stock' of 'Product' object has no setter
refused: only 6 lamp left
25.0 6
24.999 became 25.0: the setter rounds, and __init__ went through it.
Assigning lamp.stock fails because stock has no setter.
mypy also rejects the assignment to a read-only property; the type: ignore comment lets the demonstration run past it.
lamp._stock works from outside: nothing is enforced, the underscore only asks.
Change it and run it
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.
Exercises
Exercise 1 of 2
A checked temperature
Turn celsius into a property. Its setter raises ValueError for values below ABSOLUTE_ZERO and stores valid values in self._celsius; Temperature(-300) must fail too. Add a property fahrenheit (celsius * 9 / 5 + 32) with a setter that converts back and sets celsius, so the same check applies: setting fahrenheit to 212 makes celsius 100.0.
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.
Hints
Hint 1
Write @property def celsius(self) that returns self._celsius, then @celsius.setter def celsius(self, value) that checks and stores.
Hint 2
Keep self.celsius = celsius in __init__: it now calls the setter, so Temperature(-300) is refused.
Hint 3
The fahrenheit setter converts and assigns self.celsius = (value - 32) * 5 / 9, which reuses the check.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
Rewrite SecureBox. Store the code as self.__code, so it is mangled. Count wrong tries in self._attempts and show them through a read-only property attempts. Add a read-only property locked, True after MAX_ATTEMPTS wrong tries. open(code) returns True for the right code, but always False once locked. A wrong code adds one attempt.
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.
Hints
Hint 1
In __init__, write self.__code = code and self._attempts = 0. Inside the class, keep using self.__code.
Hint 2
A read-only property is a @property without a setter: @property def attempts(self) -> int: return self._attempts.
Hint 3
In open, check self.locked first. Return True for the right code; otherwise add one to self._attempts and return False.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
from main import SecureBox
def test_opens():
"""The right code opens the box"""
assert SecureBox("4711").open("4711") is True, "open with the right code did not return True"
def test_counts_wrong_tries():
"""Wrong codes are counted"""
box = SecureBox("4711")
box.open("0000")
box.open("1234")
assert box.attempts == 2, f"after two wrong codes, attempts is {box.attempts!r}"
def test_locks():
"""After three wrong codes even the right one fails"""
box = SecureBox("4711")
for _ in range(3):
box.open("0000")
assert box.locked is True, "locked is not True after three wrong codes"
assert box.open("4711") is False, "a locked box opened with the right code"
def test_attempts_read_only():
"""attempts cannot be reset from outside"""
box = SecureBox("4711")
try:
box.attempts = 0 # type: ignore[misc]
except AttributeError:
pass
else:
assert False, "box.attempts = 0 was accepted; make attempts a property without a setter"
def test_code_is_mangled():
"""The code is stored as _SecureBox__code"""
names = sorted(vars(SecureBox("4711")))
assert names == ["_SecureBox__code", "_attempts"], f"the attributes on the object are {names!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.py
Run the checks (needs learnrun.py in the same folder):
class Account:
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
self.balance = value
acct = Account()
acct.balance = 10
What Python prints
RecursionError: maximum recursion depth exceeded
Why, and the fix
self.balance = value inside the setter calls the setter again, which calls it again, until Python gives up. The property is the public face; the value needs its own storage under another name. Write self._balance = value in the setter, and return self._balance in the getter. Try this one in your own Python: in the browser, the endless recursion exhausts the page's stack.
AttributeError: property 'area' of 'Circle' object has no setter
Why, and the fix
area has a getter only, so it cannot be assigned. That is usually what you want for a computed value: change the radius instead. If assigning really should work, add a method decorated with @area.setter that works out the new radius.
Reading a mangled name from outside
class Vault:
def __init__(self):
self.__pin = "1234"
print(Vault().__pin)
What Python prints
AttributeError: 'Vault' object has no attribute '__pin'
Why, and the fix
Only code inside the class body is rewritten, so outside the class __pin is looked up as it is written, and the object has no such attribute; it holds _Vault__pin. If outside code needs the value, provide a method or a read-only property. If it does not, this error is the point.
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.
Key ideas
Privacy is a convention
Python has no private attributes. A leading underscore, as in self._balance, is a signal to other programmers: this is internal, it may change, do not rely on it from outside. Nothing stops code from reading or changing it, but well-behaved code respects the signal. Public attributes are normal in Python. Start with a plain attribute, and only when you need control, turn it into a property: callers keep writing obj.balance and never notice the change.
Name mangling for __name
Inside a class body, a name with two leading underscores and at most one trailing underscore, such as self.__pin, is rewritten to _ClassName__pin. From outside, obj.__pin raises AttributeError, but obj._Account__pin still works, so this is not security. It exists to avoid clashes: a subclass that uses its own __pin gets _Sub__pin, and the two never overwrite each other. Names with two underscores at both ends, like __init__, are not mangled. A single underscore is never mangled either.
Properties: attributes that run code
@property turns a method into an attribute: reading acct.balance calls the method, with no parentheses. Add a setter, a second method of the same name decorated with @balance.setter, and assigning acct.balance = -5 runs it, for example to reject bad values. The value itself lives in an underscore attribute such as self._balance; writing self.balance inside the setter would call the setter again. A property without a setter is read-only, and assigning to it raises AttributeError. If __init__ assigns self.balance, even the first value goes through the check.