The __getattr__() method defines the behavior of an object when an attribute is not found in the object’s namespace.

When the interpreter fails to find an attribute in an object's namespace, it checks for a method called __getattr__() in the object’s class. If the method exists, the interpreter calls it and passes the attribute name as a string. The method can then decide how to handle the non-existent attribute. This happens when  using the dot notation or the builtin getattr() function to access  the attribute. 

The __getattr__() method allows for more flexibility in terms of how an attribute can be accessed. For example, the method could perform computations or look up the attribute in an alternate location, such as a database, rather than returning an AttributeError.

__getattr__() in Custom classes

We can define the __getattr__() method for custom objects with the following syntax:

class MyClass:
    def __getattr__(self, attr):
        #statements
self This is a compulsory arguments fo all instance methods. It references the current instance
attr The attribute to be handled in case it doesn't exist.
class Person: 
    def __init__(self,name):
        self.name = name
    def __getattr__(self,attr):
         if attr == "country": 
             return "Finland"
         else:
             raise AttributeError("Attribute not found")

p = Person("John")

print(p.name) 
print(p.country)

In the above case, an error would have been raised on an attempt to access the "country" attribute for a Person instance but we handle the error and return a default value instead, i.e Finland

Another example:

class MathObject: 
    def __init__(self, num1, num2): 
        self.num1 = num1
        self.num2 = num2
    def __getattr__(self, attr): 
        output_string = "{} {} {} = {}" #Define a placeholder string for ouputs

        if attr == 'sum':
            return output_string.format(self.num1, "+" ,self.num2,  self.num1 + self.num2)
            
        elif attr == 'difference':
            return  output_string.format(self.num1, "-", self.num2, self.num1 - self.num2)
        elif attr == 'product':
            return  output_string.format(self.num1, "x", self.num2, self.num1 * self.num2)
        elif attr == 'quotient': 
            return  output_string.format(self.num1, "/", self.num2,  self.num1 / self.num2)
        else:
            return 'Invalid Operation'

math_obj = MathObject(20, 10)

#Call the object with various attributes
print(math_obj.sum)
print(math_obj.difference)
print(getattr(math_obj, "product"))
print(getattr(math_obj, "quotient"))

print(math_obj.exp)

Note: While the __getattr__() function allows us to  dynamically create attributes and handle the AttributeError exception when an attribute is not found,  It should only be used when absolutely necessary and not as  a replacement for normal attribute lookups. The best practice is to always use the normal attribute lookup, as it is more efficient and less error prone.   

The difference between __getattr__ and __getattribute__ methods

Python also contains a dunder method called __getattribute__(). It may seem confusing at first due to the resemblance with the __getattr__() method but the two methods have a different but related roles. 

The primary difference between the two is that the __getattr__() method is only called when an attribute doesn’t exist, while the __getattribute__() method is always called, regardless of whether or not an attribute exists. Therefore, the __getattribute__() method takes precedence over the __getattr__() method when an attribute is present.

You can see more about the __getattribute__() method here.