Customizing Attribute Access

Beginner

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you will learn about a fundamental aspect of Python's object-oriented programming: attribute access. Python enables developers to customize how attributes are accessed, set, and managed in classes through special methods. This offers powerful ways to control object behavior.

In addition, you will learn how to customize attribute access in Python classes, understand the difference between delegation and inheritance, and practice implementing custom attribute management in Python objects.

Understanding __setattr__ for Attribute Control

In Python, there are special methods that let you customize how attributes of an object are accessed and modified. One such important method is __setattr__(). This method comes into play every time you try to assign a value to an attribute of an object. It gives you the ability to have fine - grained control over the attribute assignment process.

What is __setattr__?

The __setattr__(self, name, value) method acts as an interceptor for all attribute assignments. When you write a simple assignment statement like obj.attr = value, Python doesn't just directly assign the value. Instead, it internally calls obj.__setattr__("attr", value). This mechanism provides you with the power to decide what should happen during the attribute assignment.

Let's now see a practical example of how we can use __setattr__ to restrict which attributes can be set on a class.

Step 1: Create a new file

First, open a new file in the WebIDE. You can do this by clicking on the "File" menu and then selecting "New File". Name this file restricted_stock.py and save it in the /home/labex/project directory. This file will contain the class definition where we'll use __setattr__ to control attribute assignment.

Step 2: Add code to restricted_stock.py

Add the following code to the restricted_stock.py file. This code defines a RestrictedStock class.

class RestrictedStock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    def __setattr__(self, name, value):
        ## Only allow specific attributes
        if name not in {'name', 'shares', 'price'}:
            raise AttributeError(f'Cannot set attribute {name}')

        ## If attribute is allowed, set it using the parent method
        super().__setattr__(name, value)

In the __init__ method, we initialize the object with name, shares, and price attributes. The __setattr__ method checks if the attribute name being assigned is in the set of allowed attributes (name, shares, price). If it's not, it raises an AttributeError. If the attribute is allowed, it uses the parent class's __setattr__ method to actually set the attribute.

Step 3: Create a test file

Create a new file called test_restricted.py and add the following code to it. This code will test the functionality of the RestrictedStock class.

from restricted_stock import RestrictedStock

## Create a new stock
stock = RestrictedStock('GOOG', 100, 490.1)

## Test accessing existing attributes
print(f"Name: {stock.name}")
print(f"Shares: {stock.shares}")
print(f"Price: {stock.price}")

## Test modifying an existing attribute
print("\nChanging shares to 75...")
stock.shares = 75
print(f"New shares value: {stock.shares}")

## Test setting an invalid attribute
try:
    print("\nTrying to set an invalid attribute 'share'...")
    stock.share = 50
except AttributeError as e:
    print(f"Error: {e}")

In this code, we first import the RestrictedStock class. Then we create an instance of the class. We test accessing existing attributes, modifying an existing attribute, and finally, we try to set an invalid attribute to see if the __setattr__ method works as expected.

Step 4: Run the test file

Open a terminal in the WebIDE and execute the following commands to run the test_restricted.py file:

cd /home/labex/project
python3 test_restricted.py

After running these commands, you should see output similar to this:

Name: GOOG
Shares: 100
Price: 490.1

Changing shares to 75...
New shares value: 75

Trying to set an invalid attribute 'share'...
Error: Cannot set attribute share

How It Works

The __setattr__ method in our RestrictedStock class works in the following steps:

  1. It first checks if the attribute name is in the allowed set (name, shares, price).
  2. If the attribute name is not in the allowed set, it raises an AttributeError. This prevents the assignment of unwanted attributes.
  3. If the attribute is allowed, it uses super().__setattr__() to actually set the attribute. This ensures that the normal attribute assignment process takes place for the allowed attributes.

This method is more flexible than using __slots__, which we saw in previous examples. While __slots__ can optimize memory usage and restrict attributes, it has limitations when working with inheritance and may conflict with other Python features. Our __setattr__ approach gives us similar control over attribute assignment without some of those limitations.

Creating Read-Only Objects with Proxies

In this step, we're going to explore proxy classes, a very useful pattern in Python. Proxy classes let you take an existing object and change how it behaves without altering its original code. This is like putting a special wrapper around an object to add new features or restrictions.

What is a Proxy?

A proxy is an object that stands between you and another object. It has the same set of functions and properties as the original object, but it can do extra things. For example, it can control who can access the object, keep a record of actions (logging), or add other useful features.

Let's create a read - only proxy. This kind of proxy will stop you from changing the attributes of an object.

Step 1: Create the Read - Only Proxy Class

First, we need to create a Python file that defines our read - only proxy.

  1. Navigate to the /home/labex/project directory.
  2. Create a new file named readonly_proxy.py in this directory.
  3. Open the readonly_proxy.py file and add the following code:
class ReadonlyProxy:
    def __init__(self, obj):
        ## Store the wrapped object directly in __dict__ to avoid triggering __setattr__
        self.__dict__['_obj'] = obj

    def __getattr__(self, name):
        ## Forward attribute access to the wrapped object
        return getattr(self._obj, name)

    def __setattr__(self, name, value):
        ## Block all attribute assignments
        raise AttributeError("Cannot modify a read-only object")

In this code, the ReadonlyProxy class is defined. The __init__ method stores the object we want to wrap. We use self.__dict__ to store it directly to avoid calling the __setattr__ method. The __getattr__ method is used when we try to access an attribute of the proxy. It simply passes the request to the wrapped object. The __setattr__ method is called when we try to change an attribute. It raises an error to prevent any changes.

Step 2: Create a Test File

Now, we'll create a test file to see how our read - only proxy works.

  1. Create a new file named test_readonly.py in the same /home/labex/project directory.
  2. Add the following code to the test_readonly.py file:
from stock import Stock
from readonly_proxy import ReadonlyProxy

## Create a normal Stock object
stock = Stock('AAPL', 100, 150.75)
print("Original stock object:")
print(f"Name: {stock.name}")
print(f"Shares: {stock.shares}")
print(f"Price: {stock.price}")
print(f"Cost: {stock.cost}")

## Modify the original stock object
stock.shares = 200
print(f"\nAfter modification - Shares: {stock.shares}")
print(f"After modification - Cost: {stock.cost}")

## Create a read-only proxy around the stock
readonly_stock = ReadonlyProxy(stock)
print("\nRead-only proxy object:")
print(f"Name: {readonly_stock.name}")
print(f"Shares: {readonly_stock.shares}")
print(f"Price: {readonly_stock.price}")
print(f"Cost: {readonly_stock.cost}")

## Try to modify the read-only proxy
try:
    print("\nAttempting to modify the read-only proxy...")
    readonly_stock.shares = 300
except AttributeError as e:
    print(f"Error: {e}")

## Show that the original object is unchanged
print(f"\nOriginal stock shares are still: {stock.shares}")

In this test code, we first create a normal Stock object and print its information. Then we modify one of its attributes and print the updated information. Next, we create a read - only proxy for the Stock object and print its information. Finally, we try to modify the read - only proxy and expect to get an error.

Step 3: Run the Test Script

After creating the proxy class and the test file, we need to run the test script to see the results.

  1. Open a terminal and navigate to the /home/labex/project directory using the following command:
cd /home/labex/project
  1. Run the test script using the following command:
python3 test_readonly.py

You should see output similar to:

Original stock object:
Name: AAPL
Shares: 100
Price: 150.75
Cost: 15075.0

After modification - Shares: 200
After modification - Cost: 30150.0

Read-only proxy object:
Name: AAPL
Shares: 200
Price: 150.75
Cost: 30150.0

Attempting to modify the read-only proxy...
Error: Cannot modify a read-only object

Original stock shares are still: 200

How the Proxy Works

The ReadonlyProxy class uses two special methods to achieve its read - only functionality:

  1. __getattr__(self, name): This method is called when Python can't find an attribute in the normal way. In our ReadonlyProxy class, we use the getattr() function to pass the attribute access request to the wrapped object. So, when you try to access an attribute of the proxy, it will actually get the attribute from the wrapped object.

  2. __setattr__(self, name, value): This method is called when you try to assign a value to an attribute. In our implementation, we raise an AttributeError to stop any changes from being made to the proxy's attributes.

  3. In the __init__ method, we directly modify self.__dict__ to store the wrapped object. This is important because if we used the normal way to assign the object, it would call the __setattr__ method, which would raise an error.

This proxy pattern allows us to add a read - only layer around any existing object without changing its original class. The proxy object acts just like the wrapped object, but it won't let you make any modifications.

Delegation as an Alternative to Inheritance

In object-oriented programming, reusing and extending code is a common task. There are two main ways to achieve this: inheritance and delegation.

Inheritance is a mechanism where a subclass inherits methods and attributes from a parent class. The subclass can choose to override some of these inherited methods to provide its own implementation.

Delegation, on the other hand, involves an object containing another object and forwarding specific method calls to it.

In this step, we will explore delegation as an alternative to inheritance. We'll implement a class that delegates some of its behavior to another object.

Setting Up a Delegation Example

First, we need to set up the base class that our delegating class will interact with.

  1. Create a new file called base_class.py in the /home/labex/project directory. This file will define a class named Spam with three methods: method_a, method_b, and method_c. Each method prints a message and returns a result. Here is the code to put in base_class.py:
class Spam:
    def method_a(self):
        print('Spam.method_a executed')
        return "Result from Spam.method_a"

    def method_b(self):
        print('Spam.method_b executed')
        return "Result from Spam.method_b"

    def method_c(self):
        print('Spam.method_c executed')
        return "Result from Spam.method_c"

Next, we'll create the delegating class.

  1. Create a new file called delegator.py. In this file, we'll define a class named DelegatingSpam that delegates some of its behavior to an instance of the Spam class.
from base_class import Spam

class DelegatingSpam:
    def __init__(self):
        ## Create an instance of Spam that we'll delegate to
        self._spam = Spam()

    def method_a(self):
        ## Override method_a but also call the original
        print('DelegatingSpam.method_a executed')
        result = self._spam.method_a()
        return f"Modified {result}"

    def method_c(self):
        ## Completely override method_c
        print('DelegatingSpam.method_c executed')
        return "Result from DelegatingSpam.method_c"

    def __getattr__(self, name):
        ## For any other attribute/method, delegate to self._spam
        print(f"Delegating {name} to the wrapped Spam object")
        return getattr(self._spam, name)

In the __init__ method, we create an instance of the Spam class. The method_a method overrides the original method but also calls the Spam class's method_a. The method_c method completely overrides the original method. The __getattr__ method is a special method in Python that is called when an attribute or method that doesn't exist in the DelegatingSpam class is accessed. It then delegates the call to the Spam instance.

Now, let's create a test file to verify our implementation.

  1. Create a test file named test_delegation.py. This file will create an instance of the DelegatingSpam class and call its methods.
from delegator import DelegatingSpam

## Create a delegating object
spam = DelegatingSpam()

print("Calling method_a (overridden with delegation):")
result_a = spam.method_a()
print(f"Result: {result_a}\n")

print("Calling method_b (not defined in DelegatingSpam, delegated):")
result_b = spam.method_b()
print(f"Result: {result_b}\n")

print("Calling method_c (completely overridden):")
result_c = spam.method_c()
print(f"Result: {result_c}\n")

## Try accessing a non-existent method
try:
    print("Calling non-existent method_d:")
    spam.method_d()
except AttributeError as e:
    print(f"Error: {e}")

Finally, we'll run the test script.

  1. Run the test script using the following commands in the terminal:
cd /home/labex/project
python3 test_delegation.py

You should see output similar to the following:

Calling method_a (overridden with delegation):
DelegatingSpam.method_a executed
Spam.method_a executed
Result: Modified Result from Spam.method_a

Calling method_b (not defined in DelegatingSpam, delegated):
Delegating method_b to the wrapped Spam object
Spam.method_b executed
Result: Result from Spam.method_b

Calling method_c (completely overridden):
DelegatingSpam.method_c executed
Result: Result from DelegatingSpam.method_c

Calling non-existent method_d:
Delegating method_d to the wrapped Spam object
Error: 'Spam' object has no attribute 'method_d'

Delegation vs. Inheritance

Now, let's compare delegation with traditional inheritance.

  1. Create a file called inheritance_example.py. In this file, we'll define a class named InheritingSpam that inherits from the Spam class.
from base_class import Spam

class InheritingSpam(Spam):
    def method_a(self):
        ## Override method_a but also call the parent method
        print('InheritingSpam.method_a executed')
        result = super().method_a()
        return f"Modified {result}"

    def method_c(self):
        ## Completely override method_c
        print('InheritingSpam.method_c executed')
        return "Result from InheritingSpam.method_c"

The InheritingSpam class overrides the method_a and method_c methods. In the method_a method, we use super() to call the parent class's method_a.

Next, we'll create a test file for the inheritance example.

  1. Create a test file named test_inheritance.py. This file will create an instance of the InheritingSpam class and call its methods.
from inheritance_example import InheritingSpam

## Create an inheriting object
spam = InheritingSpam()

print("Calling method_a (overridden with super call):")
result_a = spam.method_a()
print(f"Result: {result_a}\n")

print("Calling method_b (inherited from parent):")
result_b = spam.method_b()
print(f"Result: {result_b}\n")

print("Calling method_c (completely overridden):")
result_c = spam.method_c()
print(f"Result: {result_c}\n")

## Try accessing a non-existent method
try:
    print("Calling non-existent method_d:")
    spam.method_d()
except AttributeError as e:
    print(f"Error: {e}")

Finally, we'll run the inheritance test.

  1. Run the inheritance test using the following commands in the terminal:
cd /home/labex/project
python3 test_inheritance.py

You should see output similar to the following:

Calling method_a (overridden with super call):
InheritingSpam.method_a executed
Spam.method_a executed
Result: Modified Result from Spam.method_a

Calling method_b (inherited from parent):
Spam.method_b executed
Result: Result from Spam.method_b

Calling method_c (completely overridden):
InheritingSpam.method_c executed
Result: Result from InheritingSpam.method_c

Calling non-existent method_d:
Error: 'InheritingSpam' object has no attribute 'method_d'

Key Differences and Considerations

Let's look at the similarities and differences between delegation and inheritance.

  1. Method Override: Both delegation and inheritance allow you to override methods, but the syntax is different.

    • In delegation, you define your own method and decide whether to call the wrapped object's method.
    • In inheritance, you define your own method and use super() to call the parent's method.
  2. Method Access:

    • In delegation, undefined methods are forwarded via the __getattr__ method.
    • In inheritance, undefined methods are inherited automatically.
  3. Type Relationships:

    • With delegation, isinstance(delegating_spam, Spam) returns False because the DelegatingSpam object is not an instance of the Spam class.
    • With inheritance, isinstance(inheriting_spam, Spam) returns True because the InheritingSpam class inherits from the Spam class.
  4. Limitations: Delegation through __getattr__ doesn't work with special methods like __getitem__, __len__, etc. These methods would need to be explicitly defined in the delegating class.

Delegation is particularly useful in the following situations:

  • You want to customize an object's behavior without affecting its hierarchy.
  • You want to combine behaviors from multiple objects that don't share a common parent.
  • You need more flexibility than inheritance provides.

Inheritance is generally preferred when:

  • The "is-a" relationship is clear (e.g., a Car is a Vehicle).
  • You need to maintain type compatibility across your code.
  • Special methods need to be inherited.

Summary

In this lab, you have learned about powerful Python mechanisms for customizing attribute access and behavior. You explored how to use __setattr__ to control which attributes can be set on an object, enabling controlled access to object properties. Additionally, you implemented a read - only proxy to wrap existing objects, preventing modifications while preserving their functionality.

You also delved into the difference between delegation and inheritance for code reuse and customization. By using __getattr__, you learned to forward method calls to a wrapped object. These techniques offer flexible ways to control object behavior beyond standard inheritance, useful for creating controlled interfaces, implementing access restrictions, adding cross - cutting behaviors, and composing behavior from multiple sources. Understanding these patterns helps you write more maintainable and flexible Python code.