Skip to content

Commit b1d91a7

Browse files
authored
feat: ✨ Introduce entrypoints
1 parent 5846b4f commit b1d91a7

27 files changed

Lines changed: 446 additions & 83 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ if __name__ == "__main__":
6262
* [**Scoped dependencies**](https://github.com/100nm/python-injection/tree/prod/documentation/scoped-dependencies.md)
6363
* [**Testing**](https://github.com/100nm/python-injection/tree/prod/documentation/testing.md)
6464
* [**Advanced usage**](https://github.com/100nm/python-injection/tree/prod/documentation/advanced-usage.md)
65-
* [**Utils**](https://github.com/100nm/python-injection/tree/prod/documentation/utils.md)
65+
* [**Loaders**](https://github.com/100nm/python-injection/tree/prod/documentation/loaders.md)
66+
* [**Entrypoint**](https://github.com/100nm/python-injection/tree/prod/documentation/entrypoint.md)
6667
* [**Integrations**](https://github.com/100nm/python-injection/tree/prod/documentation/integrations.md)
6768
* [**Concrete example**](https://github.com/100nm/python-injection-example)

documentation/basic-usage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ classes.
136136

137137
> [!WARNING]
138138
> If the child class is in another file, make sure that file is imported before injection.
139-
> [_See `load_packages` function._](utils.md#load_packages)
139+
> [_See `load_packages` function._](loaders.md#load_packages)
140140
141141
_Example with one class:_
142142

documentation/entrypoint.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Entrypoint
2+
3+
## What is it?
4+
5+
_An entrypoint is the first function executed when a software component starts._
6+
7+
When using `python-injection`, you often need to perform several setup actions at the entrypoint _(such as injecting
8+
dependencies, opening a scope, or importing Python modules)_.
9+
10+
To solve this problem, the package provides an `Entrypoint` class, a builder-style utility that simplifies
11+
entrypoint preparation.
12+
13+
## Creating an entrypoint decorator
14+
15+
`entrypoint_maker` allows you to define a custom decorator for your entrypoint functions.
16+
17+
The function you decorate with `entrypoint_maker` serves to configure the `Entrypoint` instance. Its first parameter must
18+
be the `Entrypoint` instance being built. You can inject dependencies into this setup function, but **only** `constants`
19+
or `injectables`, because everything is not yet fully configured at this stage.
20+
21+
**Instruction order matters**: each configuration step applies a decorator and returns a new `Entrypoint` instance.
22+
23+
```python
24+
# src/entrypoint.py
25+
26+
import uvloop
27+
from injection import adefine_scope
28+
from injection.entrypoint import AsyncEntrypoint, Entrypoint, entrypointmaker
29+
from injection.loaders import PythonModuleLoader
30+
31+
@entrypointmaker
32+
def entrypoint[**P, T](self: AsyncEntrypoint[P, T]) -> Entrypoint[P, T]:
33+
import src
34+
35+
loader = PythonModuleLoader.from_keywords("# Auto-import")
36+
return (
37+
self.inject()
38+
.decorate(adefine_scope("lifespan", kind="shared"))
39+
.async_to_sync(uvloop.run)
40+
.load_modules(loader, src)
41+
)
42+
```
43+
44+
> [!IMPORTANT]
45+
> **Typing rule**
46+
>
47+
> When creating a decorator for async entrypoints, make sure to type `self` as `AsyncEntrypoint`.
48+
> For sync code, use `Entrypoint` instead.
49+
50+
## Example of use
51+
52+
Developing a CLI is a good example of using multiple entrypoints:
53+
54+
```python
55+
# src/cli.py
56+
57+
from injection.entrypoint import autocall
58+
from typer import Typer
59+
60+
from src.entrypoint import entrypoint
61+
from src.services.logger import AsyncLogger # project service, implementation not provided
62+
63+
app = Typer()
64+
65+
@app.command()
66+
def hello(name: str) -> None:
67+
@autocall # allows automatically calling the function
68+
@entrypoint
69+
async def _(logger: AsyncLogger) -> None:
70+
await logger.info(f"Hello {name}!")
71+
72+
@app.command()
73+
def goodbye(name: str) -> None:
74+
@autocall
75+
@entrypoint
76+
async def _(logger: AsyncLogger) -> None:
77+
await logger.info(f"Goodbye {name}!")
78+
79+
if __name__ == "__main__":
80+
app()
81+
```
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Utils
1+
# Loaders
22

33
## PythonModuleLoader
44

injection/entrypoint.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator
5+
from contextlib import asynccontextmanager, contextmanager
6+
from dataclasses import dataclass, field
7+
from functools import wraps
8+
from types import MethodType
9+
from types import ModuleType as PythonModule
10+
from typing import Any, Self, final, overload
11+
12+
from injection import Module, mod
13+
from injection.loaders import PythonModuleLoader
14+
15+
__all__ = ("AsyncEntrypoint", "Entrypoint", "autocall", "entrypointmaker")
16+
17+
type AsyncEntrypoint[**P, T] = Entrypoint[P, Coroutine[Any, Any, T]]
18+
type EntrypointDecorator[**P, T1, T2] = Callable[[Callable[P, T1]], Callable[P, T2]]
19+
type EntrypointSetupMethod[*Ts, **P, T1, T2] = Callable[
20+
[Entrypoint[P, T1], *Ts],
21+
Entrypoint[P, T2],
22+
]
23+
24+
25+
def autocall[**P, T](wrapped: Callable[P, T] | None = None, /) -> Any:
26+
def decorator(wp: Callable[P, T]) -> Callable[P, T]:
27+
wp() # type: ignore[call-arg]
28+
return wp
29+
30+
return decorator(wrapped) if wrapped else decorator
31+
32+
33+
@overload
34+
def entrypointmaker[*Ts, **P, T1, T2](
35+
wrapped: EntrypointSetupMethod[*Ts, P, T1, T2],
36+
/,
37+
*,
38+
module: Module | None = ...,
39+
) -> EntrypointDecorator[P, T1, T2]: ...
40+
41+
42+
@overload
43+
def entrypointmaker[*Ts, **P, T1, T2](
44+
wrapped: None = ...,
45+
/,
46+
*,
47+
module: Module | None = ...,
48+
) -> Callable[
49+
[EntrypointSetupMethod[*Ts, P, T1, T2]],
50+
EntrypointDecorator[P, T1, T2],
51+
]: ...
52+
53+
54+
def entrypointmaker[*Ts, **P, T1, T2](
55+
wrapped: EntrypointSetupMethod[*Ts, P, T1, T2] | None = None,
56+
/,
57+
*,
58+
module: Module | None = None,
59+
) -> Any:
60+
def decorator(
61+
wp: EntrypointSetupMethod[*Ts, P, T1, T2],
62+
) -> EntrypointDecorator[P, T1, T2]:
63+
return Entrypoint._make_decorator(wp, module)
64+
65+
return decorator(wrapped) if wrapped else decorator
66+
67+
68+
@final
69+
@dataclass(repr=False, eq=False, frozen=True, slots=True)
70+
class Entrypoint[**P, T]:
71+
function: Callable[P, T]
72+
module: Module = field(default_factory=mod)
73+
74+
def __call__(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
75+
return self.function(*args, **kwargs)
76+
77+
def async_to_sync[_T](
78+
self: AsyncEntrypoint[P, _T],
79+
run: Callable[[Coroutine[Any, Any, _T]], _T] = asyncio.run,
80+
/,
81+
) -> Entrypoint[P, _T]:
82+
function = self.function
83+
84+
@wraps(function)
85+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> _T:
86+
return run(function(*args, **kwargs))
87+
88+
return self.__recreate(wrapper)
89+
90+
def decorate(
91+
self,
92+
decorator: Callable[[Callable[P, T]], Callable[P, T]],
93+
/,
94+
) -> Self:
95+
return self.__recreate(decorator(self.function))
96+
97+
def inject(self) -> Self:
98+
return self.decorate(self.module.make_injected_function)
99+
100+
def load_modules(
101+
self,
102+
/,
103+
loader: PythonModuleLoader,
104+
*packages: PythonModule | str,
105+
) -> Self:
106+
return self.setup(lambda: loader.load(*packages))
107+
108+
def setup(self, function: Callable[..., Any], /) -> Self:
109+
@contextmanager
110+
def decorator() -> Iterator[Any]:
111+
yield function()
112+
113+
return self.decorate(decorator())
114+
115+
def async_setup[_T](
116+
self: AsyncEntrypoint[P, _T],
117+
function: Callable[..., Awaitable[Any]],
118+
/,
119+
) -> AsyncEntrypoint[P, _T]:
120+
@asynccontextmanager
121+
async def decorator() -> AsyncIterator[Any]:
122+
yield await function()
123+
124+
return self.decorate(decorator())
125+
126+
def __recreate[**_P, _T](
127+
self: Entrypoint[Any, Any],
128+
function: Callable[_P, _T],
129+
/,
130+
) -> Entrypoint[_P, _T]:
131+
return type(self)(function, self.module)
132+
133+
@classmethod
134+
def _make_decorator[*Ts, _T](
135+
cls,
136+
setup_method: EntrypointSetupMethod[*Ts, P, T, _T],
137+
/,
138+
module: Module | None = None,
139+
) -> EntrypointDecorator[P, T, _T]:
140+
module = module or mod()
141+
setup_method = module.make_injected_function(setup_method)
142+
143+
def decorator(function: Callable[P, T]) -> Callable[P, _T]:
144+
self = cls(function, module)
145+
return MethodType(setup_method, self)().function
146+
147+
return decorator

injection/loaders.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def modules(self) -> dict[str, PythonModule]:
5757

5858
def load(self, *packages: PythonModule | str) -> Self:
5959
modules = itertools.chain.from_iterable(
60-
self.__iter_modules(package) for package in packages
60+
self.__iter_modules_from(package) for package in packages
6161
)
6262
self.__modules.update(modules)
6363
return self
@@ -67,7 +67,7 @@ def __is_already_loaded(self, module_name: str) -> bool:
6767
module_name in modules for modules in (self.__modules, self._sys_modules)
6868
)
6969

70-
def __iter_modules(
70+
def __iter_modules_from(
7171
self,
7272
package: PythonModule | str,
7373
) -> Iterator[tuple[str, PythonModule | None]]:

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ dev = [
1313
"mypy",
1414
"ruff",
1515
]
16+
doc = [
17+
"fastapi",
18+
"typer",
19+
"uvloop",
20+
]
1621
test = [
1722
"fastapi",
1823
"httpx",
@@ -119,5 +124,5 @@ ignore = ["N818"]
119124
fixable = ["ALL"]
120125

121126
[tool.uv]
122-
default-groups = ["bench", "dev", "test"]
127+
default-groups = ["bench", "dev", "doc", "test"]
123128
package = true

tests/entrypoint/test_autocall.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from injection.entrypoint import autocall
2+
3+
4+
def test_autocall_with_success():
5+
count = 0
6+
7+
@autocall
8+
def increment() -> None:
9+
nonlocal count
10+
count += 1
11+
12+
assert count == 1
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from collections.abc import Iterator
2+
from contextlib import contextmanager
3+
4+
from injection import injectable
5+
from injection.entrypoint import Entrypoint
6+
7+
8+
class TestEntrypoint:
9+
def test_async_to_sync_with_success_return_entrypoint(self):
10+
async def async_function() -> int:
11+
return 42
12+
13+
entrypoint = Entrypoint(async_function).async_to_sync()
14+
assert entrypoint() == 42
15+
16+
def test_decorate_with_success_return_entrypoint(self):
17+
enter_count = 0
18+
exit_count = 0
19+
20+
@contextmanager
21+
def decorator() -> Iterator[None]:
22+
nonlocal enter_count, exit_count
23+
enter_count += 1
24+
yield
25+
exit_count += 1
26+
27+
def function():
28+
assert enter_count == exit_count + 1
29+
30+
entrypoint = Entrypoint(function).decorate(decorator())
31+
entrypoint()
32+
assert enter_count == exit_count == 1
33+
34+
def test_inject_with_success_return_entrypoint(self):
35+
@injectable
36+
class Service: ...
37+
38+
def function(service: Service) -> bool:
39+
return isinstance(service, Service)
40+
41+
entrypoint = Entrypoint(function).inject()
42+
assert entrypoint()
43+
44+
def test_setup_with_success_return_entrypoint(self):
45+
count = 0
46+
47+
def increment() -> None:
48+
nonlocal count
49+
count += 1
50+
51+
def function(): ...
52+
53+
entrypoint = Entrypoint(function).setup(increment)
54+
entrypoint()
55+
assert count == 1
56+
57+
def test_async_setup_with_success_return_entrypoint(self):
58+
count = 0
59+
60+
async def increment() -> None:
61+
nonlocal count
62+
count += 1
63+
64+
async def function(): ...
65+
66+
entrypoint = Entrypoint(function).async_setup(increment).async_to_sync()
67+
entrypoint()
68+
assert count == 1

0 commit comments

Comments
 (0)