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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ if __name__ == "__main__":
* [**Scoped dependencies**](https://github.com/100nm/python-injection/tree/prod/documentation/scoped-dependencies.md)
* [**Testing**](https://github.com/100nm/python-injection/tree/prod/documentation/testing.md)
* [**Advanced usage**](https://github.com/100nm/python-injection/tree/prod/documentation/advanced-usage.md)
* [**Utils**](https://github.com/100nm/python-injection/tree/prod/documentation/utils.md)
* [**Loaders**](https://github.com/100nm/python-injection/tree/prod/documentation/loaders.md)
* [**Entrypoint**](https://github.com/100nm/python-injection/tree/prod/documentation/entrypoint.md)
* [**Integrations**](https://github.com/100nm/python-injection/tree/prod/documentation/integrations.md)
* [**Concrete example**](https://github.com/100nm/python-injection-example)
2 changes: 1 addition & 1 deletion documentation/basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ classes.

> [!WARNING]
> If the child class is in another file, make sure that file is imported before injection.
> [_See `load_packages` function._](utils.md#load_packages)
> [_See `load_packages` function._](loaders.md#load_packages)

_Example with one class:_

Expand Down
81 changes: 81 additions & 0 deletions documentation/entrypoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Entrypoint

## What is it?

_An entrypoint is the first function executed when a software component starts._

When using `python-injection`, you often need to perform several setup actions at the entrypoint _(such as injecting
dependencies, opening a scope, or importing Python modules)_.

To solve this problem, the package provides an `Entrypoint` class, a builder-style utility that simplifies
entrypoint preparation.

## Creating an entrypoint decorator

`entrypoint_maker` allows you to define a custom decorator for your entrypoint functions.

The function you decorate with `entrypoint_maker` serves to configure the `Entrypoint` instance. Its first parameter must
be the `Entrypoint` instance being built. You can inject dependencies into this setup function, but **only** `constants`
or `injectables`, because everything is not yet fully configured at this stage.

**Instruction order matters**: each configuration step applies a decorator and returns a new `Entrypoint` instance.

```python
# src/entrypoint.py

import uvloop
from injection import adefine_scope
from injection.entrypoint import AsyncEntrypoint, Entrypoint, entrypointmaker
from injection.loaders import PythonModuleLoader

@entrypointmaker
def entrypoint[**P, T](self: AsyncEntrypoint[P, T]) -> Entrypoint[P, T]:
import src

loader = PythonModuleLoader.from_keywords("# Auto-import")
return (
self.inject()
.decorate(adefine_scope("lifespan", kind="shared"))
.async_to_sync(uvloop.run)
.load_modules(loader, src)
)
```

> [!IMPORTANT]
> **Typing rule**
>
> When creating a decorator for async entrypoints, make sure to type `self` as `AsyncEntrypoint`.
> For sync code, use `Entrypoint` instead.

## Example of use

Developing a CLI is a good example of using multiple entrypoints:

```python
# src/cli.py

from injection.entrypoint import autocall
from typer import Typer

from src.entrypoint import entrypoint
from src.services.logger import AsyncLogger # project service, implementation not provided

app = Typer()

@app.command()
def hello(name: str) -> None:
@autocall # allows automatically calling the function
@entrypoint
async def _(logger: AsyncLogger) -> None:
await logger.info(f"Hello {name}!")

@app.command()
def goodbye(name: str) -> None:
@autocall
@entrypoint
async def _(logger: AsyncLogger) -> None:
await logger.info(f"Goodbye {name}!")

if __name__ == "__main__":
app()
```
2 changes: 1 addition & 1 deletion documentation/utils.md → documentation/loaders.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Utils
# Loaders

## PythonModuleLoader

Expand Down
147 changes: 147 additions & 0 deletions injection/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass, field
from functools import wraps
from types import MethodType
from types import ModuleType as PythonModule
from typing import Any, Self, final, overload

from injection import Module, mod
from injection.loaders import PythonModuleLoader

__all__ = ("AsyncEntrypoint", "Entrypoint", "autocall", "entrypointmaker")

type AsyncEntrypoint[**P, T] = Entrypoint[P, Coroutine[Any, Any, T]]
type EntrypointDecorator[**P, T1, T2] = Callable[[Callable[P, T1]], Callable[P, T2]]
type EntrypointSetupMethod[*Ts, **P, T1, T2] = Callable[
[Entrypoint[P, T1], *Ts],
Entrypoint[P, T2],
]


def autocall[**P, T](wrapped: Callable[P, T] | None = None, /) -> Any:
def decorator(wp: Callable[P, T]) -> Callable[P, T]:
wp() # type: ignore[call-arg]
return wp

return decorator(wrapped) if wrapped else decorator


@overload
def entrypointmaker[*Ts, **P, T1, T2](
wrapped: EntrypointSetupMethod[*Ts, P, T1, T2],
/,
*,
module: Module | None = ...,
) -> EntrypointDecorator[P, T1, T2]: ...


@overload
def entrypointmaker[*Ts, **P, T1, T2](
wrapped: None = ...,
/,
*,
module: Module | None = ...,
) -> Callable[
[EntrypointSetupMethod[*Ts, P, T1, T2]],
EntrypointDecorator[P, T1, T2],
]: ...


def entrypointmaker[*Ts, **P, T1, T2](
wrapped: EntrypointSetupMethod[*Ts, P, T1, T2] | None = None,
/,
*,
module: Module | None = None,
) -> Any:
def decorator(
wp: EntrypointSetupMethod[*Ts, P, T1, T2],
) -> EntrypointDecorator[P, T1, T2]:
return Entrypoint._make_decorator(wp, module)

return decorator(wrapped) if wrapped else decorator


@final
@dataclass(repr=False, eq=False, frozen=True, slots=True)
class Entrypoint[**P, T]:
function: Callable[P, T]
module: Module = field(default_factory=mod)

def __call__(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
return self.function(*args, **kwargs)

def async_to_sync[_T](
self: AsyncEntrypoint[P, _T],
run: Callable[[Coroutine[Any, Any, _T]], _T] = asyncio.run,
/,
) -> Entrypoint[P, _T]:
function = self.function

@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> _T:
return run(function(*args, **kwargs))

return self.__recreate(wrapper)

def decorate(
self,
decorator: Callable[[Callable[P, T]], Callable[P, T]],
/,
) -> Self:
return self.__recreate(decorator(self.function))

def inject(self) -> Self:
return self.decorate(self.module.make_injected_function)

def load_modules(
self,
/,
loader: PythonModuleLoader,
*packages: PythonModule | str,
) -> Self:
return self.setup(lambda: loader.load(*packages))

def setup(self, function: Callable[..., Any], /) -> Self:
@contextmanager
def decorator() -> Iterator[Any]:
yield function()

return self.decorate(decorator())

def async_setup[_T](
self: AsyncEntrypoint[P, _T],
function: Callable[..., Awaitable[Any]],
/,
) -> AsyncEntrypoint[P, _T]:
@asynccontextmanager
async def decorator() -> AsyncIterator[Any]:
yield await function()

return self.decorate(decorator())

def __recreate[**_P, _T](
self: Entrypoint[Any, Any],
function: Callable[_P, _T],
/,
) -> Entrypoint[_P, _T]:
return type(self)(function, self.module)

@classmethod
def _make_decorator[*Ts, _T](
cls,
setup_method: EntrypointSetupMethod[*Ts, P, T, _T],
/,
module: Module | None = None,
) -> EntrypointDecorator[P, T, _T]:
module = module or mod()
setup_method = module.make_injected_function(setup_method)

def decorator(function: Callable[P, T]) -> Callable[P, _T]:
self = cls(function, module)
return MethodType(setup_method, self)().function

Comment thread
remimd marked this conversation as resolved.
return decorator
4 changes: 2 additions & 2 deletions injection/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def modules(self) -> dict[str, PythonModule]:

def load(self, *packages: PythonModule | str) -> Self:
modules = itertools.chain.from_iterable(
self.__iter_modules(package) for package in packages
self.__iter_modules_from(package) for package in packages
)
self.__modules.update(modules)
return self
Expand All @@ -67,7 +67,7 @@ def __is_already_loaded(self, module_name: str) -> bool:
module_name in modules for modules in (self.__modules, self._sys_modules)
)

def __iter_modules(
def __iter_modules_from(
self,
package: PythonModule | str,
) -> Iterator[tuple[str, PythonModule | None]]:
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ dev = [
"mypy",
"ruff",
]
doc = [
"fastapi",
"typer",
"uvloop",
]
test = [
"fastapi",
"httpx",
Expand Down Expand Up @@ -119,5 +124,5 @@ ignore = ["N818"]
fixable = ["ALL"]

[tool.uv]
default-groups = ["bench", "dev", "test"]
default-groups = ["bench", "dev", "doc", "test"]
package = true
File renamed without changes.
12 changes: 12 additions & 0 deletions tests/entrypoint/test_autocall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from injection.entrypoint import autocall


def test_autocall_with_success():
count = 0

@autocall
def increment() -> None:
nonlocal count
count += 1

assert count == 1
68 changes: 68 additions & 0 deletions tests/entrypoint/test_entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from collections.abc import Iterator
from contextlib import contextmanager

from injection import injectable
from injection.entrypoint import Entrypoint


class TestEntrypoint:
def test_async_to_sync_with_success_return_entrypoint(self):
async def async_function() -> int:
return 42

entrypoint = Entrypoint(async_function).async_to_sync()
assert entrypoint() == 42

def test_decorate_with_success_return_entrypoint(self):
enter_count = 0
exit_count = 0

@contextmanager
def decorator() -> Iterator[None]:
nonlocal enter_count, exit_count
enter_count += 1
yield
exit_count += 1

def function():
assert enter_count == exit_count + 1

entrypoint = Entrypoint(function).decorate(decorator())
entrypoint()
assert enter_count == exit_count == 1

def test_inject_with_success_return_entrypoint(self):
@injectable
class Service: ...

def function(service: Service) -> bool:
return isinstance(service, Service)

entrypoint = Entrypoint(function).inject()
assert entrypoint()

def test_setup_with_success_return_entrypoint(self):
count = 0

def increment() -> None:
nonlocal count
count += 1

def function(): ...

entrypoint = Entrypoint(function).setup(increment)
entrypoint()
assert count == 1

def test_async_setup_with_success_return_entrypoint(self):
count = 0

async def increment() -> None:
nonlocal count
count += 1

async def function(): ...

entrypoint = Entrypoint(function).async_setup(increment).async_to_sync()
entrypoint()
assert count == 1
19 changes: 19 additions & 0 deletions tests/entrypoint/test_entrypointmaker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from injection.entrypoint import Entrypoint, entrypointmaker


def test_entrypointmaker_with_success_return_entrypoint_decorator():
count = 0

def increment() -> None:
nonlocal count
count += 1

@entrypointmaker
def entrypoint[**P, T](self: Entrypoint[P, T]) -> Entrypoint[P, T]:
Comment thread
remimd marked this conversation as resolved.
return self.setup(increment)

@entrypoint
def function(): ...

function()
assert count == 1
File renamed without changes.
File renamed without changes.
File renamed without changes.
Empty file.
File renamed without changes.
Loading