Dynamic Attributes, Properties, and Metaprogramming
Notes based on Fluent Python and supplemental explanations, summarized and rephrased in my own words.[file:27]
What metaprogramming means in Python
In Python, metaprogramming means writing code that creates, inspects, or modifies other code at runtime.[file:27] It leverages Python’s dynamic nature and reflection capabilities.
Common metaprogramming techniques include:[file:27]
- Decorators – Wrap or modify functions, methods, or classes without changing their source.
- Metaclasses – "Classes of classes" that customize how classes themselves are created.
- Dynamic execution –
exec()andeval()run code built from strings or compiled objects. - Dynamic attribute access –
getattr,setattr,delattr,__getattr__,__setattr__, etc.[file:27]
These tools are heavily used in frameworks (ORMs, web frameworks, dependency injection, etc.), but they must be applied carefully to avoid making code opaque.
Dynamic features and attributes overview
Python offers many ways to create or modify behavior at runtime.[file:27]
1. Dynamically creating classes
You can build classes on the fly using type or metaclasses:
MyClass = type('MyClass', (BaseClass,), {'attr': value})- First argument: class name.
- Second: base classes tuple.
- Third: attribute dictionary.[file:27]
This is equivalent to writing a normal class statement, but lets you generate classes programmatically (e.g. from configuration or schemas).
2. Decorators
Decorators are callables that take a function or class and return a modified or wrapped version:
@decorator
def function():
passThey are a core tool for cross-cutting concerns such as logging, validation, caching, and access control.[file:27]
3. Metaclasses
A metaclass is the type of a class. By specifying metaclass=Meta, you can intervene in class creation:[file:27]
class Meta(type):
pass
class MyClass(metaclass=Meta):
passMetaclasses can:
- Modify the class dictionary.
- Register classes.
- Enforce patterns (e.g. singletons, interfaces).
They are powerful but should be used sparingly; many problems are better solved with decorators or simple helper functions.
4. exec() and eval()
exec(code) executes Python code from a string or compiled object; eval(expr) evaluates a single expression.[file:27]
Example:
exec('print("Hello World")')They are occasionally useful for code generation or REPL-like tools, but they can be dangerous and hard to reason about; prefer explicit, structured metaprogramming when possible.
5. getattr, setattr, delattr
These built-ins let you access attributes dynamically by name:[file:27]
value = getattr(obj, 'attr')
setattr(obj, 'attr', 42)
delattr(obj, 'attr')They are essential for writing generic code (e.g. serializers, ORMs) that works with arbitrary objects and attribute names.
6. Special methods: __getattr__, __setattr__, __delattr__
These hooks customize attribute access on classes:[file:27]
__getattr__(self, name)– called only when normal attribute lookup fails.__setattr__(self, name, value)– called for every attribute assignment.__delattr__(self, name)– called for deletions.
They allow patterns like:
- Lazy attribute loading.
- Delegation to an internal object.
- Virtual attributes computed on the fly.
Because __setattr__ intercepts all assignments, it must usually call super().__setattr__ to avoid infinite recursion.
7. globals() and locals()
These return dictionaries of the current global and local symbol tables:[file:27]
globals()['variable_name'] = valueThis can be used to create variables by name dynamically, but heavy use often indicates a design that could be simplified.
8. Dynamic imports
You can import modules by name at runtime:[file:27]
import importlib
module = importlib.import_module('module_name')or directly:
module = __import__('module_name')Dynamic imports are useful for plugin systems or loading modules based on configuration.
9. __new__() for custom instance creation
__new__(cls, *args, **kwargs) is called before __init__ to create a new instance.[file:27]
class MyClass(object):
def __new__(cls, *args, **kwargs):
instance = super(MyClass, cls).__new__(cls)
# custom initialization before __init__
return instanceOverriding __new__ is less common but appears in patterns that control instantiation (e.g. singletons, immutable types).
Descriptors: the engine behind properties
A descriptor is any object that implements at least one of the methods:
__get__(self, instance, owner)__set__(self, instance, value)__delete__(self, instance)[file:27]
When such an object is defined as a class attribute, it can control access to that attribute on instances. Descriptors are the mechanism behind:
property- methods (functions become non-data descriptors)
- many framework-level attribute tricks
Data vs non-data descriptors
Two categories:[file:27]
Data descriptors – define both
__get__and__set__.- Have higher priority than instance attributes stored in
obj.__dict__.
- Have higher priority than instance attributes stored in
Non-data descriptors – define only
__get__.- If an instance’s
__dict__has an attribute with the same name, that value overrides the descriptor.
- If an instance’s
In practice:
- Methods defined in classes are non-data descriptors.
- Custom-managed attributes (like type-checked fields) are often data descriptors.
Descriptor protocol methods
__get__(self, instance, owner)- Access attribute;
instanceis the target object orNonewhen accessed on the class.
- Access attribute;
__set__(self, instance, value)- Called on assignment, lets you validate or transform values.
__delete__(self, instance)- Called on
del obj.attr, useful for clean-up or enforcing invariants.[file:27]
- Called on
Example: a simple type-checking data descriptor
class TypedProperty:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"Value must be of type {self.expected_type}")
instance.__dict__[self.name] = value
class MyClass:
name = TypedProperty('name', str)
age = TypedProperty('age', int)
obj = MyClass()
obj.name = 'John' # OK
obj.age = 30 # OK
# obj.age = 'thirty' # TypeError: wrong typeHere:[file:27]
TypedPropertyis a data descriptor because it defines both__get__and__set__.- Setting
obj.nameorobj.agetriggers__set__, which enforces type constraints. - Getting
obj.nameorobj.agecalls__get__, which fetches the value from the instance dictionary.
Because it is a data descriptor, MyClass.age cannot be shadowed by an instance attribute of the same name; the descriptor always takes precedence.[file:27]
When to use descriptors and dynamic attributes
Use dynamic attribute techniques when you need:
- Custom validation, conversion, or logging on attribute access.
- Lazy loading or virtual attributes computed on demand.
- Shared logic applied uniformly across many fields or classes (e.g. in ORMs or form libraries).
Use metaprogramming carefully:
- Prefer clear, explicit code over clever magic.
- Reach for decorators and simple descriptors before metaclasses or
exec/eval. - Document dynamic behavior clearly; unexpected metaprogramming can make debugging much harder.
In many real-world frameworks, these tools are combined (e.g. descriptors managed by a metaclass, configured via decorators) to provide declarative and powerful APIs.[file:27]