diff --git a/README.md b/README.md index 315776bf..2247f9c7 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ Override dependencies with context managers, keep tests isolated, and restore th - Scoped Dependency Injection Performance + <img
+  alt=

@@ -251,7 +252,7 @@ Wireup keeps the API small, but it is built for larger application graphs. | Interfaces and protocols | [`as_type=...`](https://maldoinc.github.io/wireup/latest/interfaces/) or factory return annotations | | Multiple implementations | [Qualifiers](https://maldoinc.github.io/wireup/latest/interfaces/) | | All implementations | [`Sequence[T]` or `Mapping[Hashable, T]`](https://maldoinc.github.io/wireup/latest/interfaces/#collection-injection) | -| Isolated scopes with explicit context sharing (batch jobs, fan-out tasks, multi-tenant processing) | [`container.enter_scope({...})`](https://maldoinc.github.io/wireup/latest/lifetimes_and_scopes/#sharing-context-across-scopes) | +| Isolated scopes with explicit context sharing (batch jobs, fan-out tasks) | [`container.enter_scope({...})`](https://maldoinc.github.io/wireup/latest/lifetimes_and_scopes/#sharing-context-across-scopes) | | Environment-specific graph | [Conditional registration](https://maldoinc.github.io/wireup/latest/conditional_registration/) with normal Python | | Generic repositories/services | [Generic dependencies](https://maldoinc.github.io/wireup/latest/generic_dependencies/) | | Modular or parametrized registration | [Reusable bundles](https://maldoinc.github.io/wireup/latest/reusable_bundles/) | @@ -431,7 +432,7 @@ Wireup decorators only collect metadata. Injectables are plain classes and funct Swap dependencies during tests with `container.override`: ```python -with container.override.injectable(target=Database, new=in_memory_database): +with container.override({Database: in_memory_database}): # Injectables that depend on Database will receive in_memory_database # for the duration of this context manager response = client.get("/users") diff --git a/docs/pages/container.md b/docs/pages/container.md index 3c14b2d8..6d8906bd 100644 --- a/docs/pages/container.md +++ b/docs/pages/container.md @@ -135,7 +135,7 @@ db_url = container.config.get("database_url") Substitute dependencies for testing. Access via `container.override`. ```python -with container.override.injectable(target=Database, new=mock_db): +with container.override({Database: mock_db}): ... # All injections of Database use mock_db ``` diff --git a/docs/pages/getting_started.md b/docs/pages/getting_started.md index e35bb838..81e9bbed 100644 --- a/docs/pages/getting_started.md +++ b/docs/pages/getting_started.md @@ -254,7 +254,7 @@ To substitute dependencies on targets such as views in a web application you can the fly. ```python -with container.override.injectable(WeatherService, new=test_weather_service): +with container.override({WeatherService: test_weather_service}): response = client.get("/weather/forecast") ``` diff --git a/docs/pages/integrations/aiohttp/index.md b/docs/pages/integrations/aiohttp/index.md index 4e02b4c2..f3fd40a7 100644 --- a/docs/pages/integrations/aiohttp/index.md +++ b/docs/pages/integrations/aiohttp/index.md @@ -149,10 +149,9 @@ def test_override(aiohttp_client): def greet(self, name: str) -> str: return f"Hi, {name}" - with get_app_container(app).override.injectable( - GreeterService, - new=DummyGreeter(), - ): + container = get_app_container(app) + + with container.override({GreeterService: DummyGreeter()}): res = aiohttp_client.get("/greet?name=Test") ``` diff --git a/docs/pages/integrations/asgi/generic_asgi.md b/docs/pages/integrations/asgi/generic_asgi.md index be9dac61..edf88307 100644 --- a/docs/pages/integrations/asgi/generic_asgi.md +++ b/docs/pages/integrations/asgi/generic_asgi.md @@ -80,9 +80,7 @@ def create_app(): def test_override(): app = create_app() - with app.state.container.override.injectable( - MyService, new=MyFakeService() - ): + with app.state.container.override({MyService: MyFakeService()}): ... ``` diff --git a/docs/pages/integrations/click/index.md b/docs/pages/integrations/click/index.md index 639aac86..cd57c56f 100644 --- a/docs/pages/integrations/click/index.md +++ b/docs/pages/integrations/click/index.md @@ -92,7 +92,7 @@ def test_random_number_command(): return 4 # Create test container with mocked service - with container.override.injectable(RandomService, new=MockRandomService()): + with container.override({RandomService: MockRandomService()}): runner = CliRunner() result = runner.invoke(cli, ["random-number"]) diff --git a/docs/pages/integrations/django/request_time_injection.md b/docs/pages/integrations/django/request_time_injection.md index 2a010a6e..b8321f07 100644 --- a/docs/pages/integrations/django/request_time_injection.md +++ b/docs/pages/integrations/django/request_time_injection.md @@ -101,4 +101,4 @@ Prefer `@inject` for request-time functions and `@inject_app` for non-request en See [Django Testing](testing.md) for endpoint tests and dependency overrides. These patterns are tested the same way: - call endpoints with `Client` or `AsyncClient` -- use `get_app_container().override.injectable(...)` to inject fakes +- use `get_app_container().override({...})` to inject fakes diff --git a/docs/pages/integrations/django/testing.md b/docs/pages/integrations/django/testing.md index feddb4ea..5594c4a1 100644 --- a/docs/pages/integrations/django/testing.md +++ b/docs/pages/integrations/django/testing.md @@ -62,9 +62,9 @@ def test_override_http_endpoint(): return f"Hi {name}" client = Client() - with get_app_container().override.injectable( - GreeterService, new=FakeGreeter() - ): + container = get_app_container() + + with container.override({GreeterService: FakeGreeter()}): response = client.get("/greet/?name=World") assert response.status_code == 200 @@ -87,9 +87,9 @@ def test_override_command(): return f"Hi {name}" out = StringIO() - with get_app_container().override.injectable( - GreeterService, new=FakeGreeter() - ): + container = get_app_container() + + with container.override({GreeterService: FakeGreeter()}): call_command("greet", "--name=World", stdout=out) assert out.getvalue().strip() == "Hi World" @@ -101,4 +101,4 @@ DRF and Ninja handlers should use `@inject` explicitly. Their tests follow the s - call endpoint via test client - assert response -- use `override.injectable(...)` when needed +- use `container.override({...})` when needed diff --git a/docs/pages/integrations/fastapi/class_based_handlers.md b/docs/pages/integrations/fastapi/class_based_handlers.md index 8b68d9c0..c1443bca 100644 --- a/docs/pages/integrations/fastapi/class_based_handlers.md +++ b/docs/pages/integrations/fastapi/class_based_handlers.md @@ -122,9 +122,9 @@ from wireup.integration.fastapi import get_app_container def test_user_handler(app): - with get_app_container(app).override.injectable( - UserProfileService, new=MockUserService() - ): + container = get_app_container(app) + + with container.override({UserProfileService: MockUserService()}): # Start the client INSIDE the override block # The handler is initialized with the mock during startup with TestClient(app) as client: @@ -133,11 +133,11 @@ def test_user_handler(app): ```python title="Don't" def test_user_handler_wrong(app): + container = get_app_container(app) + # Handler has already been instantiated at startup. with TestClient(app) as client: - with get_app_container(app).override.injectable( - UserProfileService, new=MockUserService() - ): + with container.override({UserProfileService: MockUserService()}): client.get("/users/") ``` diff --git a/docs/pages/integrations/fastapi/request_time_injection.md b/docs/pages/integrations/fastapi/request_time_injection.md index 562b9f05..4a30def2 100644 --- a/docs/pages/integrations/fastapi/request_time_injection.md +++ b/docs/pages/integrations/fastapi/request_time_injection.md @@ -173,11 +173,11 @@ def test_require_auth_denied(): def test_require_auth_allowed(): + container = get_app_container(app) + with TestClient(app) as client: # Override AuthService to return True - with get_app_container(app).override.injectable( - AuthService, new=MockAuthService(allow=True) - ): + with container.override({AuthService: MockAuthService(allow=True)}): response = client.get("/users") assert response.status_code == 200 ``` @@ -193,8 +193,7 @@ def test_request_middleware_runs(): !!! tip - Use `get_app_container(app).override.injectable()` to inject mocks and fakes during tests. This works for both route - decorators and middleware. + Use `get_app_container(app).override()` to inject mocks and fakes during tests. This works for both route decorators and middleware. ## Direct Container Access diff --git a/docs/pages/integrations/fastapi/testing.md b/docs/pages/integrations/fastapi/testing.md index 97fd366b..145f06eb 100644 --- a/docs/pages/integrations/fastapi/testing.md +++ b/docs/pages/integrations/fastapi/testing.md @@ -36,9 +36,9 @@ def test_override(app: FastAPI): def greet(self, name: str) -> str: return f"Hi, {name}" - with get_app_container(app).override.injectable( - GreeterService, new=DummyGreeter() - ): + container = get_app_container(app) + + with container.override({GreeterService: DummyGreeter()}): with TestClient(app) as client: response = client.get("/greet?name=Test") @@ -73,24 +73,22 @@ from fastapi import FastAPI from fastapi.testclient import TestClient import wireup import wireup.integration.fastapi -from wireup import InjectableOverride from wireup.integration.fastapi import get_app_container @contextlib.contextmanager def create_test_app( - overrides: list[InjectableOverride] | None = None, + overrides: dict[type[Any], Any] | None = None, ) -> Iterator[FastAPI]: app = create_app() # Create app, add routes, setup Wireup. + container = get_app_container(app) - with get_app_container(app).override.injectables(overrides or []): + with container.override(overrides or {}): yield app def test_user_handler_with_override(): - overrides = [ - InjectableOverride(target=UserProfileService, new=MockUserService()) - ] + overrides = {UserProfileService: MockUserService()} # Override first, then start app lifecycle. with create_test_app(overrides=overrides) as app: diff --git a/docs/pages/integrations/flask/index.md b/docs/pages/integrations/flask/index.md index 10b36812..33b9d55b 100644 --- a/docs/pages/integrations/flask/index.md +++ b/docs/pages/integrations/flask/index.md @@ -93,10 +93,9 @@ def test_override(): def greet(self, name: str) -> str: return f"Hi, {name}" - with get_app_container(app).override.injectable( - GreeterService, - new=DummyGreeter(), - ): + container = get_app_container(app) + + with container.override({GreeterService: DummyGreeter()}): res = self.client.get("/greet?name=Test") ``` diff --git a/docs/pages/integrations/starlette/index.md b/docs/pages/integrations/starlette/index.md index f646f21f..8f1a7c93 100644 --- a/docs/pages/integrations/starlette/index.md +++ b/docs/pages/integrations/starlette/index.md @@ -144,7 +144,7 @@ class UppercaseGreeter(GreeterService): def test_override(): container = get_app_container(app) - with container.override.injectable(GreeterService, new=UppercaseGreeter()): + with container.override({GreeterService: UppercaseGreeter()}): response = client.get("/hello", params={"name": "world"}) assert response.text == "HELLO WORLD" diff --git a/docs/pages/migrate_to_wireup/dependency_injector.md b/docs/pages/migrate_to_wireup/dependency_injector.md index 1e7d3dd1..73f4ed0a 100644 --- a/docs/pages/migrate_to_wireup/dependency_injector.md +++ b/docs/pages/migrate_to_wireup/dependency_injector.md @@ -841,7 +841,7 @@ Both libraries support context-manager based overrides in tests. service_mock = MagicMock(spec=UserService) - with container.override.injectable(UserService, new=service_mock): + with container.override({UserService: service_mock}): assert container.get(UserService) is service_mock # UserService is back to normal after the block. ``` diff --git a/docs/pages/migrate_to_wireup/fastapi_depends.md b/docs/pages/migrate_to_wireup/fastapi_depends.md index 8b1d2f90..071db42a 100644 --- a/docs/pages/migrate_to_wireup/fastapi_depends.md +++ b/docs/pages/migrate_to_wireup/fastapi_depends.md @@ -768,9 +768,7 @@ You still test FastAPI with def test_get_user(app: FastAPI): - with get_app_container(app).override.injectable( - UserService, new=FakeUserService() - ): + with get_app_container(app).override({UserService: FakeUserService()}): with TestClient(app) as client: response = client.get("/users/123") diff --git a/docs/pages/testing.md b/docs/pages/testing.md index 8abd2d37..99232457 100644 --- a/docs/pages/testing.md +++ b/docs/pages/testing.md @@ -93,7 +93,7 @@ class TestGreeter(GreeterService): def test_greet_with_override(container, client: TestClient) -> None: - with container.override.injectable(GreeterService, new=TestGreeter()): + with container.override({GreeterService: TestGreeter()}): response = client.get("/greet", params={"name": "World"}) assert response.status_code == 200 @@ -132,7 +132,7 @@ See [Lifetimes & Scopes](lifetimes_and_scopes.md) for the lifetime rules behind ## Override Dependencies -Use overrides when the app/container should stay real, but one dependency should be replaced for the test. +Use overrides when the app/container should stay real, but one or more dependencies should be replaced for the test. If you want to choose different registrations before the container is created, see [Conditional Registration](conditional_registration.md). @@ -144,49 +144,47 @@ applies to objects already created in the current scope. Apply overrides before the first resolution of the object you want to affect. If your integration resolves objects at startup, apply overrides before creating the test client or starting the app. -When using `as_type`, override the `as_type` target, not the concrete implementation. When overriding a qualified -dependency, include the qualifier in the override target. - -### Override One Dependency +Pass a mapping of dependency targets to replacement values to `container.override(...)`. Use the same API for one +override, several overrides, and qualified dependencies. ```python -import pytest from unittest.mock import MagicMock +from wireup import qualified + async def test_notification_service(container) -> None: fake_email_client = MagicMock(spec=EmailClient) - with container.override.injectable(EmailClient, new=fake_email_client): + with container.override({EmailClient: fake_email_client}): notifier = await container.get(NotificationService) notifier.send_welcome_email("alice@example.com") fake_email_client.send.assert_called_once() -``` - -### Override Multiple Dependencies - -When several injected dependencies should be replaced together, use `container.override.injectables(...)`. - -```python -import pytest -from unittest.mock import MagicMock -from wireup import InjectableOverride async def test_checkout(container) -> None: user_service_mock = MagicMock() order_service_mock = MagicMock() + overrides = { + UserService: user_service_mock, + OrderService: order_service_mock, + } - overrides = [ - InjectableOverride(target=UserService, new=user_service_mock), - InjectableOverride(target=OrderService, new=order_service_mock), - ] - - with container.override.injectables(overrides=overrides): + with container.override(overrides): checkout_service = await container.get(CheckoutService) + + +async def test_cache_refresh(container) -> None: + fake_cache = MagicMock(spec=Cache) + + with container.override({qualified(Cache, "redis"): fake_cache}): + cache_refresher = await container.get(CacheRefresher) ``` +When using `as_type`, override the `as_type` target, not the concrete implementation. When overriding a qualified +dependency, build the override key with `qualified(TargetType, qualifier)`. + ## Global Overrides with Fixtures If many tests need the same auth or user setup, put those Wireup overrides in a fixture instead of repeating them in @@ -208,20 +206,19 @@ class AllowAllAuth(AuthService): @pytest.fixture def app(request): app = create_app() - overrides = getattr(request, "param", []) + overrides = getattr(request, "param", {}) if not overrides: yield app return container = get_app_container(app) - with container.override.injectables(overrides=overrides): + with container.override(overrides): yield app ``` ```python title="test_admin.py" import pytest -from wireup import InjectableOverride from myapp.auth import AllowAllAuth, AuthenticatedUser, AuthService @@ -229,13 +226,10 @@ from myapp.auth import AllowAllAuth, AuthenticatedUser, AuthService @pytest.mark.parametrize( "app", [ - [ - InjectableOverride(target=AuthService, new=AllowAllAuth()), - InjectableOverride( - target=AuthenticatedUser, - new=AuthenticatedUser(id="test-user", is_admin=True), - ), - ] + { + AuthService: AllowAllAuth(), + AuthenticatedUser: AuthenticatedUser(id="test-user", is_admin=True), + } ], indirect=True, ) @@ -245,7 +239,7 @@ def test_admin_dashboard(client) -> None: assert response.status_code == 200 ``` -If most tests in a module need the same setup, you can apply the same override list from a shared fixture instead of +If most tests in a module need the same setup, you can apply the same override mapping from a shared fixture instead of repeating it in every test. ## Next Steps diff --git a/test/integration/aiohttp/test_aiohttp_integration.py b/test/integration/aiohttp/test_aiohttp_integration.py index 17a010d4..409000e7 100644 --- a/test/integration/aiohttp/test_aiohttp_integration.py +++ b/test/integration/aiohttp/test_aiohttp_integration.py @@ -108,7 +108,7 @@ async def test_webview(client: TestClient) -> None: async def test_override(client: TestClient, app: web.Application) -> None: - with get_app_container(app).override.injectable(GreeterService, new=CustomGreeter()): + with get_app_container(app).override({GreeterService: CustomGreeter()}): res = await client.get("/webview") body = await res.json() assert body == {"greeting": "Hoi, webview"} @@ -128,7 +128,7 @@ async def test_handler_override(aiohttp_client: Callable[[web.Application], Awai app = _create_app(middleware_mode=True) container = get_app_container(app) - with container.override.injectable(GreeterService, new=CustomGreeter()): + with container.override({GreeterService: CustomGreeter()}): client = await aiohttp_client(app()) res = await client.get("/handler/greet?name=Handler") diff --git a/test/integration/django/test_django_integration.py b/test/integration/django/test_django_integration.py index 44a4e94d..85b3712a 100644 --- a/test/integration/django/test_django_integration.py +++ b/test/integration/django/test_django_integration.py @@ -133,7 +133,7 @@ class RudeGreeter(GreeterService): def greet(self, name: str) -> str: return f"Bad day to you, {name}" - with get_app_container().override.injectable(GreeterService, new=RudeGreeter()): + with get_app_container().override({GreeterService: RudeGreeter()}): res = client.get("/classbased?name=Test") assert res.status_code == 200 @@ -303,7 +303,7 @@ def greet(self, name: str) -> str: return f"Guten Tag, {name}!" stdout = StringIO() - with get_app_container().override.injectable(GreeterService, new=GermanGreeter()): + with get_app_container().override({GreeterService: GermanGreeter()}): call_command("wireup_greet", "--name=Django", stdout=stdout) assert stdout.getvalue().strip() == "Guten Tag, Django!" @@ -387,7 +387,7 @@ def greet(self, name: str) -> str: return f"Go away, {name}" # WHEN using override context manager - with get_app_container().override.injectable(GreeterService, new=RudeGreeter()): + with get_app_container().override({GreeterService: RudeGreeter()}): res = client.get("/ninja/greet?name=Bob") # THEN the overridden service is used diff --git a/test/integration/fastapi/test_fastapi_integration.py b/test/integration/fastapi/test_fastapi_integration.py index 3c72a06e..b0c2acbc 100644 --- a/test/integration/fastapi/test_fastapi_integration.py +++ b/test/integration/fastapi/test_fastapi_integration.py @@ -105,7 +105,7 @@ class RealRandom(RandomService): def get_random(self) -> int: return super().get_random() ** 2 - with get_app_container(app).override.injectable(RandomService, new=RealRandom()): + with get_app_container(app).override({RandomService: RealRandom()}): response = client.get("/rng") assert response.status_code == 200 assert response.json() == {"number": 16} @@ -577,7 +577,7 @@ def get_random(self) -> int: new_instance = FakeRandomService() - with get_app_container(app).override.injectable(RandomService, new=new_instance), TestClient(app) as client: + with get_app_container(app).override({RandomService: new_instance}), TestClient(app) as client: res = client.get("/cbr") assert res.json() == {"counter": 1, "random": 100} diff --git a/test/integration/flask/test_flask_integration.py b/test/integration/flask/test_flask_integration.py index 54efc423..0d79aeda 100644 --- a/test/integration/flask/test_flask_integration.py +++ b/test/integration/flask/test_flask_integration.py @@ -72,7 +72,7 @@ def test_service_override(client: FlaskClient, app: Flask): mocked_foo = MagicMock() mocked_foo.is_test = "mocked" - with get_app_container(app).override.injectable(IsTestService, new=mocked_foo): + with get_app_container(app).override({IsTestService: mocked_foo}): res = client.get("/foo") assert res.status_code == 200 assert res.json == {"test": "mocked"} diff --git a/test/integration/starlette/test_starlette_integration.py b/test/integration/starlette/test_starlette_integration.py index 9307dfe1..08b7d67b 100644 --- a/test/integration/starlette/test_starlette_integration.py +++ b/test/integration/starlette/test_starlette_integration.py @@ -119,7 +119,7 @@ class UppercaseGreeter(GreeterService): def greet(self, name: str) -> str: return super().greet(name).upper() - with get_app_container(app).override.injectable(GreeterService, new=UppercaseGreeter()): + with get_app_container(app).override({GreeterService: UppercaseGreeter()}): response = client.get("/hello", params={"name": "Test"}) assert response.text == "HELLO TEST" diff --git a/test/unit/test_container_collection_injection.py b/test/unit/test_container_collection_injection.py index cd42c352..3223bd54 100644 --- a/test/unit/test_container_collection_injection.py +++ b/test/unit/test_container_collection_injection.py @@ -82,7 +82,7 @@ def source(self) -> str: override_caches = (OverrideCache(), RedisCache()) assert [cache.source() for cache in container.get(Sequence[Cache])] == ["memory", "redis"] - with container.override.injectable(Sequence[Cache], new=override_caches): + with container.override({Sequence[Cache]: override_caches}): assert [cache.source() for cache in container.get(Sequence[Cache])] == ["override", "redis"] assert [cache.source() for cache in container.get(Sequence[Cache])] == ["memory", "redis"] @@ -266,7 +266,7 @@ def test_can_override_mapping_directly() -> None: override_map = {"override": RedisCache()} assert set(container.get(Mapping[Hashable, Cache]).keys()) == {None, "redis"} - with container.override.injectable(Mapping[Hashable, Cache], new=override_map): + with container.override({Mapping[Hashable, Cache]: override_map}): assert container.get(Mapping[Hashable, Cache]) is override_map assert set(container.get(Mapping[Hashable, Cache]).keys()) == {None, "redis"} diff --git a/test/unit/test_container_override.py b/test/unit/test_container_override.py index 4381aef9..1ff08e0a 100644 --- a/test/unit/test_container_override.py +++ b/test/unit/test_container_override.py @@ -6,7 +6,15 @@ import pytest import wireup -from wireup import Inject, abstract, create_async_container, create_sync_container, inject_from_container, injectable +from wireup import ( + Inject, + abstract, + create_async_container, + create_sync_container, + inject_from_container, + injectable, + qualified, +) from wireup._annotations import Injected from wireup.errors import UnknownOverrideRequestedError, WireupError from wireup.ioc.types import InjectableLifetime, InjectableOverride, get_container_object_id @@ -94,7 +102,7 @@ async def async_foo_factory() -> Foo: container = wireup.create_sync_container(injectables=[async_foo_factory]) - with container.override.injectable(Foo, MagicMock(spec=Foo)): + with container.override({Foo: MagicMock(spec=Foo)}): pass with pytest.raises( @@ -118,7 +126,7 @@ async def async_foo_factory() -> Foo: container = wireup.create_sync_container(injectables=[async_foo_factory]) - with container.override.injectable(Foo, falsy_override): + with container.override({Foo: falsy_override}): assert container.get(Foo) is falsy_override @@ -132,10 +140,10 @@ async def async_foo_factory() -> Foo: outer = MagicMock(spec=Foo) inner = MagicMock(spec=Foo) - with container.override.injectable(Foo, new=outer): + with container.override({Foo: outer}): assert container.get(Foo) is outer - with container.override.injectable(Foo, new=inner): + with container.override({Foo: inner}): assert container.get(Foo) is inner assert container.get(Foo) is outer @@ -143,12 +151,94 @@ async def async_foo_factory() -> Foo: def test_clear_on_empty_stack_should_not_raise(container: Container): container = wireup.create_sync_container(injectables=[FooImpl]) - with container.override.injectable(FooImpl, new=MagicMock()): + with container.override({FooImpl: MagicMock()}): pass container.override.clear() +def test_injectable_override_warns_and_still_works(container: Container): + container = wireup.create_sync_container(injectables=[Foo, FooImpl]) + override = MagicMock(spec=Foo) + + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectable\(\)/service\(\) is deprecated"): + with container.override.injectable(Foo, new=override): + assert container.get(Foo) is override + + +def test_injectables_override_warns_and_still_works(container: Container): + container = wireup.create_sync_container(injectables=[Foo, FooImpl]) + override = MagicMock(spec=Foo) + overrides = [InjectableOverride(target=Foo, new=override)] + + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectables\(\)/services\(\) is deprecated"): + with container.override.injectables(overrides): + assert container.get(Foo) is override + + +def test_service_override_warns_and_still_works(container: Container): + container = wireup.create_sync_container(injectables=[Foo, FooImpl]) + override = MagicMock(spec=Foo) + + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectable\(\)/service\(\) is deprecated"): + with container.override.service(Foo, new=override): + assert container.get(Foo) is override + + +def test_services_override_warns_and_still_works(container: Container): + container = wireup.create_sync_container(injectables=[Foo, FooImpl]) + override = MagicMock(spec=Foo) + overrides = [InjectableOverride(target=Foo, new=override)] + + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectables\(\)/services\(\) is deprecated"): + with container.override.services(overrides): + assert container.get(Foo) is override + + +def test_deprecated_injectable_override_supports_qualifier(container: Container): + container = wireup.create_sync_container(injectables=[random_service_factory]) + override = MagicMock(spec=RandomService) + override.get_random.return_value = 99 + + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectable\(\)/service\(\) is deprecated"): + with container.override.injectable(RandomService, new=override, qualifier="foo"): + assert container.get(RandomService, qualifier="foo") is override + assert container.get(RandomService, qualifier="foo").get_random() == 99 + + assert container.get(RandomService, qualifier="foo").get_random() == 4 + + +def test_deprecated_injectables_override_multiple_with_qualifier(container: Container): + container = wireup.create_sync_container(injectables=[Foo, FooImpl, random_service_factory]) + foo_override = MagicMock(spec=Foo) + random_override = MagicMock(spec=RandomService) + overrides = [ + InjectableOverride(target=Foo, new=foo_override), + InjectableOverride(target=RandomService, new=random_override, qualifier="foo"), + ] + + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectables\(\)/services\(\) is deprecated"): + with container.override.injectables(overrides): + assert container.get(Foo) is foo_override + assert container.get(RandomService, qualifier="foo") is random_override + + assert container.get(Foo).get_foo() == "foo" + assert container.get(RandomService, qualifier="foo").get_random() == 4 + + +def test_deprecated_override_restores_after_exception(container: Container): + container = wireup.create_sync_container(injectables=[Foo, FooImpl]) + override = MagicMock(spec=Foo) + + with pytest.raises(RuntimeError, match="boom"): + with pytest.warns(DeprecationWarning, match=r"container\.override\.injectable\(\)/service\(\) is deprecated"): + with container.override.service(Foo, new=override): + assert container.get(Foo) is override + raise RuntimeError("boom") + + assert container.get(Foo).get_foo() == "foo" + + def test_clear_actually_clears_overrides(container: Container): @wireup.injectable class Foo: @@ -175,19 +265,19 @@ def test_nested_injectable_overrides(container: Container): mock1 = MagicMock() mock1.get_foo.return_value = "foo mocked 1" - with container.override.injectable(Foo, new=mock1): + with container.override({Foo: mock1}): assert container.get(Foo).get_foo() == "foo mocked 1" mock2 = MagicMock() mock2.get_foo.return_value = "foo mocked 2" - with container.override.injectable(Foo, new=mock2): + with container.override({Foo: mock2}): assert container.get(Foo).get_foo() == "foo mocked 2" mock3 = MagicMock() mock3.get_foo.return_value = "foo mocked 3" - with container.override.injectable(Foo, new=mock3): + with container.override({Foo: mock3}): assert container.get(Foo).get_foo() == "foo mocked 3" assert container.get(Foo).get_foo() == "foo mocked 2" @@ -207,7 +297,7 @@ def test_container_overrides_deps_service_locator(container: Container): def get_random_via_inject(svc: Annotated[RandomService, Inject(qualifier="foo")]) -> int: return svc.get_random() - with container.override.injectable(target=RandomService, qualifier="foo", new=random_mock): + with container.override({qualified(RandomService, "foo"): random_mock}): svc = container.get(RandomService, qualifier="foo") assert svc.get_random() == 5 assert get_random_via_inject() == 5 @@ -227,7 +317,7 @@ async def test_container_overrides_deps_service_locator_interface(): def get_foo_via_inject(svc: Injected[Foo]) -> str: return svc.get_foo() - with container.override.injectable(target=Foo, new=foo_mock): + with container.override({Foo: foo_mock}): svc = await run(container.get(Foo)) assert svc.get_foo() == "mock" assert get_foo_via_inject() == "mock" @@ -241,17 +331,12 @@ async def test_container_override_many_with_qualifier(container: Container): rand1_mock = MagicMock() rand2_mock = MagicMock() - overrides = [ - InjectableOverride(target=ScopedService, new=rand1_mock), - InjectableOverride(target=TransientService, new=rand2_mock), - ] - @wireup.inject_from_container(container) def target(scoped: Injected[ScopedService], transient: Injected[TransientService]) -> None: assert scoped is rand1_mock assert transient is rand2_mock - with container.override.injectables(overrides=overrides): + with container.override({ScopedService: rand1_mock, TransientService: rand2_mock}): target() @@ -260,7 +345,7 @@ async def test_raises_on_unknown_override(container: Container): UnknownOverrideRequestedError, match=re.escape(f"Cannot override unknown {unittest.TestCase!r} with qualifier 'foo'."), ): - with container.override.injectable(target=unittest.TestCase, qualifier="foo", new=MagicMock()): + with container.override({qualified(unittest.TestCase, "foo"): MagicMock()}): pass @@ -279,7 +364,7 @@ def source(self) -> str: UnknownOverrideRequestedError, match=r"Wireup collection injection uses collections\.abc\.Sequence\[.*Cache.*\], not typing\.Sequence\[.*Cache.*\]", # noqa: E501 ): - with container.override.injectable(target=typing.Sequence[Cache], new=()): + with container.override({typing.Sequence[Cache]: ()}): pass @@ -298,7 +383,7 @@ def source(self) -> str: UnknownOverrideRequestedError, match=r"Wireup collection injection uses collections\.abc\.Mapping\[.*Cache.*\], not typing\.Mapping\[.*Cache.*\]", # noqa: E501 ): - with container.override.injectable(target=typing.Mapping[str, Cache], new={}): + with container.override({typing.Mapping[str, Cache]: {}}): pass @@ -323,7 +408,7 @@ async def resolve_foo() -> FooBar: foo_mock = MagicMock() foo_mock.foo = "mock" - with container.override.injectable(target=FooBar, new=foo_mock): + with container.override({FooBar: foo_mock}): svc = await resolve_foo() assert svc.foo == "mock" assert await get_foobar_via_inject() == "mock" @@ -355,7 +440,7 @@ class FooOverride: async def get_bar_via_inject(bar: Injected[BarDep]) -> BarDep: return bar - with container.override.injectable(FooDep, FooOverride()): + with container.override({FooDep: FooOverride()}): bar = await container.get(BarDep) assert isinstance(bar.foo, FooOverride) bar_via_inject = await get_bar_via_inject() @@ -392,7 +477,7 @@ async def resolve_consumer() -> AsyncOverrideConsumer: async with container.enter_scope() as scope: return await scope.get(AsyncOverrideConsumer) - with container.override.injectable(AsyncOverrideDep, AsyncOverride()): + with container.override({AsyncOverrideDep: AsyncOverride()}): foo = await resolve_foo() assert isinstance(foo, AsyncOverride) assert isinstance(await get_foo_via_inject(), AsyncOverride) @@ -431,7 +516,7 @@ def resolve_consumer() -> SyncOverrideConsumer: with container.enter_scope() as scope: return scope.get(SyncOverrideConsumer) - with container.override.injectable(SyncOverrideDep, SyncOverride()): + with container.override({SyncOverrideDep: SyncOverride()}): foo = resolve_foo() assert isinstance(foo, SyncOverride) assert isinstance(get_foo_via_inject(), SyncOverride) @@ -452,7 +537,7 @@ def make_optional() -> OptionalDep | None: container = create_sync_container(injectables=[make_optional]) override = MagicMock(spec=OptionalDep) - with container.override.injectable(Optional[OptionalDep], new=override): # noqa: UP045 + with container.override({Optional[OptionalDep]: override}): # noqa: UP045 assert container.get(OptionalDep | None) is override assert container.get(Optional[OptionalDep]) is override # noqa: UP045 @@ -469,7 +554,7 @@ def make_optional() -> Optional[OptionalDep]: # noqa: UP045 container = create_sync_container(injectables=[make_optional]) override = MagicMock(spec=OptionalDep) - with container.override.injectable(OptionalDep | None, new=override): + with container.override({OptionalDep | None: override}): assert container.get(OptionalDep | None) is override assert container.get(Optional[OptionalDep]) is override # noqa: UP045 @@ -515,7 +600,7 @@ def test_override_abstract_direct(): def get_abstract_via_inject(svc: Injected[AbstractBase]) -> AbstractBase: return svc - with container.override.injectable(target=AbstractBase, new=mock_obj): + with container.override({AbstractBase: mock_obj}): assert container.get(AbstractBase) is mock_obj assert get_abstract_via_inject() is mock_obj @@ -532,7 +617,7 @@ def test_override_abstract_indirect(): def get_svc_via_inject(svc: Injected[ServiceDependsOnAbstract]) -> ServiceDependsOnAbstract: return svc - with container.override.injectable(target=AbstractBase, new=mock_obj): + with container.override({AbstractBase: mock_obj}): with container.enter_scope() as scope: svc = scope.get(ServiceDependsOnAbstract) assert svc.dep is mock_obj @@ -553,7 +638,7 @@ async def test_override_abstract_indirect_async(): async def get_svc_via_inject(svc: Injected[ServiceDependsOnAbstract]) -> ServiceDependsOnAbstract: return svc - with container.override.injectable(target=AbstractBase, new=mock_obj): + with container.override({AbstractBase: mock_obj}): async with container.enter_scope() as scope: svc = await scope.get(ServiceDependsOnAbstract) assert svc.dep is mock_obj @@ -574,7 +659,7 @@ def test_override_as_type_direct(): def get_proto_via_inject(svc: Injected[Proto]) -> Proto: return svc - with container.override.injectable(target=Proto, new=mock_obj): + with container.override({Proto: mock_obj}): assert container.get(Proto) is mock_obj assert get_proto_via_inject() is mock_obj @@ -591,7 +676,7 @@ def test_override_as_type_indirect(): def get_svc_via_inject(svc: Injected[ServiceDependsOnProto]) -> ServiceDependsOnProto: return svc - with container.override.injectable(target=Proto, new=mock_obj): + with container.override({Proto: mock_obj}): with container.enter_scope() as scope: svc = scope.get(ServiceDependsOnProto) assert svc.dep is mock_obj @@ -612,7 +697,7 @@ async def test_override_as_type_indirect_async(): async def get_svc_via_inject(svc: Injected[ServiceDependsOnProto]) -> ServiceDependsOnProto: return svc - with container.override.injectable(target=Proto, new=mock_obj): + with container.override({Proto: mock_obj}): async with container.enter_scope() as scope: svc = await scope.get(ServiceDependsOnProto) assert svc.dep is mock_obj @@ -632,7 +717,7 @@ def test_override_restores_singleton_rebound_factory_sync(): rebound_factory = container._compiler.factories[obj_id].factory mock_obj = MagicMock(spec=Foo) - with container.override.injectable(target=Foo, new=mock_obj): + with container.override({Foo: mock_obj}): assert container.get(Foo) is mock_obj assert container._compiler.factories[obj_id].factory is not rebound_factory @@ -652,7 +737,7 @@ async def async_foo_factory() -> FooBar: rebound_factory = container._compiler.factories[obj_id].factory mock_obj = MagicMock() - with container.override.injectable(target=FooBar, new=mock_obj): + with container.override({FooBar: mock_obj}): assert await container.get(FooBar) is mock_obj assert container._compiler.factories[obj_id].factory is not rebound_factory @@ -673,9 +758,9 @@ class QualifiedSingleton: outer = MagicMock(spec=QualifiedSingleton) inner = MagicMock(spec=QualifiedSingleton) - with container.override.injectable(target=QualifiedSingleton, qualifier=0, new=outer): + with container.override({qualified(QualifiedSingleton, 0): outer}): assert container.get(QualifiedSingleton, qualifier=0) is outer - with container.override.injectable(target=QualifiedSingleton, qualifier=0, new=inner): + with container.override({qualified(QualifiedSingleton, 0): inner}): assert container.get(QualifiedSingleton, qualifier=0) is inner assert container.get(QualifiedSingleton, qualifier=0) is outer assert container._compiler.factories[obj_id].factory is not rebound_factory diff --git a/test/unit/test_container_scope.py b/test/unit/test_container_scope.py index d39c79b8..f9c4edc5 100644 --- a/test/unit/test_container_scope.py +++ b/test/unit/test_container_scope.py @@ -1,10 +1,13 @@ +import warnings from collections.abc import Iterator +from unittest.mock import MagicMock import pytest import wireup -from wireup._annotations import injectable +from wireup import create_sync_container, injectable from wireup.ioc.container.async_container import ScopedAsyncContainer from wireup.ioc.container.sync_container import ScopedSyncContainer +from wireup.util import qualified from test.unit.services.with_annotations.services import TransientService @@ -103,6 +106,57 @@ def factory() -> Iterator[SomeService]: assert done +@injectable +class Database: + def __init__(self) -> None: + self.name = "default_db" + + +@injectable +class UserService: + def __init__(self, db: Database) -> None: + self.db = db + + def get_db_name(self) -> str: + return self.db.name + + +def test_override_dict(): + @injectable(qualifier="cache") + def cache_db_factory() -> Database: + db = Database() + db.name = "cache" + return db + + container = create_sync_container(injectables=[Database, UserService, cache_db_factory]) + + mock_db = MagicMock() + mock_cache = MagicMock() + + with container.override({Database: mock_db, qualified(Database, "cache"): mock_cache}): + assert container.get(Database) is mock_db + assert container.get(Database, qualifier="cache") is mock_cache + + +def test_empty_dict_override(): + container = create_sync_container(injectables=[Database, UserService]) + + with container.override({}): + service = container.get(UserService) + assert service.get_db_name() == "default_db" + + +def test_dict_override_does_not_warn(): + container = create_sync_container(injectables=[Database, UserService]) + + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + with container.override({Database: MagicMock()}): + pass + + assert recorded == [] + + def test_scoped_qualifiers_do_not_collide_on_hash() -> None: @injectable(lifetime="scoped", qualifier=-2) def make_b1() -> int: diff --git a/test/unit/test_inject_from_container.py b/test/unit/test_inject_from_container.py index 12b12d1e..cf4a950c 100644 --- a/test/unit/test_inject_from_container.py +++ b/test/unit/test_inject_from_container.py @@ -131,7 +131,7 @@ class OverrideDep: pass override = OverrideDep() - with container.override.injectable(Dep, override): + with container.override({Dep: override}): dep, consumer = await target() assert dep is override assert consumer.dep is override @@ -461,7 +461,7 @@ def sync_func_success(a: Injected[AsyncDependency]) -> AsyncDependency: def test_async_override_with_sync_value_in_sync_context(container: Container) -> None: fake_b = AsyncDependency() - with container.override.injectable(AsyncDependency, fake_b): + with container.override({AsyncDependency: fake_b}): @inject_from_container(container) def sync_func_override(b: Injected[AsyncDependency]) -> AsyncDependency: diff --git a/wireup/ioc/container/base_container.py b/wireup/ioc/container/base_container.py index ea2f4e9d..81446f3c 100644 --- a/wireup/ioc/container/base_container.py +++ b/wireup/ioc/container/base_container.py @@ -84,7 +84,43 @@ def config(self) -> ConfigStore: @property def override(self) -> OverrideManager: - """Override registered container injectables with new values.""" + """Override injectables at runtime, typically for testing. + + Accepts a mapping from injectable types to replacement values: + + ```python + with container.override({Database: mock_db}): + ... # All injections of Database use mock_db + ``` + + Override multiple at once: + + ```python + with container.override({ + UserService: mock_user_service, + EmailClient: mock_email_client, + }): + ... + ``` + + For qualified injectables, build the key with ``qualified()``: + + ```python + from wireup import qualified + + with container.override({qualified(Database, "cache"): mock_cache_db}): + ... + ``` + + Also provides ``.set()``, ``.delete()``, and ``.clear()`` methods + for manual control outside a context manager. + + Overrides only affect *future* injection requests. Already-created singletons or scoped + objects are not rebuilt when their dependencies are overridden. + Apply overrides before the first resolution of the object you want to affect. + + See: https://maldoinc.github.io/wireup/latest/testing/ + """ return self._override_mgr @overload diff --git a/wireup/ioc/override_manager.py b/wireup/ioc/override_manager.py index 56585a41..c537d438 100644 --- a/wireup/ioc/override_manager.py +++ b/wireup/ioc/override_manager.py @@ -8,12 +8,12 @@ from wireup.errors import UnknownOverrideRequestedError from wireup.ioc.factory_compiler import CompiledFactory, FactoryCompiler -from wireup.ioc.types import get_container_object_id +from wireup.ioc.types import InjectableOverride, Qualifier, get_container_object_id if TYPE_CHECKING: - from collections.abc import Callable, Iterator + from collections.abc import Callable, Iterator, Mapping - from wireup.ioc.types import ContainerObjectIdentifier, InjectableOverride, Qualifier + from wireup.ioc.types import ContainerObjectIdentifier @dataclass(slots=True) @@ -43,6 +43,24 @@ def __init__( self._scoped_factory_compiler = scoped_factory_compiler self._original_factories: dict[ContainerObjectIdentifier, list[_OverrideFrame]] = defaultdict(list) + @contextmanager + def __call__(self, overrides: Mapping[Any, Any]) -> Iterator[None]: + parsed_overrides: list[tuple[type[Any], Qualifier | None, Any]] = [] + for key, new in overrides.items(): + if isinstance(key, tuple): + target, qualifier = key # pyright: ignore[reportUnknownVariableType] + else: + target, qualifier = key, None + + parsed_overrides.append((target, qualifier, new)) # pyright: ignore[reportUnknownArgumentType] + try: + for target, qualifier, new in parsed_overrides: + self.set(target, new, qualifier) + yield + finally: + for target, qualifier, _ in parsed_overrides: + self.delete(target, qualifier) + def _compiler_override_obj_id( self, compiler: FactoryCompiler, @@ -163,56 +181,36 @@ def clear(self) -> None: def injectable(self, target: type, new: Any, qualifier: Qualifier | None = None) -> Iterator[None]: """Override the `target` injectable with `new` for the duration of the context manager. - Future requests to inject `target` will result in `new` being injected. + Deprecated: Use `container.override` instead. :param target: The target injectable to override. :param qualifier: The qualifier of the injectable to override. Set this if injectable is registered with the qualifier parameter set to a value. :param new: The new object to be injected instead of `target`. """ - try: - self.set(target, new, qualifier) + warnings.warn( + "container.override.injectable()/service() is deprecated. Use container.override() instead.", + DeprecationWarning, + stacklevel=2, + ) + with self({get_container_object_id(target, qualifier): new}): yield - finally: - self.delete(target, qualifier) @contextmanager def injectables(self, overrides: list[InjectableOverride]) -> Iterator[None]: - """Override a number of injectables with new for the duration of the context manager.""" - try: - for override in overrides: - self.set(override.target, override.new, override.qualifier) - yield - finally: - for override in overrides: - self.delete(override.target, override.qualifier) + """Override a number of injectables with new for the duration of the context manager. - @contextmanager - def service(self, target: type, new: Any, qualifier: Qualifier | None = None) -> Iterator[None]: - """Override the `target` injectable with `new` for the duration of the context manager. - - Future requests to inject `target` will result in `new` being injected. - - :param target: The target injectable to override. - :param qualifier: The qualifier of the injectable to override. Set this if injectable is registered - with the qualifier parameter set to a value. - :param new: The new object to be injected instead of `target`. + Deprecated: Use `container.override` instead. """ warnings.warn( - "Services are now called Injectables. Use container.override.injectable() instead.", - FutureWarning, + "container.override.injectables()/services() is deprecated. Use container.override() instead.", + DeprecationWarning, stacklevel=2, ) - with self.injectable(target, new, qualifier): + with self( + {get_container_object_id(override.target, override.qualifier): override.new for override in overrides} + ): yield - @contextmanager - def services(self, overrides: list[InjectableOverride]) -> Iterator[None]: - """Override a number of injectables with new for the duration of the context manager.""" - warnings.warn( - "Services are now called Injectables. Use container.override.injectables() instead.", - FutureWarning, - stacklevel=2, - ) - with self.injectables(overrides): - yield + service = injectable + services = injectables