diff --git a/conftest.py b/conftest.py index 3ebf79f..d07704b 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ from sybil import Sybil from sybil.parsers import myst, rest +from tests.ifaces import Service import svcs @@ -32,6 +33,16 @@ pytest_collect_file = (markdown_examples + rest_examples).pytest() +@pytest.fixture(name="svc") +def _svc(): + return Service() + + +@pytest.fixture(name="rs") +def _rs(svc): + return svcs.RegisteredService(Service, Service, False, False, None) + + @pytest.fixture(name="registry") def _registry(): return svcs.Registry() diff --git a/tests/ifaces.py b/tests/ifaces.py new file mode 100644 index 0000000..40eaf66 --- /dev/null +++ b/tests/ifaces.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2023 Hynek Schlawack +# +# SPDX-License-Identifier: MIT + +""" +Interfaces used throughout the tests. They're dataclasses so they have a +predicatable repr. +""" + +import dataclasses + + +@dataclasses.dataclass +class Service: + pass + + +@dataclasses.dataclass +class AnotherService: + pass + + +@dataclasses.dataclass +class YetAnotherService: + pass diff --git a/tests/test_async.py b/tests/test_async.py deleted file mode 100644 index 18747fa..0000000 --- a/tests/test_async.py +++ /dev/null @@ -1,188 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Hynek Schlawack -# -# SPDX-License-Identifier: MIT - -import asyncio - -from dataclasses import dataclass - -import pytest - - -@dataclass -class Service: - pass - - -@dataclass -class AnotherService: - pass - - -@dataclass -class YetAnotherService: - pass - - -@pytest.mark.asyncio() -class TestAsync: - async def test_async_factory(self, registry, container): - """ - A factory can be async. - """ - - async def factory(): - await asyncio.sleep(0) - return Service() - - registry.register_factory(Service, factory) - - svc = await container.aget(Service) - - assert isinstance(svc, Service) - assert svc is (await container.aget(Service)) - - async def test_aget_works_with_sync_factory(self, registry, container): - """ - A synchronous factory does not break aget(). - """ - registry.register_factory(Service, Service) - - assert Service() == (await container.aget(Service)) - - async def test_aget_works_with_value(self, registry, container): - """ - A value instead of a factory does not break aget(). - """ - registry.register_value(Service, 42) - - assert 42 == (await container.aget(Service)) - - async def test_async_cleanup(self, registry, container): - """ - Async cleanups are handled by aclose. - """ - cleaned_up = False - - async def factory(): - nonlocal cleaned_up - await asyncio.sleep(0) - - yield Service() - - await asyncio.sleep(0) - cleaned_up = True - - registry.register_factory(Service, factory) - - svc = await container.aget(Service) - - assert 1 == len(container._on_close) - assert Service() == svc - assert not cleaned_up - - await container.aclose() - - assert cleaned_up - assert not container._instantiated - assert not container._on_close - - @pytest.mark.asyncio() - async def test_aclose_resilient(self, container, registry, caplog): - """ - Failing cleanups are logged and ignored. They do not break the - cleanup process. - """ - - def factory(): - yield 1 - raise Exception - - async def async_factory(): - yield 2 - raise Exception - - cleaned_up = False - - async def factory_no_boom(): - nonlocal cleaned_up - - yield 3 - - cleaned_up = True - - registry.register_factory(Service, factory) - registry.register_factory(AnotherService, async_factory) - registry.register_factory(YetAnotherService, factory_no_boom) - - assert 1 == container.get(Service) - assert 2 == await container.aget(AnotherService) - assert 3 == await container.aget(YetAnotherService) - - assert not cleaned_up - - await container.aclose() - - # Inverse order - assert ( - "tests.test_async.AnotherService" - == caplog.records[0].svcs_service_name - ) - assert ( - "tests.test_async.Service" == caplog.records[1].svcs_service_name - ) - assert cleaned_up - assert not container._instantiated - assert not container._on_close - - async def test_warns_if_generator_does_not_stop_after_cleanup( - self, registry, container - ): - """ - If a generator doesn't stop after cleanup, a warning is emitted. - """ - - async def factory(): - yield Service() - yield 42 - - registry.register_factory(Service, factory) - - await container.aget(Service) - - with pytest.warns(UserWarning) as wi: - await container.aclose() - - assert ( - "Container clean up for 'tests.test_async.Service' " - "didn't stop iterating." == wi.pop().message.args[0] - ) - - async def test_aping(self, registry, container): - """ - Async and sync pings work. - """ - apinged = pinged = False - - async def aping(svc): - await asyncio.sleep(0) - nonlocal apinged - apinged = True - - def ping(svc): - nonlocal pinged - pinged = True - - registry.register_value(Service, Service(), ping=aping) - registry.register_value(AnotherService, AnotherService(), ping=ping) - - (ap, p) = container.get_pings() - - assert ap.is_async - assert not p.is_async - - await ap.aping() - await p.aping() - - assert pinged - assert apinged diff --git a/tests/test_container.py b/tests/test_container.py new file mode 100644 index 0000000..203b326 --- /dev/null +++ b/tests/test_container.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2023 Hynek Schlawack +# +# SPDX-License-Identifier: MIT + + +from unittest.mock import Mock + +import pytest + +import svcs + +from .ifaces import AnotherService, Service + + +class TestContainer: + def test_get_pings_empty(self, container): + """ + get_pings returns an empty list if there are no pings. + """ + assert [] == container.get_pings() + + def test_forget_about_nothing_registered(self, container): + """ + forget_about does nothing if nothing has been registered. + """ + container.forget_about(Service) + + def test_forget_about_no_cleanup(self, container, rs, svc): + """ + forget_about removes the registered service from the container. + """ + container._instantiated[rs.svc_type] = (rs, svc) + + container.forget_about(Service) + + assert {} == container._instantiated + assert [] == container._on_close + + @pytest.mark.asyncio() + async def test_repr(self, registry, container): + """ + The repr counts correctly. + """ + + def factory(): + yield 42 + + async def async_factory(): + yield 42 + + registry.register_factory(Service, factory) + registry.register_factory(AnotherService, async_factory) + + container.get(Service) + await container.aget(AnotherService) + + assert "" == repr(container) + + +class TestServicePing: + def test_name(self, rs): + """ + The name property proxies the correct class name. + """ + + assert "tests.ifaces.Service" == svcs.ServicePing(None, rs).name + + def test_ping(self, registry, container): + """ + Calling ping instantiates the service using its factory, appends it to + the cleanup list, and calls the service's ping method. + """ + + cleaned_up = False + + def factory(): + nonlocal cleaned_up + yield Service() + cleaned_up = True + + ping = Mock(spec_set=["__call__"]) + registry.register_factory(Service, factory, ping=ping) + + (svc_ping,) = container.get_pings() + + svc_ping.ping() + + ping.assert_called_once() + + assert not cleaned_up + + container.close() + + assert cleaned_up + assert not container._instantiated + assert not container._on_close diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..ce1dfdb --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2023 Hynek Schlawack +# +# SPDX-License-Identifier: MIT + +import asyncio + +import pytest + +import svcs + +from .ifaces import AnotherService, Service, YetAnotherService + + +class TestIntegration: + def test_register_factory_get(self, registry, container): + """ + register_factory registers a factory and get returns the service. + + The service is cached. + """ + registry.register_factory(Service, Service) + + svc = container.get(Service) + + assert isinstance(svc, Service) + assert svc is container.get(Service) + + def test_register_value_get(self, registry, container, svc): + """ + register_value registers a service object and get returns it. + """ + registry.register_value(Service, svc) + + assert svc is container.get(Service) + assert svc is container.get(Service) + + def test_get_not_found(self, container): + """ + Asking for a service that isn't registered raises a ServiceNotFoundError. + """ + with pytest.raises(svcs.exceptions.ServiceNotFoundError) as ei: + container.get(Service) + + assert Service is ei.value.args[0] + + def test_passes_container_bc_name(self, registry, container): + """ + If the factory takes an argument called `svcs_container`, it is passed + on instantiation. + """ + + def factory(svcs_container): + return str(svcs_container.get(int)) + + registry.register_value(int, 42) + registry.register_factory(str, factory) + + assert "42" == container.get(str) + + def test_passes_container_bc_annotation(self, registry, container): + """ + If the factory takes an argument annotated with svcs.Container, it is + passed on instantiation. + """ + + def factory(foo: svcs.Container): + return str(foo.get(int)) + + registry.register_value(int, 42) + registry.register_factory(str, factory) + + assert "42" == container.get(str) + + def test_get_pings(self, registry, container, svc): + """ + get_pings returns a list of ServicePings. + """ + registry.register_factory(AnotherService, AnotherService) + registry.register_value(Service, svc, ping=lambda _: None) + + assert [Service] == [ + ping._rs.svc_type for ping in container.get_pings() + ] + + def test_cleanup_called(self, registry, container): + """ + Services that have a cleanup have them called on cleanup. + """ + cleaned_up = False + + def factory(): + nonlocal cleaned_up + yield 42 + cleaned_up = True + + registry.register_factory(Service, factory) + + container.get(Service) + + assert not cleaned_up + + container.close() + + assert cleaned_up + assert not container._instantiated + assert not container._on_close + + def test_close_resilient(self, container, registry, caplog): + """ + Failing cleanups are logged and ignored. They do not break the + cleanup process. + """ + + def factory(): + yield 1 + raise Exception + + cleaned_up = False + + def factory_no_boom(): + nonlocal cleaned_up + + yield 3 + + cleaned_up = True + + registry.register_factory(Service, factory) + registry.register_factory(YetAnotherService, factory_no_boom) + + assert 1 == container.get(Service) + assert 3 == container.get(YetAnotherService) + + assert not cleaned_up + + container.close() + + assert "tests.ifaces.Service" == caplog.records[0].svcs_service_name + assert cleaned_up + + def test_warns_if_generator_does_not_stop_after_cleanup( + self, registry, container + ): + """ + If a generator doesn't stop after cleanup, a warning is emitted. + """ + + def factory(): + yield Service() + yield 42 + + registry.register_factory(Service, factory) + + container.get(Service) + + with pytest.warns(UserWarning) as wi: + container.close() + + assert ( + "Container clean up for 'tests.ifaces.Service' " + "didn't stop iterating." == wi.pop().message.args[0] + ) + + +@pytest.mark.asyncio() +class TestAsync: + async def test_async_factory(self, registry, container): + """ + A factory can be async. + """ + + async def factory(): + await asyncio.sleep(0) + return Service() + + registry.register_factory(Service, factory) + + svc = await container.aget(Service) + + assert isinstance(svc, Service) + assert svc is (await container.aget(Service)) + + async def test_aget_works_with_sync_factory(self, registry, container): + """ + A synchronous factory does not break aget(). + """ + registry.register_factory(Service, Service) + + assert Service() == (await container.aget(Service)) + + async def test_aget_works_with_value(self, registry, container): + """ + A value instead of a factory does not break aget(). + """ + registry.register_value(Service, 42) + + assert 42 == (await container.aget(Service)) + + async def test_async_cleanup(self, registry, container): + """ + Async cleanups are handled by aclose. + """ + cleaned_up = False + + async def factory(): + nonlocal cleaned_up + await asyncio.sleep(0) + + yield Service() + + await asyncio.sleep(0) + cleaned_up = True + + registry.register_factory(Service, factory) + + svc = await container.aget(Service) + + assert 1 == len(container._on_close) + assert Service() == svc + assert not cleaned_up + + await container.aclose() + + assert cleaned_up + assert not container._instantiated + assert not container._on_close + + @pytest.mark.asyncio() + async def test_aclose_resilient(self, container, registry, caplog): + """ + Failing cleanups are logged and ignored. They do not break the + cleanup process. + """ + + def factory(): + yield 1 + raise Exception + + async def async_factory(): + yield 2 + raise Exception + + cleaned_up = False + + async def factory_no_boom(): + nonlocal cleaned_up + + yield 3 + + cleaned_up = True + + registry.register_factory(Service, factory) + registry.register_factory(AnotherService, async_factory) + registry.register_factory(YetAnotherService, factory_no_boom) + + assert 1 == container.get(Service) + assert 2 == await container.aget(AnotherService) + assert 3 == await container.aget(YetAnotherService) + + assert not cleaned_up + + await container.aclose() + + # Inverse order + assert ( + "tests.ifaces.AnotherService" + == caplog.records[0].svcs_service_name + ) + assert "tests.ifaces.Service" == caplog.records[1].svcs_service_name + assert cleaned_up + assert not container._instantiated + assert not container._on_close + + async def test_warns_if_generator_does_not_stop_after_cleanup( + self, registry, container + ): + """ + If a generator doesn't stop after cleanup, a warning is emitted. + """ + + async def factory(): + yield Service() + yield 42 + + registry.register_factory(Service, factory) + + await container.aget(Service) + + with pytest.warns(UserWarning) as wi: + await container.aclose() + + assert ( + "Container clean up for 'tests.ifaces.Service' " + "didn't stop iterating." == wi.pop().message.args[0] + ) + + async def test_aping(self, registry, container): + """ + Async and sync pings work. + """ + apinged = pinged = False + + async def aping(svc): + await asyncio.sleep(0) + nonlocal apinged + apinged = True + + def ping(svc): + nonlocal pinged + pinged = True + + registry.register_value(Service, Service(), ping=aping) + registry.register_value(AnotherService, AnotherService(), ping=ping) + + (ap, p) = container.get_pings() + + assert ap.is_async + assert not p.is_async + + await ap.aping() + await p.aping() + + assert pinged + assert apinged diff --git a/tests/test_core.py b/tests/test_registry.py similarity index 51% rename from tests/test_core.py rename to tests/test_registry.py index 1b7ac46..e35e539 100644 --- a/tests/test_core.py +++ b/tests/test_registry.py @@ -13,12 +13,6 @@ import svcs -needs_working_async_mock = pytest.mark.skipif( - not inspect.iscoroutinefunction(AsyncMock()), - reason="AsyncMock not working", -) - - class Service: pass @@ -31,269 +25,10 @@ class YetAnotherService: pass -@pytest.fixture(name="rs") -def _rs(svc): - return svcs.RegisteredService(Service, Service, False, False, None) - - -@pytest.fixture(name="svc") -def _svc(): - return Service() - - -class TestIntegration: - def test_passes_container_bc_name(self, registry, container): - """ - If the factory takes an argument called `svcs_container`, it is passed - on instantiation. - """ - - def factory(svcs_container): - return str(svcs_container.get(int)) - - registry.register_value(int, 42) - registry.register_factory(str, factory) - - assert "42" == container.get(str) - - def test_passes_container_bc_annotation(self, registry, container): - """ - If the factory takes an argument annotated with svcs.Container, it is - passed on instantiation. - """ - - def factory(foo: svcs.Container): - return str(foo.get(int)) - - registry.register_value(int, 42) - registry.register_factory(str, factory) - - assert "42" == container.get(str) - - -class TestContainer: - def test_register_factory_get(self, registry, container): - """ - register_factory registers a factory and get returns the service. - - The service is cached. - """ - registry.register_factory(Service, Service) - - svc = container.get(Service) - - assert isinstance(svc, Service) - assert svc is container.get(Service) - - def test_register_value_get(self, registry, container, svc): - """ - register_value registers a service object and get returns it. - """ - registry.register_value(Service, svc) - - assert svc is container.get(Service) - assert svc is container.get(Service) - - def test_get_not_found(self, container): - """ - Asking for a service that isn't registered raises a ServiceNotFoundError. - """ - with pytest.raises(svcs.exceptions.ServiceNotFoundError) as ei: - container.get(Service) - - assert Service is ei.value.args[0] - - def test_get_pings_empty(self, container): - """ - get_pings returns an empty list if there are no pings. - """ - assert [] == container.get_pings() - - def test_get_pings(self, registry, container, svc): - """ - get_pings returns a list of ServicePings. - """ - registry.register_factory(AnotherService, AnotherService) - registry.register_value(Service, svc, ping=lambda _: None) - - assert [Service] == [ - ping._rs.svc_type for ping in container.get_pings() - ] - - def test_forget_about_nothing_registered(self, container): - """ - forget_about does nothing if nothing has been registered. - """ - container.forget_about(Service) - - def test_forget_about_no_cleanup(self, container, rs, svc): - """ - forget_about removes the registered service from the container. - """ - container._instantiated[rs.svc_type] = (rs, svc) - - container.forget_about(Service) - - assert {} == container._instantiated - assert [] == container._on_close - - @pytest.mark.asyncio() - async def test_repr(self, registry, container): - """ - The repr counts correctly. - """ - - def factory(): - yield 42 - - async def async_factory(): - yield 42 - - registry.register_factory(Service, factory) - registry.register_factory(AnotherService, async_factory) - - container.get(Service) - await container.aget(AnotherService) - - assert "" == repr(container) - - def test_cleanup_called(self, registry, container): - """ - Services that have a cleanup have them called on cleanup. - """ - cleaned_up = False - - def factory(): - nonlocal cleaned_up - yield 42 - cleaned_up = True - - registry.register_factory(Service, factory) - - container.get(Service) - - assert not cleaned_up - - container.close() - - assert cleaned_up - assert not container._instantiated - assert not container._on_close - - def test_close_resilient(self, container, registry, caplog): - """ - Failing cleanups are logged and ignored. They do not break the - cleanup process. - """ - - def factory(): - yield 1 - raise Exception - - cleaned_up = False - - def factory_no_boom(): - nonlocal cleaned_up - - yield 3 - - cleaned_up = True - - registry.register_factory(Service, factory) - registry.register_factory(YetAnotherService, factory_no_boom) - - assert 1 == container.get(Service) - assert 3 == container.get(YetAnotherService) - - assert not cleaned_up - - container.close() - - assert "tests.test_core.Service" == caplog.records[0].svcs_service_name - assert cleaned_up - - def test_warns_if_generator_does_not_stop_after_cleanup( - self, registry, container - ): - """ - If a generator doesn't stop after cleanup, a warning is emitted. - """ - - def factory(): - yield Service() - yield 42 - - registry.register_factory(Service, factory) - - container.get(Service) - - with pytest.warns(UserWarning) as wi: - container.close() - - assert ( - "Container clean up for 'tests.test_core.Service' " - "didn't stop iterating." == wi.pop().message.args[0] - ) - - -class TestRegisteredService: - def test_repr(self, rs): - """ - repr uses the fully-qualified name of a svc type. - """ - - assert ( - ", takes_container=False, " - "has_ping=False" - ")>" - ) == repr(rs) - - def test_name(self, rs): - """ - The name property deducts the correct class name. - """ - - assert "tests.test_core.Service" == rs.name - - -class TestServicePing: - def test_name(self, rs): - """ - The name property proxies the correct class name. - """ - - assert "tests.test_core.Service" == svcs.ServicePing(None, rs).name - - def test_ping(self, registry, container): - """ - Calling ping instantiates the service using its factory, appends it to - the cleanup list, and calls the service's ping method. - """ - - cleaned_up = False - - def factory(): - nonlocal cleaned_up - yield Service() - cleaned_up = True - - ping = Mock(spec_set=["__call__"]) - registry.register_factory(Service, factory, ping=ping) - - (svc_ping,) = container.get_pings() - - svc_ping.ping() - - ping.assert_called_once() - - assert not cleaned_up - - container.close() - - assert cleaned_up - assert not container._instantiated - assert not container._on_close +needs_working_async_mock = pytest.mark.skipif( + not inspect.iscoroutinefunction(AsyncMock()), + reason="AsyncMock not working", +) class TestRegistry: @@ -354,7 +89,7 @@ async def hook(): with pytest.warns( UserWarning, - match="Skipped async cleanup for 'tests.test_core.Service'.", + match="Skipped async cleanup for 'tests.test_registry.Service'.", ): registry.close() @@ -369,7 +104,10 @@ def test_close_logs_failures(self, registry, caplog): with contextlib.closing(registry): ... - assert "tests.test_core.Service" == caplog.records[0].svcs_service_name + assert ( + "tests.test_registry.Service" + == caplog.records[0].svcs_service_name + ) def test_detects_async_factories(self, registry): """ @@ -472,7 +210,31 @@ async def test_aclose_logs_failures(self, registry, caplog): await registry.aclose() close_mock.assert_awaited_once() - assert "tests.test_core.Service" == caplog.records[0].svcs_service_name + assert ( + "tests.test_registry.Service" + == caplog.records[0].svcs_service_name + ) + + +class TestRegisteredService: + def test_repr(self, rs): + """ + repr uses the fully-qualified name of a svc type. + """ + + assert ( + ", takes_container=False, " + "has_ping=False" + ")>" + ) == repr(rs) + + def test_name(self, rs): + """ + The name property deducts the correct class name. + """ + + assert "tests.ifaces.Service" == rs.name def factory_wrong_annotation(foo: svcs.Registry) -> int: