Skip to Content
Course content

197: Dynamic Attribute Access with __getattr__ and __setattr__

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been working on a project where I have to pull a massive set of configuration settings from a JSON file. Right now, the code is littered with things like config['database']['connection']['timeout']. It's clunky, it's hard to read, and honestly, it's a pain to type. I want to be able to just type config.timeout and have Python figure out where that lives.

The frustration of bracket notation

Let's try to build a simple wrapper. I'll start with a class that holds a dictionary, and I'll see if I can make it feel more like a native object. Here is where I'm starting:

class ConfigWrapper:
    def __init__(self, data):
        self.data = data

settings = ConfigWrapper({'api_key': 'SECRET_123', 'port': 8080})
print(settings.api_key)

If you run this, it crashes immediately with an AttributeError. That makes sense—the ConfigWrapper instance doesn't have an attribute called api_key; it has an attribute called data, which happens to be a dictionary containing that key. I could manually map every key to an attribute in __init__, but that's tedious and fails if the configuration changes dynamically.

Catching the "Missing" attribute

This is where __getattr__ comes in. The trick here is that Python only calls __getattr__ as a last resort. If the attribute exists normally (like self.data), Python uses it. If it doesn't, it asks __getattr__: "Hey, do you know how to handle this?"

Let's adjust the class:

class ConfigWrapper:
    def __init__(self, data):
        self.data = data

    def __getattr__(self, name):
        print(f"Looking up {name} in the dictionary...")
        if name in self.data:
            return self.data[name]
        raise AttributeError(f"Config has no setting: {name}")

settings = ConfigWrapper({'api_key': 'SECRET_123', 'port': 8080})
print(settings.api_key) # This works now!
print(settings.data)    # This still works and doesn't trigger __getattr__

Notice that when I call settings.data, the print statement inside __getattr__ doesn't fire. Python found data in the instance's actual dictionary, so it never bothered asking the fallback method. But when I ask for api_key, it hits that fallback and pulls the value from the internal dictionary. It's a clean way to create a proxy.

The infinite loop of doom

Now, I want to be able to update these settings. I don't want to do settings.data['port'] = 9000; I want settings.port = 9000. Naturally, I think of __setattr__. But __setattr__ is different from __getattr__: it is called every single time an attribute is assigned, regardless of whether it already exists.

I'll try the obvious approach:

class ConfigWrapper:
    def __init__(self, data):
        self.data = data

    def __setattr__(self, name, value):
        print(f"Setting {name} to {value}")
        self.data[name] = value

settings = ConfigWrapper({'port': 8080})
settings.port = 9000

If you run this, your program will crash with a RecursionError: maximum recursion depth exceeded. Why? Because inside __setattr__, I'm calling self.data = ... (or accessing self.data). Accessing or setting self.data is itself an attribute operation, which triggers __setattr__, which tries to access self.data, and so on. We've built a loop that never ends.

Breaking the cycle

To fix this, we have to bypass the custom __setattr__ logic when we're doing the actual storage. The most reliable way to do this is to call the base class's version of the method using super(), or by manipulating the instance's __dict__ directly. I prefer super()—it's cleaner.

Here is the polished version that handles both reading and writing dynamically:

class ConfigWrapper:
    def __init__(self, data):
        # Use super().__setattr__ here to avoid triggering our own __setattr__
        super().__setattr__('data', data)

    def __getattr__(self, name):
        if name in self.data:
            return self.data[name]
        raise AttributeError(f"Setting {name} not found")

    def __setattr__(self, name, value):
        if name == 'data':
            # Allow the internal dictionary to be overwritten normally
            super().__setattr__(name, value)
        else:
            # Redirect everything else into the dictionary
            print(f"Updating config: {name} -> {value}")
            self.data[name] = value

settings = ConfigWrapper({'port': 8080})
settings.port = 9000  # Triggers __setattr__, updates dictionary
print(settings.port)  # Triggers __getattr__, reads dictionary

By using super().__setattr__, we tell Python: "Just do the standard attribute assignment without calling my custom logic." This allows us to maintain our internal state (the data dict) while providing a sleek, attribute-based API for the user.




📋 Practical Task

Exercise: The Dynamic API Response Proxy

Imagine you are building a tool that consumes a deeply nested API response. Instead of drilling down through dictionaries, you want a proxy class that allows you to access keys as attributes. However, there's a twist: if a key is accessed that doesn't exist, the proxy should return a string "NOT_FOUND" instead of raising an AttributeError.

Your Task:

  • Create a class named ApiResponseProxy.
  • The class should take a dictionary (the API response) in its __init__ method and store it in an attribute called _payload.
  • Implement __getattr__ so that if a key exists in _payload, it returns the value. If it doesn't, it returns the string "NOT_FOUND".
  • Implement __setattr__ so that any attempt to set an attribute (e.g., proxy.status = "success") actually adds that key-value pair to the _payload dictionary. Ensure you do not cause a RecursionError.

Test your implementation with this snippet:

resp = ApiResponseProxy({'id': 101, 'user': 'Alice'})
print(resp.user)    # Expected: Alice
print(resp.email)   # Expected: NOT_FOUND
resp.status = 'OK'  # Should update _payload
print(resp._payload) # Expected: {'id': 101, 'user': 'Alice', 'status': 'OK'}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.