Skip to content

gh-106016: Support customizing of module attributes access with __setattr__/__delattr__ (PEP 726) #108261

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions Doc/reference/datamodel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2007,56 +2007,75 @@ Customizing module attribute access

.. index::
single: __getattr__ (module attribute)
single: __setattr__ (module attribute)
single: __delattr__ (module attribute)
single: __dir__ (module attribute)
single: __class__ (module attribute)

Special names ``__getattr__`` and ``__dir__`` can be also used to customize
access to module attributes. The ``__getattr__`` function at the module level
Special names ``__getattr__``, ``__setattr__``, ``__delattr__`` and ``__dir__``
can be also used to customize access to module attributes.

The ``__getattr__`` function at the module level
should accept one argument which is the name of an attribute and return the
computed value or raise an :exc:`AttributeError`. If an attribute is
not found on a module object through the normal lookup, i.e.
:meth:`object.__getattribute__`, then ``__getattr__`` is searched in
the module ``__dict__`` before raising an :exc:`AttributeError`. If found,
it is called with the attribute name and the result is returned.

The ``__setattr__`` function should accept two arguments, respectively, the
name of an attribute and the value to be assigned. It can
raise an :exc:`AttributeError`. If present, this function overrides the
standard :func:`setattr` behaviour on a module. For example::

def __setattr__(name, value):
print(f'Setting {name} to {value}...')
globals()[name] = value

The ``__delattr__`` function should accept one argument which is the name of an
attribute. It can raise an :exc:`AttributeError`. If present,
this function overrides the standard :func:`delattr` behaviour on a module.

The ``__dir__`` function should accept no arguments, and return an iterable of
strings that represents the names accessible on module. If present, this
function overrides the standard :func:`dir` search on a module.

For a more fine grained customization of the module behavior (setting
attributes, properties, etc.), one can set the ``__class__`` attribute of
a module object to a subclass of :class:`types.ModuleType`. For example::
For a more fine grained customization of the module behavior, one can set the
``__class__`` attribute of a module object to a subclass of
:class:`types.ModuleType`. For example::

import sys
from types import ModuleType

class VerboseModule(ModuleType):
def __repr__(self):
return f'Verbose {self.__name__}'
class CallableModule(ModuleType):
def __call__(self, *args, **kwargs):
raise RuntimeError("NO-body expects the Spanish Inquisition!")

def __setattr__(self, attr, value):
print(f'Setting {attr}...')
super().__setattr__(attr, value)

sys.modules[__name__].__class__ = VerboseModule
sys.modules[__name__].__class__ = CallableModule

.. note::
Defining module ``__getattr__`` and setting module ``__class__`` only
affect lookups made using the attribute access syntax -- directly accessing
the module globals (whether by code within the module, or via a reference
to the module's globals dictionary) is unaffected.
Defining module ``__getattr__``, ``__setattr__``, ``__delattr__``, ``__dir__``
and setting module ``__class__`` only affect lookups made using the attribute access
syntax -- directly accessing the module globals (whether by code within the
module, or via a reference to the module's globals dictionary) is unaffected.

.. versionchanged:: 3.5
``__class__`` module attribute is now writable.

.. versionadded:: 3.7
``__getattr__`` and ``__dir__`` module attributes.

.. versionadded:: 3.13
``__setattr__`` and ``__delattr__`` module attributes.

.. seealso::

:pep:`562` - Module __getattr__ and __dir__
Describes the ``__getattr__`` and ``__dir__`` functions on modules.

:pep:`726` - Module __setattr__ and __delattr__
Proposes the ``__setattr__`` and ``__delattr__`` functions on modules.


.. _descriptors:

Expand Down
4 changes: 4 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ Other Language Changes
of the ``optimize`` argument.
(Contributed by Irit Katriel in :gh:`108113`).

* Support customizing module attribute access with ``__setattr__`` and
``__delattr__`` functions at the module level. (Contributed by
Sergey B Kirpichev in :gh:`108261`).

* :mod:`multiprocessing`, :mod:`concurrent.futures`, :mod:`compileall`:
Replace :func:`os.cpu_count` with :func:`os.process_cpu_count` to select the
default number of worker threads and processes. Get the CPU affinity
Expand Down
51 changes: 51 additions & 0 deletions Lib/test/test_module/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,57 @@ def test_module_getattr_errors(self):
if 'test.test_module.bad_getattr2' in sys.modules:
del sys.modules['test.test_module.bad_getattr2']

def test_module_setattr(self):
import test.test_module.good_setattr as gsa
self.assertEqual(gsa.foo, 1)
with self.assertRaises(AttributeError):
gsa.bar
with self.assertRaisesRegex(AttributeError, "Read-only attribute"):
gsa.foo = 2
gsa.bar = 3
self.assertEqual(gsa.bar, 3)
del sys.modules['test.test_module.good_setattr']

def test_module_setattr_errors(self):
import test.test_module.bad_setattr as bsa
from test.test_module import bad_setattr2
self.assertEqual(bsa.foo, 1)
self.assertEqual(bad_setattr2.foo, 1)
with self.assertRaises(TypeError):
bsa.foo = 2
with self.assertRaises(TypeError):
bad_setattr2.foo = 2
del sys.modules['test.test_module.bad_setattr']
if 'test.test_module.bad_setattr2' in sys.modules:
del sys.modules['test.test_module.bad_setattr2']

def test_module_delattr(self):
import test.test_module.good_delattr as gda
self.assertEqual(gda.foo, 1)
with self.assertRaises(AttributeError):
gda.bar
with self.assertRaisesRegex(AttributeError, "Read-only attribute"):
del gda.foo
gda.bar = 3
self.assertEqual(gda.bar, 3)
del gda.bar
with self.assertRaises(AttributeError):
gda.bar
del sys.modules['test.test_module.good_delattr']

def test_module_delattr_errors(self):
import test.test_module.bad_delattr as bda
from test.test_module import bad_delattr2
self.assertEqual(bda.foo, 1)
self.assertEqual(bad_delattr2.foo, 1)
with self.assertRaises(TypeError):
del bda.foo
with self.assertRaises(TypeError):
del bad_delattr2.foo
del sys.modules['test.test_module.bad_delattr']
if 'test.test_module.bad_delattr2' in sys.modules:
del sys.modules['test.test_module.bad_delattr2']

def test_module_dir(self):
import test.test_module.good_getattr as gga
self.assertEqual(dir(gga), ['a', 'b', 'c'])
Expand Down
2 changes: 2 additions & 0 deletions Lib/test/test_module/bad_delattr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
foo = 1
__delattr__ = "Oops"
4 changes: 4 additions & 0 deletions Lib/test/test_module/bad_delattr2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
foo = 1

def __delattr__():
"Bad function signature"
2 changes: 2 additions & 0 deletions Lib/test/test_module/bad_setattr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
foo = 1
__setattr__ = "Oops"
4 changes: 4 additions & 0 deletions Lib/test/test_module/bad_setattr2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
foo = 1

def __setattr__():
"Bad function signature"
6 changes: 6 additions & 0 deletions Lib/test/test_module/good_delattr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
foo = 1

def __delattr__(name):
if name == 'foo':
raise AttributeError("Read-only attribute")
del globals()[name]
6 changes: 6 additions & 0 deletions Lib/test/test_module/good_setattr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
foo = 1

def __setattr__(name, value):
if name == 'foo':
raise AttributeError("Read-only attribute")
globals()[name] = value
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add support for module ``__setattr__`` and ``__delattr__``. Patch by Sergey
B Kirpichev.
39 changes: 38 additions & 1 deletion Objects/moduleobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,43 @@ _Py_module_getattro(PyModuleObject *m, PyObject *name)
return _Py_module_getattro_impl(m, name, 0);
}

static int
module_setattro(PyModuleObject *mod, PyObject *name, PyObject *value)
{
assert(mod->md_dict != NULL);
if (value) {
PyObject *setattr;
if (PyDict_GetItemRef(mod->md_dict, &_Py_ID(__setattr__), &setattr) < 0) {
return -1;
}
if (setattr) {
PyObject *res = PyObject_CallFunctionObjArgs(setattr, name, value, NULL);
Py_DECREF(setattr);
if (res == NULL) {
return -1;
}
Py_DECREF(res);
return 0;
}
}
else {
PyObject *delattr;
if (PyDict_GetItemRef(mod->md_dict, &_Py_ID(__delattr__), &delattr) < 0) {
return -1;
}
if (delattr) {
PyObject *res = PyObject_CallFunctionObjArgs(delattr, name, NULL);
Py_DECREF(delattr);
if (res == NULL) {
return -1;
}
Py_DECREF(res);
return 0;
}
}
return PyObject_GenericSetAttr((PyObject *)mod, name, value);
}

static int
module_traverse(PyModuleObject *m, visitproc visit, void *arg)
{
Expand Down Expand Up @@ -1039,7 +1076,7 @@ PyTypeObject PyModule_Type = {
0, /* tp_call */
0, /* tp_str */
(getattrofunc)_Py_module_getattro, /* tp_getattro */
PyObject_GenericSetAttr, /* tp_setattro */
(setattrofunc)module_setattro, /* tp_setattro */
0, /* tp_as_buffer */
Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC |
Py_TPFLAGS_BASETYPE, /* tp_flags */
Expand Down