From 0ebca05144ed3655cd37e782241cb9f18d15b43f Mon Sep 17 00:00:00 2001 From: Oracle Date: Fri, 25 Oct 2024 20:25:13 +0200 Subject: [PATCH] Added extension support --- asyncron/__init__.py | 8 ++- asyncron/apps.py | 38 ++++++++++ asyncron/extender.py | 48 +++++++++++++ asyncron/extender_draft.py | 137 +++++++++++++++++++++++++++++++++++++ asyncron/shortcuts.py | 5 +- setup.py | 2 +- 6 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 asyncron/extender.py create mode 100644 asyncron/extender_draft.py diff --git a/asyncron/__init__.py b/asyncron/__init__.py index 9b172ea..526d263 100644 --- a/asyncron/__init__.py +++ b/asyncron/__init__.py @@ -1,3 +1,7 @@ -from .shortcuts import task, run_on_model_change +from .shortcuts import task, run_on_model_change, extension -__all__ = ['task', 'run_on_model_change'] +__all__ = [ + 'task', + 'run_on_model_change', + 'extension' +] diff --git a/asyncron/apps.py b/asyncron/apps.py index 6d2c96e..7e6d06a 100644 --- a/asyncron/apps.py +++ b/asyncron/apps.py @@ -16,6 +16,8 @@ class AsyncronConfig(AppConfig): except (KeyError, AttributeError): pass else: self.import_per_app( names ) + self.load_extensions() + #Init the asyncron worker for this process from .workers import AsyncronWorker #The worker should not start working until they know we're responding to requests. @@ -34,3 +36,39 @@ class AsyncronConfig(AppConfig): #print( f"Loading {app.name}.{name}:", import_file ) loader = importlib.machinery.SourceFileLoader( f"{app.name}.{name}", str(import_file) ) loader.exec_module( types.ModuleType(loader.name) ) + + + def load_extensions( self ): + from .base.models import BaseModel + from .extender import Extender + for app in apps.get_app_configs(): + app_dir = pathlib.Path(app.path) + for model in app.get_models(): + if not issubclass( model, BaseModel ): continue + + ext_dir = app_dir / "extensions" / model.__name__ + if not ext_dir.exists(): continue + + extender = None + for import_file in ext_dir.iterdir(): + if not import_file.is_file(): continue + if import_file.suffixes[-1] != ".py": continue + + #So the imported module can attach it's method to the extender created last + if extender is None: extender = Extender( model ) + + loader = importlib.machinery.SourceFileLoader( f"{app.name}.extensions.{model.__name__}.{import_file.stem}", str(import_file) ) + loader.exec_module( types.ModuleType(loader.name) ) + + if extender: + extender.attach( model ) + + Extender.stop_capturing() + + + + + + + +# diff --git a/asyncron/extender.py b/asyncron/extender.py new file mode 100644 index 0000000..bc43e79 --- /dev/null +++ b/asyncron/extender.py @@ -0,0 +1,48 @@ +import collections +import functools + +class Extender: + capturing_instance = None + + @classmethod + def stop_capturing( cls ): + cls.capturing_instance = None + + def __init__( self, model ): + self.__class__.capturing_instance = self + self.model = model + self.extensions = collections.defaultdict( list ) # method_name -> list of tuple(args, kwargs) + + def __call__( self, *checks, **filters ): + def decorator( f ): + self.extensions[ f.__name__ ].append( (f, checks, filters) ) + return f + return decorator + + def attach( extender, cls ): + #Attach the miss catch attr method + def __getattr__( self, extended_name ): + if extended_name not in extender.extensions: + try: super_getattr = super(self.__class__).__getattr__ + except AttributeError: raise AttributeError(f"{cls} Model has no extension '{extended_name}'.") + else: return super_getattr( self, method ) + + method_candidates = extender.extensions[extended_name] + def run_matching_candidate( *args, **kwargs ): + for f, checks, filters in method_candidates: + + check_failed = False + for check in checks: + if not check( self ): + check_failed = True + break + if check_failed: continue + + if any( getattr(self, k) != v for k, v in filters.items() ): + continue + + return f( self, *args, **kwargs ) + raise AttributeError(f"{self} Did not match any extensions for '{extended_name}'.") + return run_matching_candidate + + cls.__getattr__ = __getattr__ diff --git a/asyncron/extender_draft.py b/asyncron/extender_draft.py new file mode 100644 index 0000000..c1f76a1 --- /dev/null +++ b/asyncron/extender_draft.py @@ -0,0 +1,137 @@ +""" +# This is a failed draft of extender, +# I was trying to make the exact function attached as an extension, not evaluate until the first +# time the function is about to enter an async context, so we can potentially hit the db to figure out which functions +# from the 'method_candidates' list should be given the arguments provided, +# But this proved challenging to get working with all types of async initiation (async for, async with, await) +# I even have a hunch this is something that might later be made easier/possible from python's side +# +# Main Issue I faced: +# asyncio.run explicitly checks if an object is a coroutine: +# python3.12/asyncio/runners.py line 89 & 90: +# if not coroutines.iscoroutine(coro): +# raise ValueError("a coroutine was expected, got {!r}".format(coro)) +# +# So I can't have a superposition of "coroutine / async context / async iterable" until it's collapsed the moment it's used! +# I have to choose one. which would limit (arguably very little in practice) the functionality of +# extensions in a way that would force the same names for extensions to be different async types. +# +# I know that's almost never an issue, but the code smell from doing this will drive me crazy, so this is gonna stay like this for now. +""" + +import collections +import functools + +class Extender: + capturing_instance = None + + @classmethod + def stop_capturing( cls ): + cls.capturing_instance = None + + def __init__( self, model ): + self.__class__.capturing_instance = self + self.model = model + self.extensions = collections.defaultdict( list ) # method_name -> list of tuple(args, kwargs) + + def __call__( self, *checks, **filters ): + def decorator( f ): + self.extensions[ f.__name__ ].append( (f, checks, filters) ) + return f + return decorator + + def attach( extender, cls ): + #Attach the miss catch attr method + def __getattr__( self, extended_name ): + if extended_name not in extender.extensions: + try: super_getattr = super(self.__class__).__getattr__ + except AttributeError: raise AttributeError(f"{cls} Model has no extension '{extended_name}'.") + else: return super_getattr( self, method ) + + method_candidates = extender.extensions[extended_name] + + wrapper = AsyncWrapper( self, method_candidates ) + return wrapper.await_arguments + + cls.__getattr__ = __getattr__ + + + +import asyncio +import collections.abc# -> collections.abc.Coroutine + +class AsyncWrapper( collections.abc.Coroutine ): + def __init__(self, model_instance, wrapper_candidates): + self._wrapper_model_instance = model_instance + self._wrapper_candidates = wrapper_candidates + self._wrapper_obj = None + self._wrapper_args = None + self._wrapper_kwargs = None + self._wrapper_init_lock = asyncio.Lock() # Ensure init happens only once in case of concurrent access + + # Asynchronous initializer + async def _wrapper_init( self ): + if not self._wrapper_obj: + async with self._wrapper_init_lock: + if not self._wrapper_obj: # Double-check after acquiring the lock + await self._wrapper_find_matching_candidate() + + + # Example async initialization logic + async def _wrapper_find_matching_candidate( self ): + for f, checks, filters in self._wrapper_candidates: + + check_failed = False + for check in checks: + if not ( await check( self._wrapper_model_instance ) ): + check_failed = True + break + if check_failed: continue + + + if any( getattr(self._wrapper_model_instance, k) != v for k, v in filters.items() ): + continue + + self._wrapper_obj = f + return + + raise AttributeError(f"{self._wrapper_model_instance} Did not match any extensions for '{extended_name}'.") + + def await_arguments( self, *args, **kwargs ): + self._wrapper_args = args + self._wrapper_kwargs = kwargs + return self + + def send( self, thing): pass + def throw( self, thing ): pass + + # Await method + def __await__( self ): + print("R: __await__") + async def wrapper(): + print("R: __await__ wrapper") + await self._wrapper_init() + return await self._wrapper_obj( self._wrapper_model_instance, *self._wrapper_args, **self._wrapper_kwargs ) + return wrapper().__await__() + + # Async iterator + async def __aiter__( self ): + print("R: __aiter__") + await self._wrapper_init() + return self._wrapper_obj.__aiter__() + + async def __anext__( self ): + print("R: __anext__") + await self._wrapper_init() + return await self._wrapper_obj.__anext__() + + # Async context manager + async def __aenter__( self ): + print("R: __aenter__") + await self._wrapper_init() + return await self._wrapper_obj.__aenter__() + + async def __aexit__( self, exc_type, exc, tb ): + print("R: __aexit__") + await self._wrapper_init() + return await self._wrapper_obj.__aexit__(exc_type, exc, tb) diff --git a/asyncron/shortcuts.py b/asyncron/shortcuts.py index cd0571a..8204901 100644 --- a/asyncron/shortcuts.py +++ b/asyncron/shortcuts.py @@ -57,7 +57,10 @@ def run_on_model_change( *models ): return f return decorator - +from .extender import Extender +def extension( *args, **kwargs ): + assert Extender.capturing_instance, "Cannot only extend a model during inital import and after apps are ready!" + return Extender.capturing_instance( *args, **kwargs ) diff --git a/setup.py b/setup.py index 566ba50..542faf8 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name='asyncron', - version='0.1.3', + version='0.1.4', packages=find_packages(), #include_package_data=True, # Include static files from MANIFEST.in install_requires=[