Why do I want module-level properties in Python?
For many people, the desire to have module-level properties boils down to flexibility. In this case, I came up with this solution while talking with Giampaolo Rodola (author of PyFTPDLib, psutil, current maintainer of asyncore and related Python libraries, ...) and hearing about psutil's use of module-level constants. Now, module-level constants aren't usually a big deal, but in this case, some of psutil's constants are actually relatively expensive to compute - expensive enough that Giampaolo was planning on deprecating the constants, deferring computation until the library user explicitly called the relevant compute_value() functions.
But with module properties, Giampaolo can defer calculation of those values until a program accesses the module's attributes, at which point the values can be computed (and cached as necessary). But even more useful is the fact that if you don't use those attributes, you don't need to compute them, so most people will get an convenient but unexpected performance improvement any time they need to import psutil.
What doesn't work and why
The first time someone wants to use a module property, they will try to decorate a function in their module with the property() decorator like they are used to using on methods of classes. Unfortunately, when trying to access that "property", they discover that the property didn't get any of the descriptor magic applied to it, and they have a property object that doesn't do anything particularly useful.
The reason it doesn't do anything useful is because properties are attached to classes, not instances. And during import/execution of a module, your definitions are being executed in the context of an instance dictionary of the module with no substantive post-processing. On the other hand, typical Python class definition results in the body of the class being executed, then the results passed to type() (via the type(name, bases, dict) form) for class creation.
Making it work
Someone who knows a bit more about how Python's internals are put together knows that you can muck with the contents of sys.modules, and that doing so during module import will let you replace the module object itself. So that's what we are going to do. Along the way, we're going to be doing a bit of deep magic, so don't be scared if you see something that you don't quite understand.
There are 5 major steps to make module properties work:
- Define your property
- Create a new type to offer unique properties for the module
- Ensure that the replacement module has access to the module namespace
- Fix up the module namespace and handle property definitions
- Replace the module in sys.modules
Our first two steps are easy. We can simply use the standard @property decorator (that we'll make work later) to create a property, and we define an empty class definition that subclasses from object.
@property def module_property(module): return "I work!", module class Module(object): pass
Our third step is also easy, we just need to instantiate our replacement module and replace its __dict__ with the globals from the module we are replacing.
module = Module() module.__dict__ = globals()
Our fourth step also isn't all that difficult, we just need to go through the module's globals and extract any properties that are defined. Generally speaking, we really want to pull out *any* descriptors, not just properties. But for this version, we'll extract out only property instances.
for k, v in list(module.__dict__.items()): if isinstance(v, property): setattr(Module, k, v) del module.__dict__[k]
Note that when we move the properties from the module globals to the replacement module, we have to assign to the replacement module class, not the instance of the replacement module. Generally speaking, this kind of class-level function/descriptor assignment is frowned upon, but in some cases (like this), it is necessary in order to get the functionality that we want.
And our final step is actually pretty easy, but we have to remember to keep a reference to the original module, as standard module destruction includes the resetting of all values in the module to be equal to None.
module._module = sys.modules[module.__name__] module._pmodule = module sys.modules[module.__name__] = module
And that is it. If you copy and paste all of the above code into a module with all of your module properties defined before our fourth step executes, then after the module is imported you will be able to reference any of your defined properties as attributes of the module. Note that if you want to access the properties from within the module, you need to reference them from the _pmodule global we injected.
Where can I get a pre-packaged copy of this magic?
To save you (and me) from needing to copy/paste the above into every module we want module properties, I've gone ahead and built a Python package for module properties. You can find it on Github, or you can find it on the Python package index. How do you use it? Very similar to what I defined above:
@property def module_property(module): return "I work!", module # after all properties are defined (put this at the end of the file) import mprop; mprop.init()
Alternatively, if you don't want to remember to throw an mprop.init() call at the end, I've got a property work-alike that handles all of the magic:
from mprop import mproperty @mproperty def module_property(module): return "I also work!", module
And that's it. Module properties in Python. Enjoy :)
Hacker news thread here. Reddit thread here.
Very useful Josiah, thank you!
ReplyDeleteNote that the link you provide to PyPi in your post is pointing to the wrong package (rom instead of mprop) and should be https://pypi.python.org/pypi/mprop
You are quite welcome. Thank you for the note, I updated the link. :)
DeleteHello. I thought I posted earlier, but I don't see it here. I'm trying to use @mproperty, and when I do I get the following error:
ReplyDeleteDuring handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "C:\Python34\lib\site-packages\_pytest\config.py", line 543, in importconftest
mod = conftestpath.pyimport()
File "C:\Python34\lib\site-packages\py\_path\local.py", line 650, in pyimport
__import__(modname)
File "C:\Users\weiss045\Documents\dev\cloud-api-test-automation\conftest.py", line 37, in
@mproperty
File "C:\Python34\lib\site-packages\mprop.py", line 118, in __init__
mod = init(func.func_globals)
AttributeError: 'function' object has no attribute 'func_globals'
ERROR: could not load C:\Users\weiss045\Documents\dev\cloud-api-test-automation\conftest.py
This is where I have a simple property of:
@mproperty
def logger(mod):
if _logger:
return _logger
init()
return _logger
[Init intializes _logger, as you might imagine, and does not use logger].
I've also tried using the "import mprop; mprop.init()" method and get the following error:
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "C:\Python34\lib\site-packages\_pytest\config.py", line 543, in importconftest
mod = conftestpath.pyimport()
File "C:\Python34\lib\site-packages\py\_path\local.py", line 650, in pyimport
__import__(modname)
File "C:\Users\weiss045\Documents\dev\cloud-api-test-automation\conftest.py", line 227, in
import mprop; mprop.init()
File "C:\Python34\lib\site-packages\mprop.py", line 97, in init
_cleanup(Module, glbls)
File "C:\Python34\lib\site-packages\mprop.py", line 37, in _cleanup
if isdescriptor(v):
File "C:\Python34\lib\site-packages\mprop.py", line 16, in isdescriptor
if isinstance(v, (types.TypeType, types.ClassType)):
AttributeError: 'module' object has no attribute 'TypeType'
ERROR: could not load C:\Users\weiss045\Documents\dev\cloud-api-test-automation\conftest.py
I'm using Python 3.4, on windows.
Thanks!
Sam
Hello Sam, I'm sorry for not getting back to you earlier. I have updated the library to version 0.14 to fix the issues you have been experiencing. You can get the updated version from PyPI [1] or Github [2].
DeleteIf you have any other issues in the future, please post the bug on the Github tracker. Thank you for the bug report :)
[1] https://pypi.python.org/pypi/mprop/
[2] https://github.com/josiahcarlson/mprop/