PEP: 726 Title: Module ``__setattr__`` and ``__delattr__`` Author: Sergey B Kirpichev Sponsor: Adam Turner Discussions-To: https://discuss.python.org/t/32640/ Status: Draft Type: Standards Track Content-Type: text/x-rst Created: 24-Aug-2023 Python-Version: 3.13 Post-History: `06-Apr-2023 `__, `31-Aug-2023 `__, 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 :py:class:`python: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 :py:meth:`python:object.__setattr__` and :py:meth:`python:object.__delattr__` methods, except that they will be defined on module *instances*. For example .. code:: python # 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] .. code:: pycon >>> 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 :py:obj:`None` or raise an :exc:`AttributeError`. .. code:: python def __setattr__(name: str, value: typing.Any, /) -> None: ... The ``__delattr__`` function should accept one argument, the name of an attribute, and return :py:obj:`None` or raise an :py:exc:`AttributeError`: .. code:: python 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: .. code:: pycon >>> import mod >>> mod.__dict__['foo'] = 'spam' # bypasses __setattr__, defined in mod.py or .. code:: python # 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: .. code:: python # 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 ========= .. [1] Customizing module attribute access (https://docs.python.org/3.11/reference/datamodel.html#customizing-module-attribute-access) .. [2] Reserved classes of identifiers (https://docs.python.org/3.11/reference/lexical_analysis.html#reserved-classes-of-identifiers) Copyright ========= This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.