Skip to content
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

Idea: Add support for lazy module-level properties #127

Open
Erotemic opened this issue Jul 21, 2024 · 0 comments
Open

Idea: Add support for lazy module-level properties #127

Erotemic opened this issue Jul 21, 2024 · 0 comments

Comments

@Erotemic
Copy link

I have a use-case where I want my kwplot module to have a "module-level property". In other words, I want to define a function in the module and when the name of that function is accessed as an attribute, the function should execute and give the return value. Specifically this is to provide users quick access to the pyplot and seaborn libraries, which have import time side effects I would like to avoid until I explicitly use them.

Previously I have users do something like this when they need plt.

import kwplot
plt = kwplot.autoplt()
plt.figure()

which appropriately sets the backend based on hueristics. However, I've found it much more convenient to have something like:

import kwplot
kwplot.plt.figure()

But this requires hooking up plt as a module-level property so it calls autoplt exactly when needed.

My original proof of concept was something very simple:

     __all__ += ['plt', 'sns']
     def __getattr__(key):
         # Make these special auto-backends top-level dynamic properties of kwplot
         if key == 'plt':
             import kwplot
             return kwplot.autoplt()
         if key == 'sns':
             import kwplot
             return kwplot.autosns()
         raise AttributeError(key)

And this was not used in conjunction with lazy-loader. But now I'm dropping 3.6 and 3.7 as supported versions, so lazy-loader is becoming much more appealing.

I have a proof of concept for this in mkinit, where in __init__.py, the user defines something like:

class __module_properties__:
    """
    experimental mkinit feature for handling module level properties.
    """

    @property
    def plt(self):
        import kwplot
        return kwplot.autoplt()

    @property
    def sns(self):
        import kwplot
        return kwplot.autosns()

    @property
    def Color(self):
        # Backwards compat
        from kwimage import Color
        return Color

And then mkinit does static parsing to parse the names of all properties in that special class if it sees it and then munges the lazy_import (equivalent to lazy_loader.attach) function and effectively inject

def lazy_import(module_name, submodules, submod_attrs, eager='auto'):
    ... ommitted ...
    module_property_names = {'Color', 'plt', 'sns'}
    modprops = __module_properties__()
    def __getattr__(name):
        if name in module_property_names:
            return getattr(modprops, name)
        ... ommitted ...
        return attr
    ... ommitted ...
    return __getattr__

Which would not work here, because we probably don't want to dynamically generate the body of lazy_loader.attach at import time. However, it would not be hard to have lazy_loader.attach take a __module_properties__ class and dynamically infer what its properties are. Perhaps something like this:

-def attach(package_name, submodules=None, submod_attrs=None):
+def attach(package_name, submodules=None, submod_attrs=None, __module_properties__=None):
     """Attach lazily loaded submodules, functions, or other attributes.
 
     Typically, modules import submodules and attributes as follows::
@@ -68,7 +68,13 @@ def attach(package_name, submodules=None, submod_attrs=None):
         attr: mod for mod, attrs in submod_attrs.items() for attr in attrs
     }
 
-    __all__ = sorted(submodules | attr_to_modules.keys())
+    if __module_properties__ is not None:
+        modprops = __module_properties__()
+        property_names = {k for k in dir(__module_properties__) if not k.startswith('__')}
+    else:
+        property_names = {}
+
+    __all__ = sorted(submodules | attr_to_modules.keys() | property_names)
 
     def __getattr__(name):
         if name in submodules:
@@ -86,6 +92,8 @@ def attach(package_name, submodules=None, submod_attrs=None):
                 pkg.__dict__[name] = attr
 
             return attr
+        elif name in property_names:
+            return getattr(modprops, name)
         else:
             raise AttributeError(f"No {package_name} attribute {name}")

Then the user would be responsible for passing the class they want to expose as module-level properties to the attach function.

Another option is to real properties similar to how it is done in this example:

"""
Demo how to add real module level properties

The following code follows [SO1060796]_ to enrich a module with class features
like properties, `__call__()`, and `__iter__()`, etc... for Python versions
3.5+.  In the future, if [PEP713]_ is accepted then that will be preferred.
Note that type checking is ignored here because mypy cannot handle callable
modules [MyPy9240]_.

References
----------
.. [SO1060796] https://stackoverflow.com/questions/1060796/callable-modules
.. [PEP713] https://peps.python.org/pep-0713/
.. [MyPy9240] https://github.com/python/mypy/issues/9240

Example
-------
>>> # Assuming this file is in your cwd named "demo_module_properties.py"
>>> import demo_module_properties
>>> print(demo_module_properties.MY_GLOBAL)
0
>>> print(demo_module_properties.our_property1)
we made a module level property with side effects
>>> print(demo_module_properties.MY_GLOBAL)
1
>>> demo_module_properties()
YOU CALLED ME!
>>> print(list(demo_module_properties))
[1, 2, 3]

"""
import sys

MY_GLOBAL = 0


class OurModule(sys.modules[__name__].__class__):  # type: ignore

    def __iter__(self):
        yield from [1, 2, 3]

    def __call__(self, *args, **kwargs):
        print('YOU CALLED ME!')

    @property
    def our_property1(self):
        global MY_GLOBAL
        MY_GLOBAL += 1
        return 'we made a module level property with side effects'


sys.modules[__name__].__class__ = OurModule
del sys, OurModule

But that seems more heavy handed than the alternative of using the existing module-level __getattr__ functionality.

In any case, I'm experimenting with this feature in mkinit and thought I would at least put a writeup of my ideas here in case others had similar use-cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant