Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose annotations types + make nurse.get() raise when service is not registered #39

Merged
merged 1 commit into from
Nov 13, 2024
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
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
Loading