diff --git a/README.md b/README.md index 265a06c..6c77554 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,49 @@ asyncio.run(run()) The docs: [ahopkins.github.io/mayim](https://ahopkins.github.io/mayim/guide/) -## Coming soon: Sanic support +## Framework support -In v22.6, [Sanic Extensions](https://sanic.dev/en/plugins/sanic-ext/getting-started.html) will introduce a simplified API for adding custom extensions to your [Sanic app](https://sanic.dev). It will look something like this: + +### Quart + +Mayim can attach to Quart using the customary `init_app` pattern and will handle setting up Mayim and the lifecycle events. + +```python +from quart import Quart +from dataclasses import asdict +from typing import List +from mayim import PostgresExecutor +from model import City +from mayim.extension import QuartMayimExtension + +app = Quart(__name__) + + +class CityExecutor(PostgresExecutor): + async def select_all_cities( + self, limit: int = 4, offset: int = 0 + ) -> List[City]: + ... + + +ext = QuartMayimExtension( + executors=[CityExecutor], + dsn="postgres://postgres:postgres@localhost:5432/world", +) +ext.init_app(app) + + +@app.route("/") +async def handler(): + executor = CityExecutor() + cities = await executor.select_all_cities() + return {"cities": [asdict(city) for city in cities]} +``` + + +### Sanic + +Mayim uses [Sanic Extensions](https://sanic.dev/en/plugins/sanic-ext/getting-started.html) v22.6+ to extend your [Sanic app](https://sanic.dev). It starts Mayim and provides dependency injections into your routes of all of the executors ```python from typing import List diff --git a/pyproject.toml b/pyproject.toml index e421cfd..1822928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,12 @@ include_trailing_comma = true [[tool.mypy.overrides]] module = [ - "psycopg.*", + "asyncmy.*", "psycopg_pool.*", + "psycopg.*", + "quart.*", "sanic_ext.*", - "asyncmy.*" + "sanic.*" ] ignore_missing_imports = true diff --git a/setup.cfg b/setup.cfg index 07e8b4a..eb2a113 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = mayim -version = 0.2 +version = 0.3.0 description = The NOT ORM hydraroe long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/mayim/executor/sql.py b/src/mayim/executor/sql.py index bdcee61..893b66a 100644 --- a/src/mayim/executor/sql.py +++ b/src/mayim/executor/sql.py @@ -7,6 +7,7 @@ from mayim.convert import convert_sql_params from mayim.exception import MayimError, RecordNotFound +from mayim.interface.lazy import LazyPool from mayim.query.sql import ParamType, SQLQuery from mayim.registry import LazyHydratorRegistry, LazySQLRegistry @@ -202,6 +203,10 @@ def _setup(func): def decorator(f): @wraps(f) async def decorated_function(self: SQLExecutor, *args, **kwargs): + if isinstance(self.pool, LazyPool): + raise MayimError( + "Connection pool to your database has not been setup. " + ) self._context.set((model, name)) if auto_exec: query = self._queries[name] diff --git a/src/mayim/extension/__init__.py b/src/mayim/extension/__init__.py index 0039e9d..e1cddc2 100644 --- a/src/mayim/extension/__init__.py +++ b/src/mayim/extension/__init__.py @@ -1,3 +1,7 @@ +from .quart_extension import QuartMayimExtension from .sanic_extension import SanicMayimExtension -__all__ = ("SanicMayimExtension",) +__all__ = ( + "QuartMayimExtension", + "SanicMayimExtension", +) diff --git a/src/mayim/extension/quart_extension.py b/src/mayim/extension/quart_extension.py new file mode 100644 index 0000000..24e7d09 --- /dev/null +++ b/src/mayim/extension/quart_extension.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Optional, Sequence, Type, Union + +from mayim import Executor, Hydrator, Mayim +from mayim.exception import MayimError +from mayim.interface.base import BaseInterface +from mayim.registry import InterfaceRegistry, Registry + +try: + from quart import Quart + + QUART_INSTALLED = True +except ModuleNotFoundError: + QUART_INSTALLED = False + Quart = type("Quart", (), {}) # type: ignore + + +class QuartMayimExtension: + name = "mayim" + + def __init__( + self, + *, + executors: Optional[Sequence[Union[Type[Executor], Executor]]] = None, + dsn: str = "", + hydrator: Optional[Hydrator] = None, + pool: Optional[BaseInterface] = None, + app: Optional[Quart] = None, + ): + if not QUART_INSTALLED: + raise MayimError( + "Could not locate Quart. It must be installed to use " + "QuartMayimExtension. Try: pip install quart" + ) + self.executors = executors or [] + for executor in self.executors: + Registry().register(executor) + self.mayim_kwargs = { + "dsn": dsn, + "hydrator": hydrator, + "pool": pool, + } + if app is not None: + self.init_app(app) + + def init_app(self, app: Quart) -> None: + @app.while_serving + async def lifespan(): + Mayim(executors=self.executors, **self.mayim_kwargs) + for interface in InterfaceRegistry(): + await interface.open() + + yield + + for interface in InterfaceRegistry(): + await interface.close() diff --git a/src/mayim/extension/sanic_extension.py b/src/mayim/extension/sanic_extension.py index 909c3d3..63c362e 100644 --- a/src/mayim/extension/sanic_extension.py +++ b/src/mayim/extension/sanic_extension.py @@ -1,11 +1,21 @@ from typing import Optional, Sequence, Type, Union -from sanic_ext import Extend -from sanic_ext.extensions.base import Extension - from mayim import Executor, Hydrator, Mayim +from mayim.exception import MayimError from mayim.interface.base import BaseInterface -from mayim.registry import Registry +from mayim.registry import InterfaceRegistry, Registry + +try: + from sanic.log import logger + from sanic_ext import Extend + from sanic_ext.extensions.base import Extension + + SANIC_INSTALLED = True +except ModuleNotFoundError: + SANIC_INSTALLED = False + logger = object() # type: ignore + Extension = type("Extension", (), {}) # type: ignore + Extend = type("Extend", (), {}) # type: ignore class SanicMayimExtension(Extension): @@ -19,10 +29,12 @@ def __init__( hydrator: Optional[Hydrator] = None, pool: Optional[BaseInterface] = None, ): - # raise NotImplementedError( - # "This is a placeholder feature and will not be released until " - # "sometime after June 2022." - # ) + if not SANIC_INSTALLED: + raise MayimError( + "Could not locate either Sanic or Sanic Extensions. " + "Both libraries must be installed to use SanicMayimExtension. " + "Try: pip install sanic[ext]" + ) self.executors = executors or [] for executor in self.executors: Registry().register(executor) @@ -34,8 +46,17 @@ def __init__( def startup(self, bootstrap: Extend) -> None: @self.app.before_server_start - async def setup(app): + async def setup(_): Mayim(executors=self.executors, **self.mayim_kwargs) + for interface in InterfaceRegistry(): + logger.info(f"Opening {interface}") + await interface.open() + + @self.app.after_server_stop + async def shutdown(_): + for interface in InterfaceRegistry(): + logger.info(f"Closing {interface}") + await interface.close() for executor in Registry().values(): if isinstance(executor, Executor): diff --git a/src/mayim/interface/base.py b/src/mayim/interface/base.py index 9b03e06..3a202c6 100644 --- a/src/mayim/interface/base.py +++ b/src/mayim/interface/base.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse from mayim.exception import MayimError +from mayim.registry import InterfaceRegistry UrlMapping = namedtuple("UrlMapping", ("key", "cast")) @@ -89,6 +90,7 @@ def __init__( self._populate_connection_args() self._populate_dsn() self._setup_pool() + InterfaceRegistry.add(self) def __str__(self) -> str: return f"<{self.__class__.__name__} {self.dsn}>" diff --git a/src/mayim/interface/lazy.py b/src/mayim/interface/lazy.py index 42e0d1f..23bd14e 100644 --- a/src/mayim/interface/lazy.py +++ b/src/mayim/interface/lazy.py @@ -1,16 +1,19 @@ -from typing import AsyncContextManager, Optional +from typing import AsyncContextManager, Optional, Type from psycopg import AsyncConnection +from mayim.exception import MayimError from mayim.interface.base import BaseInterface class LazyPool(BaseInterface): _singleton = None + _derivative: Optional[Type[BaseInterface]] def __new__(cls, *args, **kwargs): if cls._singleton is None: cls._singleton = super().__new__(cls) + cls._singleton._derivative = None return cls._singleton def _setup_pool(self): @@ -32,3 +35,11 @@ def connection( self, timeout: Optional[float] = None ) -> AsyncContextManager[AsyncConnection]: ... + + def set_derivative(self, interface_class: Type[BaseInterface]) -> None: + self._derivative = interface_class + + def derive(self) -> BaseInterface: + if not self._derivative: + raise MayimError("No interface available to derive") + return self._derivative(dsn=self.full_dsn) diff --git a/src/mayim/mayim.py b/src/mayim/mayim.py index dd85278..3af6efc 100644 --- a/src/mayim/mayim.py +++ b/src/mayim/mayim.py @@ -1,3 +1,4 @@ +from asyncio import get_running_loop from inspect import isclass from typing import Optional, Sequence, Type, TypeVar, Union @@ -25,7 +26,13 @@ def __init__( raise MayimError("Conflict with pool and DSN") if not pool and dsn: - pool = PostgresPool(dsn) + try: + get_running_loop() + except RuntimeError: + pool = LazyPool(dsn=dsn) + pool.set_derivative(PostgresPool) + else: + pool = PostgresPool(dsn=dsn) if not executors: executors = [] @@ -88,3 +95,13 @@ def load( continue executor._load() + + async def connect(self) -> None: + registry = Registry() + to_derive = { + executor + for executor in registry.values() + if isinstance(executor.pool, LazyPool) + } + for executor in to_derive: + executor._pool = executor.pool.derive() diff --git a/src/mayim/registry.py b/src/mayim/registry.py index c04eb18..132cd53 100644 --- a/src/mayim/registry.py +++ b/src/mayim/registry.py @@ -2,11 +2,12 @@ from collections import defaultdict from inspect import isclass -from typing import TYPE_CHECKING, DefaultDict, Dict, Optional, Type, Union +from typing import TYPE_CHECKING, DefaultDict, Dict, Optional, Set, Type, Union if TYPE_CHECKING: from mayim.executor import Executor from mayim.hydrator import Hydrator + from mayim.interface.base import BaseInterface class Registry(dict): @@ -27,6 +28,25 @@ def reset(cls): cls._singleton = super().__new__(cls) # type: ignore +class InterfaceRegistry: + _singleton = None + _interfaces: Set[BaseInterface] + + def __new__(cls, *args, **kwargs): + if cls._singleton is None: + cls._singleton = super().__new__(cls) + cls._singleton._interfaces = set() + return cls._singleton + + @classmethod + def add(cls, interface: BaseInterface) -> None: + instance = cls() + instance._interfaces.add(interface) + + def __iter__(self): + return iter(self._interfaces) + + class LazySQLRegistry: _singleton = None _queries: DefaultDict[str, Dict[str, str]]