diff --git a/CHANGELOG.md b/CHANGELOG.md index c397629..5d6343e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ ### Added +- Container-local registries! + Sometimes it's useful to bind a value or factory only to a container. + For example, request metadata or authentication information. + + You can now achieve that with `svcs.Container.register_local_factory()` and `svcs.Container.register_local_value()`. + Once something local is registered, a registry is transparently created and it takes precedence over the global one when a service is requested. + The local registry is closed together with the container. + [#56](https://github.com/hynek/issues/pull/56) + - Flask: `svcs.flask.registry` which is a `werkzeug.local.LocalProxy` for the currently active registry on `flask.current_app`. diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 5b1e41c..de4d099 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -150,6 +150,7 @@ In this case make sure to reset it by calling {meth}`svcs.Container.close` on it Closing a container is idempotent and it's safe to use it again afterwards. If your integration has a function called `overwrite_(value|factory)()`, it will do all of that for you. +Of course, you can also use {ref}`local-registries`. ::: @@ -166,11 +167,10 @@ You can also use containers as (async) context managers that (a)close automatica ```python >>> reg = svcs.Registry() ->>> def factory() -> str: +>>> def clean_factory() -> str: ... yield "Hello World" ... print("Cleaned up!") ->>> reg.register_factory(str, factory) - +>>> reg.register_factory(str, clean_factory) >>> with svcs.Container(reg) as con: ... _ = con.get(str) Cleaned up! @@ -186,6 +186,87 @@ That makes testing even easier because the business code makes fewer assumptions *svcs* will raise a {class}`ResourceWarning` if a container with pending cleanups is garbage-collected. +(local-registries)= + +### Container-Local Registries + +::: {versionadded} 23.21.0 +::: + +Sometimes, you want to register a factory or value that's only valid within a container. +For example, you might want to register a factory that depends on data from a request object. +Per-request factories, if you will. + +This is where container-local registries come in. +They are created implicitly by calling {meth}`svcs.Container.register_local_factory()` and {meth}`svcs.Container.register_local_value()`. +When looking up factories in a container, the local registry takes precedence over the global one, and it is closed along with the container: + +```python +>>> container = svcs.Container(registry) +>>> registry.register_value(str, "Hello World!") +>>> container.register_local_value(str, "Goodbye Cruel World!") +>>> container.get(str) +'Goodbye Cruel World!' +>>> container.close() # closes both container & its local registry +>>> registry.close() # closes the global registry +``` + +::: {warning} +Nothing is going to stop you from letting your global factories depend on local ones -- similarly to template subclassing. + +For example, you could define your database connection like this: + +```python +from sqlalchemy import text + +def connect_and_set_user(svcs_container): + user_id = svcs_container.get(User).user_id + with engine.connect() as conn: + conn.execute(text("SET user = :id", {"id": user_id})) + + yield conn + +registry.register_factory(Connection, connect_and_set_user) +``` + +And then, somewhere in a middleware, define a local factory for the `Request` type using something like: + +```python +def middleware(request): + container.register_local_value(User, User(request.user_id, ...)) +``` + +**However**, then you have to be very careful around the caching of created services. +If your application requests a `Connection` instance before you register the local `Request` factory, the `Connection` factory will either crash or be created with the wrong user (for example, if you defined a stub/fallback user in the global registry). + +It is safer and easier to reason about your code if you keep the dependency arrows point from the local registry to the global one: + +% skip: next -- Python 3.12 + +```python +# The global connection factory that creates and cleans up vanilla +# connections. +registry.register_factory(Connection, engine.connect) + +# Create a type alias with an idiomatic name. +type ConnectionWithUserID = Connection + +def middleware(request): + def set_user_id(svcs_container): + conn = svcs_container.get(Connection) + conn.execute(text("SET user = :id", {"id": user_id})) + + return conn + + # Use a factory to keep the service lazy. If the view never asks for a + # connection, we never connect -- or set a user. + container.register_local_factory(ConnectionWithUserID, set_user_id) +``` + +Now the type name expresses the purpose of the object and it doesn't matter if there's already a non-user-aware `Connection` in the global registry. +::: + + (health)= ### Health Checks @@ -278,7 +359,7 @@ You can see that the datetime factory and the str value have both been registere :members: register_factory, register_value, close, aclose, __contains__ .. autoclass:: Container() - :members: get, aget, get_abstract, aget_abstract, close, aclose, get_pings, __contains__ + :members: get, aget, get_abstract, aget_abstract, register_local_factory, register_local_value, close, aclose, get_pings, __contains__ .. autoclass:: ServicePing() :members: name, ping, aping, is_async diff --git a/src/svcs/_core.py b/src/svcs/_core.py index 67cd627..10da2f8 100644 --- a/src/svcs/_core.py +++ b/src/svcs/_core.py @@ -14,6 +14,7 @@ AbstractContextManager, asynccontextmanager, contextmanager, + suppress, ) from inspect import ( isasyncgenfunction, @@ -282,7 +283,7 @@ def register_value( ) Please note that, unlike with :meth:`register_factory`, entering - context managers is **disabled** by default. + context managers is **disabled** by default. .. versionchanged:: 23.21.0 *enter* is now ``False`` by default. @@ -468,17 +469,19 @@ class Container: Warns: - ResourceWarning: If a container with pending cleanups is - garbage-collected. + ResourceWarning: + If a container with pending cleanups is garbage-collected. Attributes: - registry: The :class:`Registry` instance that this container uses for - service type lookup. + registry: + The :class:`Registry` instance that this container uses for service + type lookup. """ registry: Registry + _lazy_local_registry: Registry | None = None _instantiated: dict[type, object] = attrs.Factory(dict) _on_close: list[ tuple[str, AbstractContextManager | AbstractAsyncContextManager] @@ -563,6 +566,8 @@ def close(self) -> None: extra={"svcs_service_name": name}, ) + if self._lazy_local_registry is not None: + self._lazy_local_registry.close() self._on_close.clear() self._instantiated.clear() @@ -595,6 +600,8 @@ async def aclose(self) -> None: extra={"svcs_service_name": name}, ) + if self._lazy_local_registry is not None: + await self._lazy_local_registry.aclose() self._on_close.clear() self._instantiated.clear() @@ -651,11 +658,86 @@ def _lookup(self, svc_type: type) -> tuple[bool, object, str, bool]: ) is not attrs.NOTHING: return True, svc, "", False - rs = self.registry.get_registered_service_for(svc_type) + rs = None + if self._lazy_local_registry is not None: + with suppress(ServiceNotFoundError): + rs = self._lazy_local_registry.get_registered_service_for( + svc_type + ) + + if rs is None: + rs = self.registry.get_registered_service_for(svc_type) + svc = rs.factory(self) if rs.takes_container else rs.factory() return False, svc, rs.name, rs.enter + def register_local_factory( + self, + svc_type: type, + factory: Callable, + *, + enter: bool = True, + ping: Callable | None = None, + on_registry_close: Callable | Awaitable | None = None, + ) -> None: + """ + Same as :meth:`svcs.Registry.register_factory()`, but registers the + factory only for this container. + + A temporary :class:`svcs.Registry` is transparently created -- and + closed together with the container it belongs to. + + .. seealso:: :ref:`local-registries` + + .. versionadded:: 23.21.0 + """ + if self._lazy_local_registry is None: + self._lazy_local_registry = Registry() + + self._lazy_local_registry.register_factory( + svc_type=svc_type, + factory=factory, + enter=enter, + ping=ping, + on_registry_close=on_registry_close, + ) + + def register_local_value( + self, + svc_type: type, + value: object, + *, + enter: bool = False, + ping: Callable | None = None, + on_registry_close: Callable | Awaitable | None = None, + ) -> None: + """ + Syntactic sugar for:: + + register_local_factory( + svc_type, + lambda: value, + enter=enter, + ping=ping, + on_registry_close=on_registry_close + ) + + Please note that, unlike with :meth:`register_local_factory`, entering + context managers is **disabled** by default. + + .. seealso:: :ref:`local-registries` + + .. versionadded:: 23.21.0 + """ + self.register_local_factory( + svc_type, + lambda: value, + enter=enter, + ping=ping, + on_registry_close=on_registry_close, + ) + @overload def get(self, svc_type: type[T1], /) -> T1: ... diff --git a/tests/test_integration.py b/tests/test_integration.py index e6f3832..7f0e479 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -22,7 +22,7 @@ async_int_factory, async_str_gen_factory, ) -from .helpers import Annotated, nop +from .helpers import Annotated, CloseMe, nop from .ifaces import AnotherService, Interface, Service, YetAnotherService @@ -258,6 +258,43 @@ def test_get_on_async_factory_raises_type_error( ) == recwarn.pop().message.args +def test_local_value_overrides_global_value(registry, container): + """ + If a container registers a local value, it takes precedence of the global + registry. The local registry is created lazily and closed when the + container is closed. + """ + registry.register_value(int, 1) + + assert container._lazy_local_registry is None + + cm = CloseMe() + container.register_local_value(int, 2, on_registry_close=cm.close) + + assert container._lazy_local_registry._on_close + assert 2 == container.get(int) + + container.close() + + assert not container._lazy_local_registry._on_close + assert cm.is_closed + + +def test_local_registry_is_lazy_but_only_once(container): + """ + The local registry is created on first use and then kept using. + """ + assert container._lazy_local_registry is None + + container.register_local_value(int, 1) + + reg = container._lazy_local_registry + + container.register_local_value(int, 2) + + assert reg is container._lazy_local_registry + + @pytest.mark.asyncio() class TestAsync: async def test_async_factory(self, registry, container): @@ -476,3 +513,21 @@ async def factory(): assert 1 == i await container.aclose() + + async def test_local_factory_overrides_global_factory( + self, registry, container + ): + """ + A container-local factory takes precedence over a global one. An + aclosed container also acloses the registry. + """ + cm = CloseMe() + container.register_local_factory( + int, async_int_factory, on_registry_close=cm.aclose + ) + registry.register_value(int, 23) + + async with container: + assert 42 == await container.aget(int) + + assert cm.is_aclosed