Global training solutions for engineers creating the world's electronics products

Python Magic Methods


Copyright 2020 Doulos Ltd

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.


There is an executable version of this document available on Google Colab, allowing you to run every Python code fragment from this document, online, without having to install Python on your computer. Click here to register and gain access.


Attributes of built-in object types

Every Python object has a collection of built-in attributes, which can be listed using the built-in function dir. In effect, these are all the things you can do with the given object. In this example, the object is an integer.

v = 1
dir(v)
The attributes with names like __abs__ are mostly built-in methods (some are values), which can be called or overridden. These are sometimes known as dunders, which stands for double-underscores, and are also known as magic methods. Although these built-in methods can be called directly, it is more usual to call them using the regular Python language syntax or using built-in functions and operators. In other words, dunders provide a way to override the behavior of built-in functions and operators for a particular class.
Calling the method __abs__ directly is possible, but it not recommended:
v = -1
v.__abs__()
Instead the dunder method __abs__ would normally be called indirectly by calling the built-in function abs:
abs(v)
The same would go for __add__, which takes two operands:
v = 2
v.__add__(3)

The normal way to call _add__ is using the + operator.

v + 3

There are also non-dunder attributes which are functions that are intended to be called directly. For example, the built-in attribute bit_length, which is a Python function:

v = 0b10011001
v.bit_length
v.bit_length()

Duck typing

Python uses duck typing, which takes its name from the following test in the field of logical reasoning: "If it walks like a duck, swims like a duck, and quacks like a duck, it is a duck." In the context of programming languages, this means that any object with the appropriate methods can be used in a context that calls only those methods. In other words, a Python object does not have a type in the sense of statically typed programming languages such as C, C++, and Java, but rather, the places where an object can be used is determined solely by the methods it provides.
Consider this function. do_it can be called with any object that has methods do_this and do_that.
def do_it(arg):
    arg.do_this()
    arg.do_that()
So let's make such an object.
class C:
    def do_this(self):
        print('do_this from C')
    def do_that(self):
        print('do_that from C')
    def do_c(self):
        print('do_c')
    
obj_c = C()
We can now pass our object as an argument to do_it.
do_it(obj_c)
Or, we could pass an object of a different, unrelated class, provided only that it has the two methods called by do_it.
class D:
    def do_this(self):
        print('do_this from D')
    def do_that(self):
        print('do_that from D')
    def do_d(self):
        print('do_d')
    
obj_d = D()

do_it(obj_d)
If we really wanted to restrict the type of the object passed to do_it, we would have to add an explicit check.
def do_it(arg):
    assert isinstance(arg, C)
    arg.do_this()
    arg.do_that()

try:
    do_it(obj_d)
except AssertionError as details:
    print('Wrong argument type', details)

Overriding built-in Dunders or Magic Methods

It is possible to override the behavior of any built-in Python function or operator by overriding the appropriate magic method. Here we illustrate this by overriding the magic methods __eq__ and __add__ to redefine the == and + operators to work on a user-defined class, but the same principle holds for any built-in operator. Note that __eq__ returns a Boolean, whereas __add__ returns an instance object of class C.
class C:
    def __init__(self, value):
        self.value = value
    def __eq__(self, other):
        print(f'Test for equality, '
              f'self={self.value}, other={other.value}')
        return self.value == other.value
    def __add__(self, other):
        return C(self.value + other.value)
obj1 = C(5)
obj2 = C(5)
obj3 = C(10)
assert obj1 == obj2
assert not obj2 == obj3
The != operator also calls __eq__
assert obj2 != obj3
The is operator does not call __eq__, however. is tests whether two variables are bound to the same object.
assert obj1 is not obj2
Now we'll check that the + operator behaves as expected.
assert obj1 + obj2 == obj3
assert C(1) + C(2) == C(3)
assert C(-4) + C(4) == C(0)
The behavior of almost any built-in function or operator can be defined using a magic method. As another example, here is __round__ being used to define the behaviour of the built-in function round for a user-defined class.
class C:
    def __init__(self, value):
        assert isinstance(value, float)
        self.value = value
    def __round__(self, n):
        return C(round(self.value, n))
    def get(self):
        return self.value
obj = C(1.9788)
obj2 = round(obj, 2)
obj2.get()
But we could not directly convert an instance object of class C to an int. To do that, we would first need to define the magic method __int__.
try:
    int(obj)
except TypeError as details:
    print(details)
class C:
    def __init__(self, value):
        assert isinstance(value, float)
        self.value = value
    def __int__(self):
        return int(self.value)
obj = C(1.9788)
int(obj)

Defining a Class/Object that can be Called as a Function

Functions are callable. By default, objects are not.

def f():
    pass

f()
class C:
    pass

obj = C()

try:
    obj()
except TypeError as details:
    print(details)
By overriding the appropriate magic method, any Python object can be called as a function, that is, object_name() or object_name(arguments). To allow an object to be callable as a function, define the magic method __call__ in the class, defining any arguments to that function in the usual way. So, in a sense, a Python function is just an ordinary instance object that happens to have the method __call__ defined.
class C:
    #def __init__(self):
    #    print('__init__')
    #    self.value = 0
    def __call__(self, arg1=None, arg2=None):
        print(f'__call__ with arg1={arg1}, arg2={arg2}')
        self.value = arg1
        return self.value
obj = C()
obj()
obj(1, 2)
There are two simple, equivalent ways to test whether a given object is callable as a function: the built-in function callable, and the existence of the __call__ method (hasattr is discussed below).
callable(C)
callable(obj)
hasattr(obj, "__call__")
callable(lambda x: x)
callable(99)

Defining a Class/Object that can be used as an Iterator

An iterator is an object that defines a __next__ method. An iterable is an object that defines an __iter__ method which returns an iterator. An iterable is often itself an iterator, although it does not have to be. The build-in function iter can be used to test whether an object is iterable.

An integer value such as 4 is not iterable.

try:
    for i in 4:
        pass
except TypeError as details:
    print(details)
'int' object is not iterable
range(4), on the other hand, is iterable.
for i in range(4):
    pass
Although range(4) is iterable, range(4) is not itself an iterator.
try:
    next(range(4))
except TypeError as details:
    print(details)    
'range' object is not an iterator
range(4) will return an iterator, however.
it = iter(range(4))
next(it)
An iterable object should define the magic methods __iter__ and __next__. The __iter__ method should return an object that has a __next__ method. The __next__ method should return the next object, or raise StopException if there are no more objects.
class C_iter:
    def __init__(self, values):
        assert isinstance(values, list)
        self.index = 0
        self.values = values
    def __iter__(self):
        return self
    def __next__(self):
        next_obj = None
        while (next_obj is None):
            if self.index >= len(self.values):
                raise StopIteration
            next_obj = self.values[self.index]
            self.index += 1
        print("C_iter returns " + str(next_obj))
        return next_obj
obj = C_iter([1, 2, 3])
for i in obj:
    print(i)
Replacing the while with a for loop shows the call to next and the StopIteration more explicitly
obj = C_iter([1, 2, 3])
try:
    while(True):
        print(next(obj))
except StopIteration:
    print('Done!')

Customizing the Behavior of Module copy

The standard library module copy provides functions for taking shallow copies and deep copies of objects.
class Copyable():
    def __init__(self):
        self.data = [1, 2, [3, 4, 5]]
import copy

obj = Copyable()
print(f"obj.data = {obj.data}")

shallow_copy = copy.copy(obj)
deep_copy = copy.deepcopy(obj)

assert shallow_copy.data == [1, 2, [3, 4, 5]]
assert shallow_copy is not obj
assert shallow_copy.data[2] is obj.data[2]

assert deep_copy.data == [1, 2, [3, 4, 5]]
assert deep_copy is not obj
assert deep_copy.data[2] is not obj.data[2]
It is possible to customize shallow and deep copy operations for a user-defined class by overriding the __copy__() and __deepcopy__() magic methods of the class. This would give a way to disable the copy operations, for example.
import warnings 

class Uncopyable():
    def __init__(self):
        self.data = [1, 2, [3, 4, 5]]
    def __copy__(self):
        warnings.warn("Shallow copy disabled, returning a reference instead")
        return self
    def __deepcopy__(self, memo):
        warnings.warn("Deep copy disabled, returning a reference instead")
        return self
obj = Uncopyable()
print(f"obj.data = {obj.data}")

shallow_copy = copy.copy(obj)
deep_copy = copy.deepcopy(obj)

assert shallow_copy is obj
assert deep_copy is obj

Defining a Class/Object that Behaves like a Sequence

Python lists, tuples, ranges, and strings are examples of Python sequences. All sequences suppport a common set of sequence operations:
s = [1, 2, 3, 3]
3 in s   # Membership
s + s   # Concatenation
s * 3   # Concatenating a sequence with itself N times
s[0]   # Indexing
s[1:3]  # Slicing
len(s)  # Length (number of items)
max(s)  # Largest item
s.index(2)  # Index of first occurrence of an item
s.count(3)  # Number of occurrences of an item
s[2] = 0   # Assignment to an item
s
s[1:3] = [5, 6]   # Assignment to a slice
s
del s[1]  # Deleting an item or slice
s
s.append(4)
s
s.insert(1, 2)
s
s.reverse()
s
s.pop()
s
s.clear()
s
The same set of operations are not defined for a user-defined class by default.
class C:
    def __init__(self, data):
        self.data = data;
        
obj = C("abcde")

try:
    obj[1]
except TypeError as details:
    print(details)
Any user-defined object can be made to behave like a sequence by defining the appropriate methods (magic or otherwise), although it would be tedious and often unnecessary to define all the methods of a sequence in a user-defined class. Perhaps the most useful are the magic methods to define the index operator [], the len function, and to make a class iterable. Let's start with a very simple example that just implements the index operator [] to get a value from a sequence-like object:
class C:
    def __init__(self, data):
        self.data = data;
    def __getitem__(self, index):
        return self.data[index]
obj1 = C("abcde")
assert obj1[1] == 'b'
assert 'd' in obj1
obj2 = C([1, 2, 3])
assert obj2[2] == 3
assert 3 in obj2
As things stand, [] can only be used to return a value, not for assignment:
try:
    obj2[2] = 4
except TypeError as details:
    print(details)
Nor can the built-in len function be called on the object:
try:
    assert len(obj2) == 3
except TypeError as details:
    print(details)
To allow [] to be used for item assignment, define the magic method __setitem__. To allow len to be called, define the magic method __len__.
class C:
    def __init__(self, data):
        self.data = data;
    def __getitem__(self, index):
        return self.data[index]
    def __setitem__(self, index, value):
        self.data[index] = value
    def __len__(self):
        return len(self.data)
obj3 = C([1, 2, 3])
obj3[0] = 0
obj3[2] = 4

assert obj3[0] == 0 and obj3[2] == 4
assert len(obj3) == 3
In some cases we might want to add an explicit check that the index in within bounds.
class C:
    def __init__(self, size):
        self.size = size
        self.data = [None] * size;
    def check_key(self, key):
        if key < 0 or key >= self.size:
            raise KeyError(f'key={key} out of range [0:{self.size}]')
    def __getitem__(self, key):
        self.check_key(key)
        return self.data[key]
    def __setitem__(self, key, value):
        self.check_key(key)
        self.data[key] = value
obj4 = C(4)
try:
    obj4[6] = 1
except KeyError as details:
    print(details)
We might want our class to support slices, but unfortunately, as the code is currently written, check_key assumes the index to be an integer.
obj4 = C(4)
try:
    obj4[1:3] = [1, 2]
except TypeError as details:
    print(details)
We can fix this by explicitly testing whether the key is an integer or a slice, and acting accordingly. A slice is itself an object with attributes start, stop, and step. There are situations where creating an explicit slice object can make the code easier to maintain. As an aside, here is an example of a slice object.
indices = slice(1, 6, 2)

assert indices.start == 1
assert indices.stop == 6
assert indices.step == 2
assert isinstance(indices, slice)

data = [i for i in range(10)]
assert data[indices] == [1, 3, 5]
Now testing for a slice in the check_key method of our class.
class C:
    def __init__(self, size):
        self.size = size
        self.data = [None] * size;
    def check_key(self, key):
        msg = f'key={key} out of range [0:{self.size}]'
        if isinstance(key, int):
            if key < 0 or key >= self.size:
                raise KeyError(msg)
        elif isinstance(key, slice):
            if key.start < 0 or key.stop > self.size:
                raise KeyError(msg)
        else:
             raise KeyError(msg)
    def __getitem__(self, key):
        self.check_key(key)
        return self.data[key]
    def __setitem__(self, key, value):
        self.check_key(key)
        self.data[key] = value
It becomes possible to make slice assignments and to read slices on our instance object.
obj5 = C(4)
obj5[0:4] = [1, 2, 3, 4]
assert obj5[1:3] == [2, 3]
As things stand, we cannot call any of the built-in functions that require an iterable as an argument on our instance object because iterating through the items in the object will eventually cause the index to go out-of-range.
try:
    [item for item in obj5]
except Exception as details:
    print(details)
'key=4 out of range [0:4]'
To make an instance object iterable, it is necessary to define the magic method __iter__ in the class.
class C:
    def __init__(self, size):
        self.size = size
        self.data = [None] * size;
    def check_key(self, key):
        msg = f'key={key} out of range'
        if isinstance(key, int):
            if key < 0 or key >= self.size:
                raise KeyError(msg)
        elif isinstance(key, slice):
            if key.start < 0 or key.stop > self.size:
                raise KeyError(msg)
        else:
             raise KeyError(msg)
    def __getitem__(self, key):
        self.check_key(key)
        return self.data[key]
    def __setitem__(self, key, value):
        self.check_key(key)
        self.data[key] = value
    def __iter__(self):
        for i in self.data:
            if i is not None:
                yield i
obj5 = C(4)
obj5[0:4] = [0, 3, 5, 2]
This opens up a lot of possibilities. With __iter__ defined, we can call any of the built-in functions that take an iterable argument.
list(obj5)
tuple(obj5)
set(obj5)
all(obj5)
any(obj5)
min(obj5)
max(obj5)
sum(obj5)
sorted(obj5)
[item for item in obj5]
for i, item in enumerate(obj5):
    print(i, item, end=', ')
tuple(filter(lambda x: x > 1, obj5))
tuple(map(lambda x: -x, obj5))
tuple(zip(obj5, obj5))
The built-in function reversed requires either both __len__ and __getitem__ to be defined or __reversed__ to be defined.
try:
    reversed(obj5)
except Exception as details:
    print(details)
class C:
    def __init__(self, size):
        self.size = size
        self.data = [None] * size
    def __getitem__(self, key):
        return self.data[key]
    def __setitem__(self, key, value):
        self.data[key] = value
    def __iter__(self):
        for i in self.data:
            if i is not None:
                yield i
    def __reversed__(self):
        for i in self.data[::-1]:
            if i is not None:
                yield i
obj6 = C(4)
obj6[0:4] = [1, 2, 3, 4]

assert list(reversed(obj6)) == [4, 3, 2, 1]
If you want your object to be even more sequence-like, you can define magic methods for concatenation, deletion, and so on. You have a choice as to exactly which methods and magic methods you define, depending on the functionality you want to provide. Here we define __add__ and __mul__ to support concatenation and repeated concatenation, respectively, and __delitem__ to support item deletion.
class C:
    def __init__(self, size):
        self.size = size
        self.data = [None] * size
    def check_key(self, key):
        msg = f'key={key} out of range'
        if isinstance(key, int):
            if key < 0 or key >= self.size:
                raise KeyError(msg)
        elif isinstance(key, slice):
            if key.start < 0 or key.stop > self.size:
                raise KeyError(msg)
        else:
             raise KeyError(msg)
    def __getitem__(self, key):
        self.check_key(key)
        return self.data[key]
    def __setitem__(self, key, value):
        self.check_key(key)
        self.data[key] = value
    def __delitem__(self, key):
        self.check_key(key)
        del self.data[key]
    def __iter__(self):
        for i in self.data:
            if i is not None:
                yield i
    def __add__(self, other):
        assert isinstance(other, C)
        tmp = C(0)
        tmp.data.extend(self.data)
        tmp.data.extend(other.data)
        tmp.size = self.size + other.size
        return tmp
    def __mul__(self, n):
        assert isinstance(n, int)
        tmp = C(0)
        for i in range(n):
            tmp.data.extend(self.data)
        tmp.size = self.size * n
        return tmp
obj7 = C(3)
obj7[0:3] = [1, 2, 3]

assert list(obj7 + obj7) == [1, 2, 3, 1, 2, 3]  
assert list(obj7 * 3) == [1, 2, 3, 1, 2, 3, 1, 2, 3]

del obj7[1]
assert list(obj7) == [1, 3]
The magic methods __str__ and __repr__ define the behavior of the built-in functions str and repr, respectively. Both return human-readable text strings describing the value of the object. The difference is that str is meant to be human-readable, whereas repr is meant to be precise. In many cases, the value returned by repr can be passed to eval to reconstruct the original object.

If neither __str__ nor __repr__ are defined, the default is a fairly useless string that contains just the class name and object address.

class C:
    def __init__(self, value):
        self.value = value
        
obj8 = C(0)
str(obj8)
repr(obj8)
With both __str__ and __repr__ defined, the appropriate method will be called according to context. __str__ is called from print by default whenever an argument to print is not a string. __repr__ is call when an object is evaluated and printed from the Python command shell (or Jupyter).
class C:
    def __init__(self, value):
        self.value = value
    def __str__(self):
        return f'{self.value}'
    def __repr__(self):
        return f'C({self.value})'
    
obj9 = C(9)

print(obj9)
print(str(obj9))
print(repr(obj9))
obj9
In the absense of __str__, the value of __repr__ is used.
class C:
    def __init__(self, value):
        self.value = value
    def __repr__(self):
        return f'C({self.value})'
obj = C(0)
str(obj)
Now putting everything together in one class:
class C:
    def __init__(self, size):
        self.size = size
        self.data = [None] * size
    def check_key(self, key):
        msg = f'key={key} out of range'
        if isinstance(key, int):
            if key < 0 or key >= self.size:
                raise KeyError(msg)
        elif isinstance(key, slice):
            if key.start < 0 or key.stop > self.size:
                raise KeyError(msg)
        else:
             raise KeyError(msg)
    def __getitem__(self, key):
        self.check_key(key)
        return self.data[key]
    def __setitem__(self, key, value):
        self.check_key(key)
        self.data[key] = value
    def __delitem__(self, key):
        self.check_key(key)
        del self.data[key]
    def __len__(self):
        return self.size
    def __iter__(self):
        for i in self.data:
            if i is not None:
                yield i
    def __reversed__(self):
        for i in self.data[::-1]:
            if i is not None:
                yield i
    def __add__(self, other):
        assert isinstance(other, C)
        tmp = C(0)
        tmp.data.extend(self.data)
        tmp.data.extend(other.data)
        tmp.size = self.size + other.size
        return tmp
    def __mul__(self, n):
        assert isinstance(n, int)
        tmp = C(0)
        for i in range(n):
            tmp.data.extend(self.data)
        tmp.size = self.size * n
        return tmp
    def __repr__(self):
        txt = '['
        for i in self.data:
            txt += str(i)
            txt += ', '
        txt = txt[:-2]
        txt += ']'
        return txt
obj = C(4)
obj[0:4] = [1, 2, 3, 4]

assert len(obj) == 4
assert sum(obj) == 10
assert list(obj) == [1, 2, 3, 4]
assert list(reversed(obj)) == [4, 3, 2, 1]
assert list(obj + obj) == [1, 2, 3, 4, 1, 2, 3, 4]
assert list(obj * 2) == [1, 2, 3, 4, 1, 2, 3, 4]
assert str(obj) == '[1, 2, 3, 4]'

obj

Defining a Context Manager

A content manager is an object that is used with the Python with syntax. It should define __enter__ and __exit__ methods, which are called at the top and bottom of the with construct, respectively. The __enter__ method is for running setup code, and the __exit__ method for running tear-down code. The context manager guarantees that the setup and tear-down code will be executed, meaning that any resources needed by the code inside the with will be allocated at the top and deallocated at the bottom, even if exceptions are raised inside the with construct.

One of the most common use cases for a context manager is to open and close files. The built-in open function returns a context manager as well as opening a file. The __enter__ method of that context manager returns the file object just opened. The __exit__ method closes the file.

%%writefile foo.txt
abc
123
with open('foo.txt') as f:
    for line in f.read().splitlines():
        print(line)
Let's try opening a non-existent file.
try:
    with open('nonexistentfile.txt') as f:
        pass
except Exception as details:
    print(details)
class CM:
    def __init__(self, obj):
        print('Context manager created with argument of type', type(obj))
        self.obj = obj
    def __enter__(self):
        print('Context manager setup code')
        try:
             self.obj.open()
        except AttributeError:
            print('Object cannot be opened')
        return self.obj
    def __exit__(self, exception_type, exception_val, trace):
        print('Context manager tear-down code')
        try:
            self.obj.close()
        except AttributeError:
            print('Object cannot be closed')
        return False            
The following class is separate from the context manager, but models a resource to be opened (allocated) and closed (deallocated) by the context manager in this particular example.
class Helper:
    def open(self):
        print('Opening object of class', Helper.__name__)
    def close(self):
        print('Closing object of class', Helper.__name__)
    def printme(self):
        print('Helper.print called')
The messages printed below show the context manager being created, the __enter__ method being called, and the __exit__ method being called.
with CM(Helper()):
    pass
The same tear-down code is executed, even if the context manager raises an exception. The __exit__ method may suppress the exception by returning the value True, otherwise the exception will be processed normally, that is, passed back up the call stack to be handled by any enclosing try block, or ultimately by the system.
try:
    with CM(Helper()):
        1. / 0.
except ZeroDivisionError as details:
    print(details)
Here is the same example repeated but with the __exit__ method returning True so that the floating division by zero exception raised within the with block is suppressed.
class CMsilent(CM):
    def __exit__(self, exception_type, exception_val, trace):
        super().__exit__(exception_type, exception_val, trace)
        return True
with CMsilent(Helper()):
    1. / 0.
As an alterative to with, the with as syntax binds the given variable to the object returned from the __enter__ method. The variable is then available inside the with block.
with CM(Helper()) as x:
    x.printme()
Now, instead of passing an instance object the helper class C to the context manager, we pass an integer, which, of course, does not provide the open and close methods required by our particular context manager.
with CM(0):
    pass

Attributes of user-defined objects

Both built-in and user-defined objects have attributes. The int object is built into Python, so its attributes are fixed:

v = -7
type(v)
But given a user-defined object, it becomes possible to add user-defined attributes to that object.

We start with an empty class:

class C:
    pass
Create an instance object
obj = C()
Even a naked instance object of a user-defined class such as this comes with certain built-in attributes:
dir(obj)
We can now start adding user-defined attributes to the object by calling the built-in function setattr, or by assignment. The two are equivalent:
setattr(obj, "foo1", 11)
obj.foo2 = 22
foo and foo2 now appear in the directory of attributes:
dir(obj)
We can get the value of a specific attributes and test for the existence of a specific attribute by name, whether built-in or user-defined. First, the user-defined attribute:
getattr(obj, "foo2")
obj.foo1

Now a built-in attribute:

getattr(obj, "__class__")
hasattr(obj, "foo1")
hasattr(obj, "foo2")
hasattr(obj, "foobar")

Naturally, we can delete attributes too:

delattr(obj, "foo1")
delattr(obj, "foo2")
hasattr(obj, "foo1")

Managed attributes

The normal practice in object-oriented programming (OOP) is to access the values of attributes using so-called getter and setter methods. The built-in function property gives a way to define managed attributes, that is, attributes of a class which, although they have user-defined getter, setter, and deleter methods, can be accessed directly without explicitly calling those methods. These user-defined methods will be called automatically whenever the attribute is accessed. This makes it possible to execute arbitrary user-defined code whenever an attribute is accessed; the user-defined methods do not need to be called explicitly. This facility could be used to ensure that the values of attributes are kept mutually consistent or to implement side-effects whenever an attribute is accessed.

The getter, setter, and deleter methods are first defined (as normal methods) and are then passed as arguments to the built-in property function, which returns the managed attribute:

class C:
    
    def __init__(self):
        self._x = None
        
    def getx(self):
        print('C.getx() returns', self._x)
        return self._x
    
    def setx(self, value):
        if not isinstance(value, int):
            raise TypeError('Expecting an int')
        if value < 0:
            raise ValueError('Expecting a non-negative int')
        print('C.setx(', value, ')', sep='')
        self._x = value
        
    def delx(self):
        print('C.delx()')
        del self._x
        
    x = property(getx, setx, delx, "I'm the 'x' property.")
    
    # property() is implemented using a data descriptor (the descriptor protocol)
c = C()
An assignment to attribute x will implicitly call setx.
c.x = 99
Accessing the value of attribute x will implicitly call getx.
assert c.x == 99
We can call hasattr and getattr.
assert hasattr(c, 'x')
assert getattr(c, 'x') == 99
Deleting the attribute will implicitly call delx.
del c.x
assert not hasattr(c, 'x')
Rather than calling the property function, it is possible to accomplish the same thing using Python decorators. This is no more than a syntactical convenience. The decorator @property, applied to the first getter/setter method, implicitly marks that method as a getter.
class C2:
    def __init__(self):
        self._x = None
        
    @property
    def x(self):
        print('C2.getx() returns', self._x)
        return self._x
    
    @x.setter
    def x(self, value):
        if not isinstance(value, int):
            raise TypeError('Expecting an int')
        if value < 0:
            raise ValueError('Expecting a non-negative int')
        print('C2.setx(', value, ')', sep='')
        self._x = value
        
    @x.deleter
    def x(self):
        print('C2.delx()')
        del self._x   
c = C2()
c.x = 99
assert c.x == 99
del c.x
assert not hasattr(c, 'x')

THE END