-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: ✨ Introduce entrypoints #235
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
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| # Utils | ||
| # Loaders | ||
|
|
||
| ## PythonModuleLoader | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| return decorator | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]: | ||
|
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Empty file.
File renamed without changes.
File renamed without changes.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.