From c6f28cd2eacc93df579993dc00c9cc72cfa1afb4 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 13:23:55 -0700 Subject: [PATCH] feat(container): type make() generically so call sites resolve concrete types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container.make() was unannotated, so pyright/basedpyright inferred `(name: Unknown, *arguments: Unknown) -> (Unknown | Any | None)` and every resolved service degraded to Unknown at the call site. Add overloads: a class key returns that class (`make(Foo) -> Foo`), a string key stays `Any` since it carries no static type information. The `| None` in the old inferred return came from `dict.get` on the swaps lookup, not from a reachable code path — make() raises on a missing key — so the typed signature is non-optional and call sites need no narrowing. Also annotate `_instance` / `set_instance` / `instance` so `Container.instance().make(...)` resolves too. Typing-only: no runtime logic changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EohxFkN7w7tuq71KmkUp1B --- .../fastapi_startkit/container/container.py | 25 +++++-- .../tests/core/test_container_typing.py | 66 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 fastapi_startkit/tests/core/test_container_typing.py 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