Python Arena
Step 3 of 4

Setters that check the value

To let the client change a property with an ordinary assignment, add a setter: a second method with the same name, with the decorator @name.setter above it. The setter gets the new value as a parameter, so it can check the value before storing it:

class Thermostat:
    def __init__(self):
        self.__target = 20

    @property
    def target(self):
        return self.__target

    @target.setter
    def target(self, degrees):
        if 5 <= degrees <= 30:
            self.__target = degrees


heater = Thermostat()
heater.target = 23
print(heater.target)
heater.target = 45
print(heater.target)
Output
23
23

For the client, heater.target = 23 looks just like assigning to an attribute. Behind the scenes the setter runs, and it quietly ignores 45.

The getter has to come first. @property creates the property called target, and @target.setter then adds a setter to it. With the setter first, Python stops at the decorator with NameError: name 'target' is not defined, because no target exists yet.

Saying no out loud

Ignoring a bad value hides the mistake: the client thinks the heater is set to 45. It's usually better to raise an exception, as you did in part 6. A ValueError is the usual one for a value that isn't acceptable:

class Thermostat:
    def __init__(self):
        self.__target = 20

    @property
    def target(self):
        return self.__target

    @target.setter
    def target(self, degrees):
        if degrees < 5 or degrees > 30:
            raise ValueError("The target must be from 5 to 30 degrees")
        self.__target = degrees


heater = Thermostat()
heater.target = 23
print(heater.target)
heater.target = 45
Output
23
Python stops with
Traceback (most recent call last):
  File "main.py", line 19, in <module>
    heater.target = 45
    ^^^^^^^^^^^^^
  File "main.py", line 12, in target
    raise ValueError("The target must be from 5 to 30 degrees")
ValueError: The target must be from 5 to 30 degrees

The client can catch the error with try and except ValueError and decide what to do, for example ask the user again.

The setter in __init__

The value given to the constructor needs the same check. Instead of writing the check twice, let __init__ use the setter: self.target = degrees, without the underscores, assigns through the property and so runs the setter.

class Thermostat:
    def __init__(self, degrees):
        self.target = degrees  # runs the setter, which checks the value

Now Thermostat(45) raises the same ValueError, and no thermostat can ever start with a bad target.

One trap: inside the setter, store the value in the private attribute, self.__target = degrees. Writing self.target = degrees there would call the setter again, which would call itself again, and so on, until Python stops with a RecursionError.

Your task

Challenge 1 of 3

A product's price must never be negative. Fill in the blanks so that price has a setter that raises a ValueError, and the program prints:

35
Error: The price can't be negative
35

Challenge 2

Write the class Book. Its constructor takes a title and a rating:

  • The title is read-only: a property title with a getter only.
  • The rating is a property rating with a getter and a setter. A rating must be a number from 1 to 5; for any other value the setter raises ValueError("The rating must be from 1 to 5").
  • The constructor must check the rating too, so it should use the setter.

For example, this code:

book = Book("Small Gods", 4)
book.rating = 5
print(book.title, book.rating)
try:
    book.rating = 9
except ValueError as error:
    print(error)
print(book.rating)

prints:

Small Gods 5
The rating must be from 1 to 5
5

Challenge 3

Write the class Temperature. It stores degrees Celsius in a private attribute, and its constructor takes degrees Celsius. It has two properties, each with a getter and a setter:

  • celsius: nothing can be colder than -273.15 degrees, so for a lower value the setter raises ValueError("Below absolute zero"). The constructor uses this setter too.
  • fahrenheit: the getter returns celsius * 9 / 5 + 32. The setter turns the Fahrenheit value it gets into Celsius, (fahrenheit - 32) * 5 / 9, and stores it through the celsius setter, so the same check applies.

For example, this code:

water = Temperature(25)
print(water.fahrenheit)
water.fahrenheit = 212
print(water.celsius)
try:
    water.celsius = -300
except ValueError as error:
    print(error)

prints:

77.0
100.0
Below absolute zero