Skip to content
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crate-ci/typos@master
- uses: crate-ci/typos@v1.31.0

lint:
name: "Lint"
Expand All @@ -44,7 +44,7 @@ jobs:
run: uvx ruff check .

- name: "Python type check"
run: uvx mypy
run: uv run mypy

tests:
name: "Testing with Python ${{ matrix.python-version }}"
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ repos:

- id: mypy
name: mypy
entry: mypy
entry: uvx mypy
language: python
types_or: [ python, pyi ]
stages: [pre-push]

- id: tests
name: tests
entry: pytest
entry: uvx pytest
language: python
types_or: [ python, pyi ]
stages: [pre-push]
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ classifiers = [

requires-python = ">=3.11"
dependencies = [
"typing-extensions>=4.12.2",
]

[dependency-groups]
Expand All @@ -52,6 +53,8 @@ lint = [
"mypy[faster-cache]>=1.15.0",
"ruff>=0.11.2",
"pre-commit>=4.0.1",
# mypy should have all dependencies to run
{ include-group = "dev" },
]

[tool.pytest.ini_options]
Expand Down
4 changes: 2 additions & 2 deletions pyqure/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Top level Pyqure package.
"""Pyqure is a dependency injector.

Pyqure is a dependency injector, inspired by the simplicity and magic of spring annotation.
It's inspired by the simplicity and magic of spring annotation, to bring it to the python world.
"""

from importlib.metadata import PackageNotFoundError, version
Expand Down
121 changes: 121 additions & 0 deletions pyqure/container.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from contextlib import contextmanager
from logging import Logger
from pathlib import Path
from typing import Any, Generic, Iterator, NamedTuple, TypeVar

from typing_extensions import Self

from pyqure.exceptions import DependencyError, InvalidRegisteredType
from pyqure.injectables import Injectable
from pyqure.utils.types import filter_mro, is_union

logger = Logger("pyqure")

T = TypeVar("T")

DEFAULT_DEPENDENCIES_STATE_FILE = Path(".dependencies.pyq")


class Key(NamedTuple, Generic[T]):
"""Injectable key object."""

clazz: type[T] | None
qualifier: str | None


def Class(clz: type[T]) -> Key[T]:
"""Create a key with no qualifier."""
return Key(clz, None)


def Alias(qualifier: str) -> Key[T]:
"""Create a key with no type."""
return Key(None, qualifier)


class DependencyContainer:
"""Container of the whole dependency tree registered."""

def __init__(
self,
) -> None:
self._primary: dict[type[Any], Key[Any]] = {}
self._injectables: dict[Key[Any], Injectable[Any]] = {}
self._overrides: dict[Key[Any], Injectable[Any]] = {}

def register(self, key: Key[T], component: Injectable[T], *, primary: bool = False) -> Self:
"""Register a new injectable among the dependencies.

Notes:
Can add some options to the injectable registering impossible with __setitem__
* primary : specify that to use the injectable for a class over others.

Examples:
>>> container.register(Key(int, "42"), Constant(42), primary=True).register(Key(int, "72"), Constant(72))
>>> assert container[Class(int)] == 42
"""
self.__register(key, component, primary)

return self

def __setitem__(self, key: Key[T], value: Injectable[T]) -> None:
"""Register a new injectable among the dependencies.

Examples:
>>> container[Key(int, "test")] = Constant(42)
>>> assert container[Key(int, "test")] == 42
"""
self.__register(key, value)

def __getitem__(self, key: Key[T]) -> T:
"""Retrieve injectable by its Key.

The injectable look up order:
* Check if has been overridden
* Check if the key exists inside dependencies
* Check if an injectable has been set as primary for this type
* Raise error
"""
clss, qualifier = key
if key in self._overrides:
return self._overrides[key].supply() # type: ignore[no-any-return]

if key in self._injectables:
return self._injectables[key].supply() # type: ignore[no-any-return]

if clss in self._primary:
return self._injectables[self._primary[clss]].supply() # type: ignore[no-any-return]

raise DependencyError(f"No component found based on key: {key}.")

def __contains__(self, key: Key[Any]) -> bool:
"""Check whether an injectable exists for this key."""
clss, qualifier = key
return clss in self._primary or key in self._injectables or key in self._overrides

@contextmanager
def override(self, key: Key[T], component: Injectable[T]) -> Iterator[None]:
"""Override a certain key with component within the context."""
self._overrides[key] = component
try:
yield
finally:
del self._overrides[key]

def __register(self, key: Key[T], component: Injectable[T], primary: bool = False) -> None:
"""Intern method registering an injectable."""
clzz, qualifier = key

if clzz:
if is_union(clzz):
raise InvalidRegisteredType(clzz)

for cls in filter_mro(clzz):
self._injectables[Key(cls, qualifier)] = component
if primary:
self._primary[cls] = key
else:
self._injectables[key] = component


dc: DependencyContainer = DependencyContainer()
48 changes: 48 additions & 0 deletions pyqure/discover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import importlib
import inspect
import pkgutil

from pyqure.container import logger


def discover(package_name: str | None = None) -> None:
"""Discover recursively all psub-package to perform auto-loading of modules, and so the injectables defined.

If the package is provided, the discovering will be performed from it as root.
Otherwise, it will use the package where the function is being called.
"""
package_to_discover = package_name

if package_to_discover is None:
package_to_discover = _get_package_caller(2)

if package_to_discover is None:
raise ValueError("Should be call inside a package not a script.")

package = importlib.import_module(package_to_discover)
for _, module_name, is_pkg in pkgutil.walk_packages(
package.__path__, package_to_discover + "."
):
if not is_pkg:
imported_module = importlib.import_module(module_name)
for name, _ in inspect.getmembers(imported_module):
logger.debug(f"Add component {name} in {module_name}")


def _get_package_caller(lvl: int = 1) -> str | None:
"""Lookup the source package at the origin of a call.

You should not have to use it outside the project.
"""
frame = inspect.currentframe()
for _ in range(lvl):
if frame is None:
return None

frame = frame.f_back

module = inspect.getmodule(frame)
if module is None:
return None

return module.__package__
37 changes: 37 additions & 0 deletions pyqure/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Any, Callable, Iterable

from pyqure.utils.types import unpack_types


class PyqureError(Exception):
"""Pyqure general error."""


class DependencyError(PyqureError):
"""Dependency error."""


class InvalidRegisteredType(DependencyError):
"""Exception raised when invalid type used to register an injectable."""

def __init__(self, type_: type[Any]) -> None:
super().__init__(
f"Union types cannot be used for registered injectables:"
f" you provide {type_}, try registering separately one of {unpack_types(type_)}."
)


class InjectionError(PyqureError):
"""Injection error."""


class MissingDependencies(InjectionError):
"""Missing dependency for service injection call."""

def __init__(self, component: Callable[..., Any], missing: Iterable[str]) -> None:
super().__init__(
f"Cannot instantiate component {component}."
f" Missed binding for the following parameters: {', '.join(missing)}."
)
self.component = component
self.missing = missing
78 changes: 78 additions & 0 deletions pyqure/injectables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from dataclasses import dataclass, field
from typing import Callable, Protocol, TypeVar

from typing_extensions import override

T = TypeVar("T", covariant=True)


class Qualifier(str):
"""Marker to specify alias for injectable."""


def qualifier(alias: str) -> Qualifier:
"""Create a qualifier alias to specify which component to inject.

Examples:
```python
@inject
def my_function(service: Annotated[Service, qualifier("alias")]) -> None:
...
```
"""
return Qualifier(alias)


class Injectable(Protocol[T]):
"""Injectable object contrat."""

def supply(self) -> T:
"""Supply the injectable component/value."""


@dataclass(slots=True)
class Constant(Injectable[T]):
"""Component to create Injectable constant.

Examples:
>>> container[Key(str, "contract_table_name")] = Constant("Contract")
"""

value: T

@override
def supply(self) -> T:
return self.value


@dataclass(slots=True)
class Singleton(Injectable[T]):
"""Singleton injectable.

Singleton is lazy evaluated by design, the component is only instantiated
when needed.
"""

supplier: Callable[..., T]
value: T | None = field(init=False, default=None)

@override
def supply(self) -> T:
if self.value is None:
self.value = self.supplier()

return self.value


@dataclass(slots=True)
class Factory(Injectable[T]):
"""Factory injectable.

Each time the injectable is needed, a new instance is created.
"""

supplier: Callable[..., T]

@override
def supply(self) -> T:
return self.supplier()
Loading