diff --git a/fastapi_startkit/src/fastapi_startkit/container/container.py b/fastapi_startkit/src/fastapi_startkit/container/container.py index 0ec26652..146b58a9 100644 --- a/fastapi_startkit/src/fastapi_startkit/container/container.py +++ b/fastapi_startkit/src/fastapi_startkit/container/container.py @@ -1,7 +1,7 @@ """Core of the IOC Container.""" import inspect -from typing import Any +from typing import Any, ClassVar, TypeVar, overload from ..exceptions import ( ContainerError, @@ -9,6 +9,8 @@ StrictContainerException, ) +T = TypeVar("T") + class Container: """Core of the Service Container. @@ -16,14 +18,14 @@ class Container: Performs bindings and resolving of objects to and from the container. """ - _instance = None + _instance: ClassVar["Container | None"] = None @classmethod - def set_instance(cls, instance): + def set_instance(cls, instance: "Container") -> None: cls._instance = instance @classmethod - def instance(cls): + def instance(cls) -> "Container": if cls._instance is None: raise RuntimeError("Container not initialized") return cls._instance @@ -103,11 +105,22 @@ def singleton(self, name, class_obj): obj = self.resolve(class_obj) self.bind(name, obj) - def make(self, name, *arguments): + @overload + def make(self, name: type[T], *arguments: Any) -> T: ... + + @overload + def make(self, name: str, *arguments: Any) -> Any: ... + + def make(self, name: type[T] | str, *arguments: Any) -> Any: """Retrieve a class from the container by key. + A class key resolves to an instance of that class, so `make(Foo)` is + typed as `Foo`. A string key carries no static type information, so it + stays `Any` and callers annotate the binding themselves. The return is + never `None`: a missing key raises instead. + Arguments: - name {string} -- Key in the container that you want to get. + name {string | type} -- Key in the container that you want to get. Raises: MissingContainerBindingNotFound -- Raised if the key is not in the container. diff --git a/fastapi_startkit/tests/core/test_container_typing.py b/fastapi_startkit/tests/core/test_container_typing.py new file mode 100644 index 00000000..44e4ff12 --- /dev/null +++ b/fastapi_startkit/tests/core/test_container_typing.py @@ -0,0 +1,66 @@ +"""Typing guarantees of Container.make(). + +The `assert_type` calls here are checked statically: + + uv run pyright tests/core/test_container_typing.py + +They are no-ops at runtime, so each one is paired with a runtime assertion that +the value really is what the annotation promises. +""" + +from typing import Any, assert_type + +import pytest + +from fastapi_startkit.container.container import Container +from fastapi_startkit.exceptions import MissingContainerBindingNotFound + + +class Mailer: + def send(self) -> str: + return "sent" + + +@pytest.fixture +def container() -> Container: + return Container() + + +class TestMakeTyping: + def test_class_key_resolves_to_that_class(self, container: Container): + container.bind(Mailer, Mailer()) + + mailer = container.make(Mailer) + + assert_type(mailer, Mailer) + assert isinstance(mailer, Mailer) + assert mailer.send() == "sent" + + def test_unbound_class_key_still_resolves_to_that_class(self, container: Container): + mailer = container.make(Mailer) + + assert_type(mailer, Mailer) + assert isinstance(mailer, Mailer) + + def test_string_key_stays_any(self, container: Container): + container.bind("mailer", Mailer()) + + mailer = container.make("mailer") + + assert_type(mailer, Any) + assert isinstance(mailer, Mailer) + + def test_missing_string_key_raises_instead_of_returning_none(self, container: Container): + with pytest.raises(MissingContainerBindingNotFound): + container.make("nope") + + def test_instance_returns_a_container(self): + original = Container._instance + try: + c = Container() + Container.set_instance(c) + + assert_type(Container.instance(), Container) + assert Container.instance() is c + finally: + Container._instance = original