I have played with this a bit more, and I would propose to change the PEP to add more broadly “magic modules” instead of merely callable ones.
If you want to give it a try, here’s a simple proof of concept against current cpython that implements generic call, getattr, and setattr. One could think of other useful methods. It’s debatable where one would put the patching of the magic methods.
Patch
diff --git a/Lib/importlib/_bootstrap.py b/Lib/importlib/_bootstrap.py
index e4fcaa6..62f0f32 100644
--- a/Lib/importlib/_bootstrap.py
+++ b/Lib/importlib/_bootstrap.py
@@ -814,6 +814,19 @@ def _load_backward_compatible(spec):
pass
return module
+
+def _add_magic(module):
+ """Add magic methods to an existing module by changing its type."""
+ magic = {"__call__", "__getattr__", "__setattr__"}
+ module_dict = module.__dict__
+ subclass_dict = {}
+ for name in module_dict.keys() & magic:
+ subclass_dict[name] = staticmethod(module_dict[name])
+ if subclass_dict:
+ cls = module.__class__
+ module.__class__ = type("magic_module", (cls,), subclass_dict)
+
+
def _load_unlocked(spec):
# A helper for direct use by the import system.
if spec.loader is not None:
@@ -845,6 +858,8 @@ def _load_unlocked(spec):
except KeyError:
pass
raise
+ # If module has magic methods, make them magic
+ _add_magic(module)
# Move the module to the end of sys.modules.
# We don't ensure that the import-related module attributes get
# set in the sys.modules replacement case. Such modules are on
diff --git a/Python/bytecodes.c b/Python/bytecodes.c
index 9de0d92..ab948f5 100644
--- a/Python/bytecodes.c
+++ b/Python/bytecodes.c
@@ -1676,7 +1676,7 @@ dummy_func(
}
inst(LOAD_ATTR_MODULE, (unused/1, type_version/2, index/1, unused/5, owner -- res2 if (oparg & 1), res)) {
- DEOPT_IF(!PyModule_CheckExact(owner), LOAD_ATTR);
+ DEOPT_IF(!PyModule_Check(owner), LOAD_ATTR);
PyDictObject *dict = (PyDictObject *)((PyModuleObject *)owner)->md_dict;
assert(dict != NULL);
DEOPT_IF(dict->ma_keys->dk_version != type_version, LOAD_ATTR);
diff --git a/Python/generated_cases.c.h b/Python/generated_cases.c.h
index 864a4f7..96c764f 100644
--- a/Python/generated_cases.c.h
+++ b/Python/generated_cases.c.h
@@ -2338,7 +2338,7 @@
uint32_t type_version = read_u32(&next_instr[1].cache);
uint16_t index = read_u16(&next_instr[3].cache);
#line 1679 "Python/bytecodes.c"
- DEOPT_IF(!PyModule_CheckExact(owner), LOAD_ATTR);
+ DEOPT_IF(!PyModule_Check(owner), LOAD_ATTR);
PyDictObject *dict = (PyDictObject *)((PyModuleObject *)owner)->md_dict;
assert(dict != NULL);
DEOPT_IF(dict->ma_keys->dk_version != type_version, LOAD_ATTR);
diff --git a/Python/specialize.c b/Python/specialize.c
index 33a3c45..527b454 100644
--- a/Python/specialize.c
+++ b/Python/specialize.c
@@ -752,7 +752,7 @@ _Py_Specialize_LoadAttr(PyObject *owner, _Py_CODEUNIT *instr, PyObject *name)
SPECIALIZATION_FAIL(LOAD_ATTR, SPEC_FAIL_OTHER);
goto fail;
}
- if (PyModule_CheckExact(owner)) {
+ if (PyModule_Check(owner)) {
if (specialize_module_load_attr(owner, instr, name))
{
goto fail;
@@ -925,7 +925,7 @@ _Py_Specialize_StoreAttr(PyObject *owner, _Py_CODEUNIT *instr, PyObject *name)
SPECIALIZATION_FAIL(STORE_ATTR, SPEC_FAIL_OTHER);
goto fail;
}
- if (PyModule_CheckExact(owner)) {
+ if (PyModule_Check(owner)) {
SPECIALIZATION_FAIL(STORE_ATTR, SPEC_FAIL_OVERRIDDEN);
goto fail;
}
With this, the magic works as expected:
# echo.py
def __getattr__(key): return key
def __setattr__(key, value): raise RuntimeError("not allowed")
def __call__(word): print(word)
>>> import echo
>>> echo.hello
'hello'
>>> echo.hello = 'hi'
RuntimeError: not allowed
>>> echo('hello world')
hello world