From 8732685e8b7207e40ac17ab923f59d1f9d43e9e4 Mon Sep 17 00:00:00 2001 From: ducdetronquito Date: Wed, 13 Nov 2024 09:57:53 +0100 Subject: [PATCH] feat!: Expose annotations types + make nurse.get() raise when service is not registered --- .pre-commit-config.yaml | 5 ++ nurse/__init__.py | 5 +- nurse/api.py | 132 +++++++-------------------------------- nurse/exceptions.py | 8 +-- nurse/py.typed | 0 nurse/service_catalog.py | 11 ++-- poetry.lock | 33 +++++++++- pyproject.toml | 13 ++++ tests/test_api.py | 87 +++++--------------------- 9 files changed, 100 insertions(+), 194 deletions(-) create mode 100644 nurse/py.typed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e3d70c..b744981 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,3 +13,8 @@ repos: entry: poetry run ruff format language: system types: [file, python] + - id: pyright + name: pyright + entry: poetry run pyright + language: system + types: [file, python] diff --git a/nurse/__init__.py b/nurse/__init__.py index 214ade5..94fef31 100644 --- a/nurse/__init__.py +++ b/nurse/__init__.py @@ -1,4 +1,5 @@ -from nurse.api import clear, get, inject, serve +from nurse.api import clear, get, serve +from nurse.exceptions import ServiceNotFound __version__ = "0.5.0" -__all__ = ["clear", "get", "inject", "serve"] +__all__ = ["clear", "get", "serve", "ServiceNotFound"] diff --git a/nurse/api.py b/nurse/api.py index 9c51719..026d6a8 100644 --- a/nurse/api.py +++ b/nurse/api.py @@ -1,10 +1,14 @@ -from inspect import iscoroutinefunction, isfunction -from nurse.exceptions import DependencyError from .service_catalog import ServiceCatalog -from typing import List +from typing import TypeVar +from nurse.exceptions import ServiceNotFound +TDependencyInterface = TypeVar("TDependencyInterface") -def serve(user_class, through=None) -> None: + +def serve( + service_instance: TDependencyInterface, + through: type[TDependencyInterface] | None = None, +) -> None: """ Add an instance of a user-defined class to Nurse's services catalog. By default, a dependency is registered for its concrete type, but an interface can be provided. @@ -13,13 +17,17 @@ def serve(user_class, through=None) -> None: :param through: An interface used to access the user class (must be a direct or indirect parent class) """ + if through is not None: + _through = through + else: + _through = service_instance.__class__ - through = through or user_class.__class__ - - if not issubclass(user_class.__class__, through): - raise ValueError(f"Class {user_class} must be a subclass of {through}.") + if not issubclass(service_instance.__class__, _through): + raise ValueError( + f"Service instance of type '{service_instance.__class__}' must be a subclass of {_through}." + ) - ServiceCatalog.get_instance()._services[through] = user_class + ServiceCatalog.get_instance().services[_through] = service_instance def clear() -> None: @@ -29,7 +37,7 @@ def clear() -> None: ServiceCatalog.get_instance().clear() -def get(service_type): +def get(service_instance_class: type[TDependencyInterface]) -> TDependencyInterface: """ Retrieve a service from the service catalog. @@ -39,105 +47,9 @@ def get(service_type): ssh_client = nurse.get(SSHClient) """ - return ServiceCatalog.get_instance()._services.get(service_type) - - -def inject(*items_to_inject: List[str]): - """ - A decorator that injects dependencies into every instances of a user-defined class or method - - :Example: - - class SSHClient: - - def connect(self): - pass - - @nurse.inject("ssh_client") - class Server: - ssh_client: SSHClient - - server = Server() - server.ssh_client.connect() - - - @nurse.inject("client") - def send(client: SSHClient): - client.send("Hello World !") - - nurse.serve(SSHClient()) - send() - - - @nurse.inject("client") - async def send(client: SSHClient): - await client.send("Hello World !") - - nurse.serve(SSHClient()) - asyncio.run(send()) - """ - - def decorator(decorated): - service_catalog = ServiceCatalog.get_instance() - if isinstance(decorated, type): - return inject_class(decorated, service_catalog, items_to_inject) - elif iscoroutinefunction(decorated): - return inject_async_function(decorated, service_catalog, items_to_inject) - elif isfunction(decorated): - return inject_function(decorated, service_catalog, items_to_inject) - - raise NotImplementedError("user-defined class or function can't be injected.") - - return decorator - - -def inject_class(decorated_class, service_catalog, field_to_inject): - def init_decorator(self, *args, **kwargs): - for param_to_inject in field_to_inject: - service = get_service(service_catalog, decorated_class, param_to_inject) - setattr(self, param_to_inject, service) - - return init_decorator.decorated_init(self, *args, **kwargs) - - init_decorator.decorated_init = decorated_class.__init__ - decorated_class.__init__ = init_decorator - - return decorated_class - - -def inject_function(decorated_func, service_catalog, args_to_inject): - def decorator(*args, **kwargs): - injected_args = {} - for param_to_inject in args_to_inject: - service = get_service(service_catalog, decorated_func, param_to_inject) - injected_args.setdefault(param_to_inject, service) - - return decorated_func(*args, **injected_args, **kwargs) - - return decorator - - -def inject_async_function(decorated_func, service_catalog, args_to_inject): - async def decorator(*args, **kwargs): - injected_args = {} - for param_to_inject in args_to_inject: - service = get_service(service_catalog, decorated_func, param_to_inject) - injected_args.setdefault(param_to_inject, service) - - return await decorated_func(*args, **injected_args, **kwargs) - - return decorator - - -def get_service(service_catalog: ServiceCatalog, decorated_obj, param_to_inject: str): - service_type = decorated_obj.__annotations__.get(param_to_inject) - if not service_type: - raise DependencyError(f"Args `{param_to_inject}` must be typed to be injected.") - - service = service_catalog._services.get(service_type) - if not service: - raise DependencyError( - f"Dependency `{service_type.__name__}` for `{param_to_inject}` was not found." + service = ServiceCatalog.get_instance().services.get(service_instance_class) + if service is None: + raise ServiceNotFound( + f"No service exists for '{service_instance_class.__class__}'" ) - return service diff --git a/nurse/exceptions.py b/nurse/exceptions.py index 8e56bc9..73cee07 100644 --- a/nurse/exceptions.py +++ b/nurse/exceptions.py @@ -1,6 +1,2 @@ -class DependencyError(Exception): - def __init__(self, message: str) -> None: - self.message = message - - def __str__(self) -> str: - return self.message +class ServiceNotFound(Exception): + pass diff --git a/nurse/py.typed b/nurse/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/nurse/service_catalog.py b/nurse/service_catalog.py index 39098d5..74f1788 100644 --- a/nurse/service_catalog.py +++ b/nurse/service_catalog.py @@ -1,16 +1,19 @@ +from typing import Any, Optional + + class ServiceCatalog: """ A singleton service catalog. """ - __slots__ = ("_services",) - instance = None + __slots__ = ("services",) + instance: Optional["ServiceCatalog"] = None def __init__(self) -> None: - self._services = {} + self.services = dict[Any, Any]() def clear(self) -> None: - self._services.clear() + self.services.clear() @classmethod def get_instance(cls) -> "ServiceCatalog": diff --git a/poetry.lock b/poetry.lock index 46aec57..b8d8f97 100644 --- a/poetry.lock +++ b/poetry.lock @@ -236,6 +236,26 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pyright" +version = "1.1.388" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.388-py3-none-any.whl", hash = "sha256:c7068e9f2c23539c6ac35fc9efac6c6c1b9aa5a0ce97a9a8a6cf0090d7cbf84c"}, + {file = "pyright-1.1.388.tar.gz", hash = "sha256:0166d19b716b77fd2d9055de29f71d844874dbc6b9d3472ccd22df91db3dfa34"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + [[package]] name = "pytest" version = "8.3.3" @@ -376,6 +396,17 @@ files = [ {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "virtualenv" version = "20.27.1" @@ -399,4 +430,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "47a36fada5274ded82c6ecdf9a07b6ffc210a60c25377d32c00b92e20e0c5305" +content-hash = "d31c633b6083869385a22dc9c4d891ae905516755363ba8f10416f7245314ccf" diff --git a/pyproject.toml b/pyproject.toml index dfa87ec..4241af3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ pytest = "^8.0" ruff = "^0.7.3" pre-commit = "^4.0.1" pytest-cov = "^6.0.0" +pyright = "^1.1.388" [build-system] requires = ["poetry-core"] @@ -39,3 +40,15 @@ line-length = 88 # It would be preferable to use python's standard "project.requires-python" # but poetry does not comply with it. target-version = "py311" + + +[tool.pyright] +venvPath = "." +venv = ".venv" +typeCheckingMode = "strict" +include = ["nurse/**"] +exclude = [ + "**/__pycache__", +] + +executionEnvironments = [{ root = "nurse" }] diff --git a/tests/test_api.py b/tests/test_api.py index 0aaa787..a259186 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,10 +1,8 @@ +from dataclasses import dataclass from unittest import TestCase import nurse import pytest -from nurse.exceptions import DependencyError -import asyncio - class TestServe(TestCase): def tearDown(self): @@ -19,31 +17,33 @@ def name(self): nurse.serve(User()) - @nurse.inject("player") + @dataclass class Game: player: User - game = Game() + user = nurse.get(User) + game = Game(user) assert game.player.name == "Leroy Jenkins" def test_can_inject_a_dependency_through_an_interface(self): class User: @property - def name(self): + def name(self) -> str: return "Leroy Jenkins" class Cheater(User): @property - def name(self): + def name(self) -> str: return "Igor" nurse.serve(Cheater(), through=User) - @nurse.inject("player") + @dataclass class Game: player: User - game = Game() + user = nurse.get(User) + game = Game(user) assert game.player.name == "Igor" def test_cannot_serve_a_dependency_if_it_does_not_subclass_the_provided_interface( @@ -51,12 +51,12 @@ def test_cannot_serve_a_dependency_if_it_does_not_subclass_the_provided_interfac ): class User: @property - def name(self): + def name(self) -> str: return "Leroy Jenkins" class Animal: @property - def name(self): + def name(self) -> str: return "Miaouss" with pytest.raises(ValueError): @@ -64,69 +64,13 @@ def name(self): class ServiceDependency: - def __init__(self, name): + def __init__(self, name: str): self.name = name - def get_name(self): + def get_name(self) -> str: return self.name -class TestInjectMethod(TestCase): - def tearDown(self): - super().tearDown() - nurse.clear() - - def test_methods_with_only_dependency_args(self): - @nurse.inject("service") - def foo(service: ServiceDependency): - return service.get_name() - - nurse.serve(ServiceDependency("Leroy Jenkins")) - - assert foo() == "Leroy Jenkins" - - def test_methods_with_dependency_and_value_args(self): - @nurse.inject("service") - def foo(prefix, service: ServiceDependency): - return prefix + service.get_name() - - nurse.serve(ServiceDependency("Leroy Jenkins")) - - assert foo("My name is ") == "My name is Leroy Jenkins" - - def test_methods_with_missing_type(self): - @nurse.inject("service") - def foo(service): - pass - - with pytest.raises( - DependencyError, match="Args `service` must be typed to be injected." - ): - foo() - - def test_methods_with_missing_dependency(self): - @nurse.inject("service") - def foo(service: ServiceDependency): - pass - - with pytest.raises( - DependencyError, - match="Dependency `ServiceDependency` for `service` was not found.", - ): - foo() - - def test_async_method(self): - @nurse.inject("service") - async def foo(service: ServiceDependency): - return service.get_name() - - nurse.serve(ServiceDependency("Leroy Jenkins")) - - res = asyncio.run(foo()) - - assert res == "Leroy Jenkins" - - class TestGet(TestCase): def tearDown(self): super().tearDown() @@ -136,8 +80,9 @@ def test_retrieve_service(self): nurse.serve(ServiceDependency("Leroy Jenkins")) service = nurse.get(ServiceDependency) + assert service is not None assert service.get_name() == "Leroy Jenkins" def test_returns_none_if_service_is_not_registered(self): - service = nurse.get(ServiceDependency) - assert service is None + with pytest.raises(nurse.ServiceNotFound): + nurse.get(ServiceDependency)