Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 726 – Module __setattr__ and __delattr__

Author:
Sergey B Kirpichev <skirpichev at gmail.com>
Sponsor:
Adam Turner <python at quite.org.uk>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
24-Aug-2023
Python-Version:
3.13
Post-History:
06-Apr-2023, 31-Aug-2023

Table of Contents

Abstract

This PEP proposes supporting user-defined __setattr__ and __delattr__ methods on modules to extend customization of module attribute access beyond PEP 562.

Motivation

There are several potential uses of a module __setattr__:

  1. To prevent setting an attribute at all (i.e. make it read-only)
  2. To validate the value to be assigned
  3. To intercept setting an attribute and update some other state

Proper support for read-only attributes would also require adding the __delattr__ function to prevent their deletion.

A typical workaround is assigning the __class__ of a module object to a custom subclass of types.ModuleType (see [1]). Unfortunately, this also brings a noticeable speed regression (~2-3x) for attribute access. It would be convenient to directly support such customization, by recognizing __setattr__ and __delattr__ methods defined in a module that would act like normal object.__setattr__() and object.__delattr__() methods, except that they will be defined on module instances.

For example

# mplib.py

CONSTANT = 3.14
prec = 53
dps = 15

def dps_to_prec(n):
    """Return the number of bits required to represent n decimals accurately."""
    return max(1, int(round((int(n)+1)*3.3219280948873626)))

def prec_to_dps(n):
    """Return the number of accurate decimals that can be represented with n bits."""
    return max(1, int(round(int(n)/3.3219280948873626)-1))

def validate(n):
    n = int(n)
    if n <= 0:
        raise ValueError('non-negative integer expected')
    return n

def __setattr__(name, value):
    if name == 'CONSTANT':
        raise AttributeError('Read-only attribute!')
    if name == 'dps':
        value = validate(value)
        globals()['dps'] = value
        globals()['prec'] = dps_to_prec(value)
        return
    if name == 'prec':
        value = validate(value)
        globals()['prec'] = value
        globals()['dps'] = prec_to_dps(value)
        return
    globals()[name] = value

def __delattr__(name):
    if name in ('CONSTANT', 'dps', 'prec'):
        raise AttributeError('Read-only attribute!')
    del globals()[name]
>>> import mplib
>>> mplib.foo = 'spam'
>>> mplib.CONSTANT = 42
Traceback (most recent call last):
  ...
AttributeError: Read-only attribute!
>>> del mplib.foo
>>> del mplib.CONSTANT
Traceback (most recent call last):
  ...
AttributeError: Read-only attribute!
>>> mplib.prec
53
>>> mplib.dps
15
>>> mplib.dps = 5
>>> mplib.prec
20
>>> mplib.dps = 0
Traceback (most recent call last):
  ...
ValueError: non-negative integer expected

Specification

The __setattr__ function at the module level should accept two arguments, the name of an attribute and the value to be assigned, and return None or raise an AttributeError.

def __setattr__(name: str, value: typing.Any, /) -> None: ...

The __delattr__ function should accept one argument, the name of an attribute, and return None or raise an AttributeError:

def __delattr__(name: str, /) -> None: ...

The __setattr__ and __delattr__ functions are looked up in the module __dict__. If present, the appropriate function is called to customize setting the attribute or its deletion, else the normal mechanism (storing/deleting the value in the module dictionary) will work.

Defining module __setattr__ or __delattr__ only affects lookups made using the attribute access syntax—directly accessing the module globals (whether by globals() within the module, or via a reference to the module’s globals dictionary) is unaffected. For example:

>>> import mod
>>> mod.__dict__['foo'] = 'spam'  # bypasses __setattr__, defined in mod.py

or

# mod.py

def __setattr__(name, value):
   ...

foo = 'spam'  # bypasses __setattr__
globals()['bar'] = 'spam'  # here too

def f():
    global x
    x = 123

f()  # and here

To use a module global and trigger __setattr__ (or __delattr__), one can access it via sys.modules[__name__] within the module’s code:

# mod.py

sys.modules[__name__].foo = 'spam'  # bypasses __setattr__

def __setattr__(name, value):
    ...

sys.modules[__name__].bar = 'spam'  # triggers __setattr__

How to Teach This

The “Customizing module attribute access” [1] section of the documentation will be expanded to include new functions.

Reference Implementation

The reference implementation for this PEP can be found in CPython PR #108261.

Backwards compatibility

This PEP may break code that uses module level (global) names __setattr__ and __delattr__, but the language reference explicitly reserves all undocumented dunder names, and allows “breakage without warning” [2].

The performance implications of this PEP are small, since additional dictionary lookup is much cheaper than storing/deleting the value in the dictionary. Also it is hard to imagine a module that expects the user to set (and/or delete) attributes enough times to be a performance concern. On another hand, proposed mechanism allows to override setting/deleting of attributes without affecting speed of attribute access, which is much more likely scenario to get a performance penalty.

Footnotes


Source: https://github.com/python/peps/blob/main/peps/pep-0726.rst

Last modified: 2023-09-09 17:39:29 GMT