diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 72f2bcd2c2d..4602dbe7b38 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -602,6 +602,7 @@ pep-0721.rst @encukou pep-0722.rst @pfmoore pep-0723.rst @AA-Turner pep-0725.rst @pradyunsg +pep-0726.rst @AA-Turner pep-0727.rst @JelleZijlstra # ... # pep-0754.txt diff --git a/pep-0726.rst b/pep-0726.rst new file mode 100644 index 00000000000..20b821ad386 --- /dev/null +++ b/pep-0726.rst @@ -0,0 +1,188 @@ +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 ``__setattr__`` or ``__delattr__`` only affect lookups made +using the attribute access syntax---directly accessing the module +globals is unaffected, e.g. ``sys.modules[__name__].some_global = 'spam'``. + + +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.