Skip to content

Commit

Permalink
feat!: Expose annotations types + make nurse.get() raise when service…
Browse files Browse the repository at this point in the history
… is not registered
  • Loading branch information
ducdetronquito committed Nov 13, 2024
1 parent 1d4c960 commit 8732685
Show file tree
Hide file tree
Showing 9 changed files with 100 additions and 194 deletions.
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
5 changes: 3 additions & 2 deletions nurse/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
132 changes: 22 additions & 110 deletions nurse/api.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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
8 changes: 2 additions & 6 deletions nurse/exceptions.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added nurse/py.typed
Empty file.
11 changes: 7 additions & 4 deletions nurse/service_catalog.py
Original file line number Diff line number Diff line change
@@ -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":
Expand Down
33 changes: 32 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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" }]
Loading

0 comments on commit 8732685

Please sign in to comment.