Skip to content
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
25 changes: 19 additions & 6 deletions fastapi_startkit/src/fastapi_startkit/container/container.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
"""Core of the IOC Container."""

import inspect
from typing import Any
from typing import Any, ClassVar, TypeVar, overload

from ..exceptions import (
ContainerError,
MissingContainerBindingNotFound,
StrictContainerException,
)

T = TypeVar("T")


class Container:
"""Core of the Service 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
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions fastapi_startkit/tests/core/test_container_typing.py
Original file line number Diff line number Diff line change
@@ -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
Loading