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

Add container-local registries #56

Merged
merged 7 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.


Expand Down
89 changes: 85 additions & 4 deletions docs/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
:::


Expand All @@ -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!
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 88 additions & 6 deletions src/svcs/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AbstractContextManager,
asynccontextmanager,
contextmanager,
suppress,
)
from inspect import (
isasyncgenfunction,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
...
Expand Down
57 changes: 56 additions & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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