pythonboot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonboot/__init__.py +41 -0
- pythonboot/aop/__init__.py +11 -0
- pythonboot/aop/decorators.py +75 -0
- pythonboot/aop/joinpoint.py +33 -0
- pythonboot/aop/pointcut.py +81 -0
- pythonboot/aop/proxy.py +196 -0
- pythonboot/beans/__init__.py +7 -0
- pythonboot/beans/conditions.py +108 -0
- pythonboot/beans/definitions.py +108 -0
- pythonboot/beans/lifecycle.py +82 -0
- pythonboot/beans/scanner.py +164 -0
- pythonboot/boot/__init__.py +5 -0
- pythonboot/boot/autoconfiguration.py +120 -0
- pythonboot/boot/task_configuration.py +38 -0
- pythonboot/config/__init__.py +6 -0
- pythonboot/config/logging.py +90 -0
- pythonboot/config/properties.py +148 -0
- pythonboot/context/__init__.py +6 -0
- pythonboot/context/container.py +427 -0
- pythonboot/context/injection.py +60 -0
- pythonboot/exceptions/__init__.py +40 -0
- pythonboot/task/__init__.py +7 -0
- pythonboot/task/executor.py +241 -0
- pythonboot/transaction/__init__.py +24 -0
- pythonboot/transaction/decorators.py +48 -0
- pythonboot/transaction/definition.py +90 -0
- pythonboot/transaction/exceptions.py +31 -0
- pythonboot/transaction/interceptor.py +71 -0
- pythonboot/transaction/manager.py +114 -0
- pythonboot/util/__init__.py +5 -0
- pythonboot/util/context_util.py +59 -0
- pythonboot-0.1.0.dist-info/METADATA +502 -0
- pythonboot-0.1.0.dist-info/RECORD +34 -0
- pythonboot-0.1.0.dist-info/WHEEL +4 -0
pythonboot/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""PythonBoot public interfaces."""
|
|
2
|
+
|
|
3
|
+
from pythonboot.aop import (
|
|
4
|
+
JoinPoint, MethodAnnotation, MethodInfo, Pointcut, ProceedingJoinPoint, annotation,
|
|
5
|
+
after, after_returning, after_throwing, around, aspect, before, pointcut,
|
|
6
|
+
)
|
|
7
|
+
from pythonboot.beans import Scope, bean, component, configuration, ondestroy, postconstruct, service
|
|
8
|
+
from pythonboot.boot import auto_configuration
|
|
9
|
+
from pythonboot.beans.conditions import (
|
|
10
|
+
conditional_on_bean, conditional_on_missing_bean,
|
|
11
|
+
conditional_on_module, conditional_on_property,
|
|
12
|
+
)
|
|
13
|
+
from pythonboot.context import ApplicationContext, autowired
|
|
14
|
+
from pythonboot.config import ConfigurationPropertiesOptions, configuration_properties
|
|
15
|
+
from pythonboot.util import ContextUtil
|
|
16
|
+
from pythonboot.task import RejectionPolicy, TaskExecutor, TaskRejectedError, ThreadPoolTaskExecutor
|
|
17
|
+
from pythonboot.transaction import (
|
|
18
|
+
AsyncPlatformTransactionManager, AsyncSavepointManager, Isolation,
|
|
19
|
+
PlatformTransactionManager, Propagation, SavepointManager,
|
|
20
|
+
TransactionAttribute, TransactionDefinition, TransactionInterceptor, TransactionStatus,
|
|
21
|
+
get_transaction_attribute, transactional,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"auto_configuration", "conditional_on_bean", "conditional_on_missing_bean",
|
|
26
|
+
"conditional_on_module", "conditional_on_property",
|
|
27
|
+
"ApplicationContext", "bean", "component", "configuration", "service",
|
|
28
|
+
"aspect", "pointcut", "before", "after", "after_returning", "after_throwing", "around",
|
|
29
|
+
"JoinPoint", "ProceedingJoinPoint", "MethodInfo", "Pointcut",
|
|
30
|
+
"annotation", "MethodAnnotation",
|
|
31
|
+
"autowired",
|
|
32
|
+
"Scope",
|
|
33
|
+
"ContextUtil",
|
|
34
|
+
"TaskExecutor", "ThreadPoolTaskExecutor", "TaskRejectedError", "RejectionPolicy",
|
|
35
|
+
"postconstruct", "ondestroy",
|
|
36
|
+
"configuration_properties", "ConfigurationPropertiesOptions",
|
|
37
|
+
"Propagation", "Isolation", "TransactionDefinition", "TransactionAttribute",
|
|
38
|
+
"TransactionStatus", "PlatformTransactionManager", "AsyncPlatformTransactionManager",
|
|
39
|
+
"SavepointManager", "AsyncSavepointManager", "transactional", "get_transaction_attribute",
|
|
40
|
+
"TransactionInterceptor",
|
|
41
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public AOP declarations and invocation contexts."""
|
|
2
|
+
|
|
3
|
+
from pythonboot.aop.decorators import after, after_returning, after_throwing, around, aspect, before
|
|
4
|
+
from pythonboot.aop.joinpoint import JoinPoint, ProceedingJoinPoint
|
|
5
|
+
from pythonboot.aop.pointcut import MethodAnnotation, MethodInfo, Pointcut, annotation, pointcut
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"aspect", "pointcut", "before", "after", "after_returning", "after_throwing", "around",
|
|
9
|
+
"JoinPoint", "ProceedingJoinPoint", "MethodInfo", "Pointcut",
|
|
10
|
+
"annotation", "MethodAnnotation",
|
|
11
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Aspect and advice declarations without decoration-time instantiation."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Literal, TypeVar
|
|
6
|
+
|
|
7
|
+
from pythonboot.aop.pointcut import MethodAnnotation, MethodInfo, Pointcut
|
|
8
|
+
from pythonboot.beans import Scope, component
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T", bound=type)
|
|
11
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
AdviceKind = Literal["before", "after", "after_returning", "after_throwing", "around"]
|
|
13
|
+
Cut = str | Pointcut | MethodAnnotation | Callable[[MethodInfo], bool]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AdviceDefinition:
|
|
18
|
+
kind: AdviceKind
|
|
19
|
+
pointcut: Pointcut
|
|
20
|
+
order: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def aspect(
|
|
24
|
+
cls: T | None = None, *, order: int = 0, name: str | None = None,
|
|
25
|
+
init_method: str | None = None, destroy_method: str | None = None,
|
|
26
|
+
lazy: bool = False,
|
|
27
|
+
scope: Scope = "singleton", primary: bool = False,
|
|
28
|
+
qualifiers: tuple[str, ...] = (), depends_on: tuple[str, ...] = (),
|
|
29
|
+
) -> T | Callable[[T], T]:
|
|
30
|
+
"""Declare a managed aspect; lower order values enter the advice chain first."""
|
|
31
|
+
def decorate(target: T) -> T:
|
|
32
|
+
if not inspect.isclass(target):
|
|
33
|
+
raise TypeError("@aspect can only decorate classes")
|
|
34
|
+
component(
|
|
35
|
+
target, name=name, init_method=init_method, destroy_method=destroy_method,
|
|
36
|
+
lazy=lazy, scope=scope, primary=primary, qualifiers=qualifiers, depends_on=depends_on,
|
|
37
|
+
)
|
|
38
|
+
target.__pythonboot_aspect__ = order
|
|
39
|
+
return target
|
|
40
|
+
|
|
41
|
+
return decorate(cls) if cls is not None else decorate
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _advice(kind: AdviceKind, cut: Cut, order: int) -> Callable[[F], F]:
|
|
45
|
+
matcher = cut if isinstance(cut, Pointcut) else Pointcut(cut)
|
|
46
|
+
|
|
47
|
+
def decorate(method: F) -> F:
|
|
48
|
+
if not inspect.isfunction(method):
|
|
49
|
+
raise TypeError("Advice must be declared as an aspect instance method")
|
|
50
|
+
definitions = list(getattr(method, "__pythonboot_advices__", ()))
|
|
51
|
+
definitions.append(AdviceDefinition(kind, matcher, order))
|
|
52
|
+
method.__pythonboot_advices__ = tuple(definitions)
|
|
53
|
+
return method
|
|
54
|
+
|
|
55
|
+
return decorate
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def before(cut: Cut, *, order: int = 0) -> Callable[[F], F]:
|
|
59
|
+
return _advice("before", cut, order)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def after(cut: Cut, *, order: int = 0) -> Callable[[F], F]:
|
|
63
|
+
return _advice("after", cut, order)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def after_returning(cut: Cut, *, order: int = 0) -> Callable[[F], F]:
|
|
67
|
+
return _advice("after_returning", cut, order)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def after_throwing(cut: Cut, *, order: int = 0) -> Callable[[F], F]:
|
|
71
|
+
return _advice("after_throwing", cut, order)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def around(cut: Cut, *, order: int = 0) -> Callable[[F], F]:
|
|
75
|
+
return _advice("around", cut, order)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Context for a method invocation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class JoinPoint:
|
|
9
|
+
bean_name: str
|
|
10
|
+
target: Any
|
|
11
|
+
method: Callable[..., Any]
|
|
12
|
+
args: tuple[Any, ...]
|
|
13
|
+
kwargs: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def method_name(self) -> str:
|
|
17
|
+
return self.method.__name__
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def qualified_name(self) -> str:
|
|
21
|
+
cls = type(self.target)
|
|
22
|
+
return f"{cls.__module__}.{cls.__qualname__}.{self.method_name}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProceedingJoinPoint(JoinPoint):
|
|
27
|
+
_proceed: Callable[..., Any] = field(repr=False)
|
|
28
|
+
|
|
29
|
+
def proceed(self, *args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
"""Proceed with current arguments, or replace all arguments when supplied."""
|
|
31
|
+
if not args and not kwargs:
|
|
32
|
+
return self._proceed(*self.args, **self.kwargs)
|
|
33
|
+
return self._proceed(*args, **kwargs)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Pointcuts based on qualified method names, annotations, or predicates."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MethodInfo:
|
|
13
|
+
bean_name: str
|
|
14
|
+
target_type: type
|
|
15
|
+
method_name: str
|
|
16
|
+
method: Callable[..., Any]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def qualified_name(self) -> str:
|
|
20
|
+
return f"{self.target_type.__module__}.{self.target_type.__qualname__}.{self.method_name}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class MethodAnnotation:
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
29
|
+
raise ValueError("Aspect annotation names must be non-empty strings")
|
|
30
|
+
|
|
31
|
+
def __call__(self, method: F) -> F:
|
|
32
|
+
"""Mark a business method without changing its signature or coroutine type."""
|
|
33
|
+
if not inspect.isfunction(method):
|
|
34
|
+
raise TypeError("Aspect annotations can only decorate instance methods")
|
|
35
|
+
names = frozenset(getattr(method, "__pythonboot_annotations__", ()))
|
|
36
|
+
method.__pythonboot_annotations__ = names | {self.name}
|
|
37
|
+
return method
|
|
38
|
+
|
|
39
|
+
def matches(self, method: MethodInfo) -> bool:
|
|
40
|
+
return self.name in getattr(method.method, "__pythonboot_annotations__", ())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def annotation(name: str) -> MethodAnnotation:
|
|
44
|
+
"""Create an aspect annotation usable as @logged and @around(logged)."""
|
|
45
|
+
return MethodAnnotation(name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _matches(pattern: tuple[str, ...], name: tuple[str, ...]) -> bool:
|
|
49
|
+
if not pattern:
|
|
50
|
+
return not name
|
|
51
|
+
if pattern[0] == "**":
|
|
52
|
+
return _matches(pattern[1:], name) or (
|
|
53
|
+
bool(name) and _matches(pattern, name[1:])
|
|
54
|
+
)
|
|
55
|
+
return bool(name) and fnmatchcase(name[0], pattern[0]) and _matches(pattern[1:], name[1:])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Pointcut:
|
|
60
|
+
expression: str | MethodAnnotation | Callable[[MethodInfo], bool]
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
if isinstance(self.expression, str):
|
|
64
|
+
if not self.expression or any(
|
|
65
|
+
not part or ("**" in part and part != "**")
|
|
66
|
+
for part in self.expression.split(".")
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Pointcut expressions must not be empty; ** must occupy an entire segment")
|
|
69
|
+
elif not callable(self.expression):
|
|
70
|
+
raise TypeError("Pointcuts must be method path expressions, annotations, or matching functions")
|
|
71
|
+
|
|
72
|
+
def matches(self, method: MethodInfo) -> bool:
|
|
73
|
+
if isinstance(self.expression, MethodAnnotation):
|
|
74
|
+
return self.expression.matches(method)
|
|
75
|
+
if callable(self.expression):
|
|
76
|
+
return bool(self.expression(method))
|
|
77
|
+
return _matches(tuple(self.expression.split(".")), tuple(method.qualified_name.split(".")))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pointcut(expression: str | MethodAnnotation | Callable[[MethodInfo], bool]) -> Pointcut:
|
|
81
|
+
return Pointcut(expression)
|
pythonboot/aop/proxy.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Application-scoped bean proxies and synchronous or asynchronous advice chains."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import wraps
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import wrapt
|
|
10
|
+
|
|
11
|
+
from pythonboot.aop.decorators import AdviceDefinition
|
|
12
|
+
from pythonboot.aop.joinpoint import JoinPoint, ProceedingJoinPoint
|
|
13
|
+
from pythonboot.aop.pointcut import MethodInfo
|
|
14
|
+
from pythonboot.exceptions import AopError
|
|
15
|
+
from pythonboot.transaction.interceptor import TransactionInterceptor
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Advisor:
|
|
20
|
+
bean_name: str
|
|
21
|
+
aspect_type: type
|
|
22
|
+
method_name: str
|
|
23
|
+
method: Callable[..., Any]
|
|
24
|
+
definition: AdviceDefinition
|
|
25
|
+
aspect_order: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BeanProxy(wrapt.ObjectProxy):
|
|
29
|
+
def __init__(self, target: Any, methods: dict[str, Callable[..., Any]]) -> None:
|
|
30
|
+
super().__init__(target)
|
|
31
|
+
self._self_methods = methods
|
|
32
|
+
|
|
33
|
+
def __getattr__(self, name: str) -> Any:
|
|
34
|
+
if name in self._self_methods:
|
|
35
|
+
return self._self_methods[name]
|
|
36
|
+
return getattr(self.__wrapped__, name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AopEngine:
|
|
40
|
+
def __init__(
|
|
41
|
+
self, beans: tuple[tuple[str, type], ...],
|
|
42
|
+
resolver: Callable[[type, str | None], Any],
|
|
43
|
+
*, async_resolver: Callable[[type, str | None], Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._resolver = resolver
|
|
46
|
+
self._transaction_interceptor = TransactionInterceptor(resolver, async_resolver)
|
|
47
|
+
transaction_advice = TransactionInterceptor.invoke.__pythonboot_advices__[0]
|
|
48
|
+
advisors: list[Advisor] = [Advisor(
|
|
49
|
+
"pythonboot.transactionInterceptor", TransactionInterceptor, "invoke",
|
|
50
|
+
TransactionInterceptor.invoke, transaction_advice, -sys.maxsize - 1,
|
|
51
|
+
)]
|
|
52
|
+
for bean_name, cls in beans:
|
|
53
|
+
if not hasattr(cls, "__pythonboot_aspect__"):
|
|
54
|
+
continue
|
|
55
|
+
for method_name, method in inspect.getmembers(cls, inspect.isfunction):
|
|
56
|
+
definitions = getattr(method, "__pythonboot_advices__", ())
|
|
57
|
+
if not definitions:
|
|
58
|
+
continue
|
|
59
|
+
descriptor = inspect.getattr_static(cls, method_name)
|
|
60
|
+
if isinstance(descriptor, (staticmethod, classmethod)):
|
|
61
|
+
raise AopError("Aspect advice must use instance methods")
|
|
62
|
+
if inspect.isgeneratorfunction(method) or inspect.isasyncgenfunction(method):
|
|
63
|
+
raise AopError("Aspect advice does not support generators")
|
|
64
|
+
for definition in definitions:
|
|
65
|
+
parameter_count = 2 if definition.kind in ("after_returning", "after_throwing") else 1
|
|
66
|
+
try:
|
|
67
|
+
inspect.signature(method).bind(object(), *[object() for _ in range(parameter_count)])
|
|
68
|
+
except TypeError as error:
|
|
69
|
+
raise AopError(f"Invalid advice signature: {cls.__name__}.{method_name}") from error
|
|
70
|
+
advisors.append(Advisor(
|
|
71
|
+
bean_name, cls, method_name, method, definition,
|
|
72
|
+
cls.__pythonboot_aspect__,
|
|
73
|
+
))
|
|
74
|
+
self._advisors = tuple(sorted(advisors, key=lambda item: (
|
|
75
|
+
item.aspect_order, item.definition.order, item.bean_name, item.method_name,
|
|
76
|
+
)))
|
|
77
|
+
|
|
78
|
+
def create_proxy(
|
|
79
|
+
self, target: Any, bean_name: str, *, excluded_methods: tuple[str, ...] = (),
|
|
80
|
+
) -> Any:
|
|
81
|
+
if not self._advisors or hasattr(type(target), "__pythonboot_aspect__"):
|
|
82
|
+
return target
|
|
83
|
+
methods: dict[str, Callable[..., Any]] = {}
|
|
84
|
+
for method_name, method in inspect.getmembers(type(target), inspect.isfunction):
|
|
85
|
+
if method_name.startswith("_") or method_name in excluded_methods:
|
|
86
|
+
continue
|
|
87
|
+
descriptor = inspect.getattr_static(type(target), method_name)
|
|
88
|
+
if isinstance(descriptor, (staticmethod, classmethod)):
|
|
89
|
+
continue
|
|
90
|
+
info = MethodInfo(bean_name, type(target), method_name, method)
|
|
91
|
+
advisors = tuple(item for item in self._advisors if item.definition.pointcut.matches(info))
|
|
92
|
+
if not advisors:
|
|
93
|
+
continue
|
|
94
|
+
if inspect.isgeneratorfunction(method) or inspect.isasyncgenfunction(method):
|
|
95
|
+
raise AopError(f"AOP does not support generator methods: {info.qualified_name}")
|
|
96
|
+
is_async = inspect.iscoroutinefunction(method)
|
|
97
|
+
if not is_async and any(inspect.iscoroutinefunction(item.method) for item in advisors):
|
|
98
|
+
raise AopError(f"Synchronous methods cannot match asynchronous advice: {info.qualified_name}")
|
|
99
|
+
methods[method_name] = self._wrap(getattr(target, method_name), target, bean_name, advisors, is_async)
|
|
100
|
+
return BeanProxy(target, methods) if methods else target
|
|
101
|
+
|
|
102
|
+
def _advice(self, advisor: Advisor) -> Callable[..., Any]:
|
|
103
|
+
if advisor.aspect_type is TransactionInterceptor:
|
|
104
|
+
return self._transaction_interceptor.invoke
|
|
105
|
+
aspect = self._resolver(advisor.aspect_type, advisor.bean_name)
|
|
106
|
+
return getattr(aspect, advisor.method_name)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _sync_result(result: Any) -> Any:
|
|
110
|
+
if inspect.isawaitable(result):
|
|
111
|
+
if inspect.iscoroutine(result):
|
|
112
|
+
result.close()
|
|
113
|
+
raise AopError("Synchronous advice returned an awaitable; use synchronous advice or declare the target method as async")
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
async def _async_result(result: Any) -> Any:
|
|
118
|
+
return await result if inspect.isawaitable(result) else result
|
|
119
|
+
|
|
120
|
+
def _wrap(
|
|
121
|
+
self, method: Callable[..., Any], target: Any, bean_name: str,
|
|
122
|
+
advisors: tuple[Advisor, ...], is_async: bool,
|
|
123
|
+
) -> Callable[..., Any]:
|
|
124
|
+
def invoke(index: int, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
125
|
+
if index == len(advisors):
|
|
126
|
+
return self._sync_result(method(*args, **kwargs))
|
|
127
|
+
advisor = advisors[index]
|
|
128
|
+
advice = self._advice(advisor)
|
|
129
|
+
kind = advisor.definition.kind
|
|
130
|
+
jp = JoinPoint(bean_name, target, method, args, dict(kwargs))
|
|
131
|
+
|
|
132
|
+
def proceed(*next_args: Any, **next_kwargs: Any) -> Any:
|
|
133
|
+
return invoke(index + 1, next_args, next_kwargs)
|
|
134
|
+
|
|
135
|
+
if kind == "around":
|
|
136
|
+
pjp = ProceedingJoinPoint(bean_name, target, method, args, dict(kwargs), proceed)
|
|
137
|
+
return self._sync_result(advice(pjp))
|
|
138
|
+
if kind == "before":
|
|
139
|
+
self._sync_result(advice(jp))
|
|
140
|
+
return proceed(*jp.args, **jp.kwargs)
|
|
141
|
+
if kind == "after":
|
|
142
|
+
try:
|
|
143
|
+
return proceed(*jp.args, **jp.kwargs)
|
|
144
|
+
finally:
|
|
145
|
+
self._sync_result(advice(jp))
|
|
146
|
+
if kind == "after_throwing":
|
|
147
|
+
try:
|
|
148
|
+
return proceed(*jp.args, **jp.kwargs)
|
|
149
|
+
except BaseException as error:
|
|
150
|
+
self._sync_result(advice(jp, error))
|
|
151
|
+
raise
|
|
152
|
+
result = proceed(*jp.args, **jp.kwargs)
|
|
153
|
+
self._sync_result(advice(jp, result))
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
async def invoke_async(index: int, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
157
|
+
if index == len(advisors):
|
|
158
|
+
return await self._async_result(method(*args, **kwargs))
|
|
159
|
+
advisor = advisors[index]
|
|
160
|
+
advice = self._advice(advisor)
|
|
161
|
+
kind = advisor.definition.kind
|
|
162
|
+
jp = JoinPoint(bean_name, target, method, args, dict(kwargs))
|
|
163
|
+
|
|
164
|
+
async def proceed(*next_args: Any, **next_kwargs: Any) -> Any:
|
|
165
|
+
return await invoke_async(index + 1, next_args, next_kwargs)
|
|
166
|
+
|
|
167
|
+
if kind == "around":
|
|
168
|
+
pjp = ProceedingJoinPoint(bean_name, target, method, args, dict(kwargs), proceed)
|
|
169
|
+
return await self._async_result(advice(pjp))
|
|
170
|
+
if kind == "before":
|
|
171
|
+
await self._async_result(advice(jp))
|
|
172
|
+
return await proceed(*jp.args, **jp.kwargs)
|
|
173
|
+
if kind == "after":
|
|
174
|
+
try:
|
|
175
|
+
return await proceed(*jp.args, **jp.kwargs)
|
|
176
|
+
finally:
|
|
177
|
+
await self._async_result(advice(jp))
|
|
178
|
+
if kind == "after_throwing":
|
|
179
|
+
try:
|
|
180
|
+
return await proceed(*jp.args, **jp.kwargs)
|
|
181
|
+
except BaseException as error:
|
|
182
|
+
await self._async_result(advice(jp, error))
|
|
183
|
+
raise
|
|
184
|
+
result = await proceed(*jp.args, **jp.kwargs)
|
|
185
|
+
await self._async_result(advice(jp, result))
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
if is_async:
|
|
189
|
+
@wraps(method)
|
|
190
|
+
async def endpoint(*args: Any, **kwargs: Any) -> Any:
|
|
191
|
+
return await invoke_async(0, args, kwargs)
|
|
192
|
+
else:
|
|
193
|
+
@wraps(method)
|
|
194
|
+
def endpoint(*args: Any, **kwargs: Any) -> Any:
|
|
195
|
+
return invoke(0, args, kwargs)
|
|
196
|
+
return endpoint
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Bean declarations, metadata, and component scanning."""
|
|
2
|
+
|
|
3
|
+
from pythonboot.beans.definitions import BeanDefinition, Scope, bean, component, configuration, service
|
|
4
|
+
from pythonboot.beans.scanner import ScanResult, scan_components
|
|
5
|
+
from pythonboot.beans.lifecycle import ondestroy, postconstruct
|
|
6
|
+
|
|
7
|
+
__all__ = ["BeanDefinition", "Scope", "ScanResult", "bean", "component", "configuration", "service", "scan_components", "postconstruct", "ondestroy"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Declarative conditions evaluated while assembling auto-configurations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.util import find_spec
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from pythonboot.beans.definitions import BeanDefinition
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
_MISSING = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Condition:
|
|
16
|
+
kind: str
|
|
17
|
+
values: tuple[type | str, ...] = ()
|
|
18
|
+
having_value: Any = _MISSING
|
|
19
|
+
match_if_missing: bool = False
|
|
20
|
+
|
|
21
|
+
def matches(
|
|
22
|
+
self, definitions: tuple[BeanDefinition, ...], configuration: Mapping[str, Any],
|
|
23
|
+
) -> bool:
|
|
24
|
+
if self.kind == "module":
|
|
25
|
+
for module in self.values:
|
|
26
|
+
try:
|
|
27
|
+
if find_spec(module) is None:
|
|
28
|
+
return False
|
|
29
|
+
except ModuleNotFoundError as error:
|
|
30
|
+
if error.name == module or module.startswith(f"{error.name}."):
|
|
31
|
+
return False
|
|
32
|
+
raise
|
|
33
|
+
return True
|
|
34
|
+
if self.kind == "property":
|
|
35
|
+
value: Any = configuration
|
|
36
|
+
for part in self.values[0].split("."):
|
|
37
|
+
if not isinstance(value, Mapping) or part not in value:
|
|
38
|
+
return self.match_if_missing
|
|
39
|
+
value = value[part]
|
|
40
|
+
if self.having_value is _MISSING:
|
|
41
|
+
return value is not None and str(value).lower() != "false"
|
|
42
|
+
return str(value).lower() == str(self.having_value).lower()
|
|
43
|
+
matches = tuple(
|
|
44
|
+
any(
|
|
45
|
+
definition.name == value if isinstance(value, str)
|
|
46
|
+
else issubclass(definition.bean_type, value)
|
|
47
|
+
for definition in definitions
|
|
48
|
+
)
|
|
49
|
+
for value in self.values
|
|
50
|
+
)
|
|
51
|
+
return not any(matches) if self.kind == "missing_bean" else all(matches)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _declare(condition: Condition) -> Callable[[T], T]:
|
|
55
|
+
def decorate(target: T) -> T:
|
|
56
|
+
if not isinstance(target, type) and not callable(target):
|
|
57
|
+
raise TypeError("Conditions can only decorate configuration classes or bean factories")
|
|
58
|
+
conditions = vars(target).get("__pythonboot_conditions__", ())
|
|
59
|
+
target.__pythonboot_conditions__ = (*conditions, condition)
|
|
60
|
+
return target
|
|
61
|
+
return decorate
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def conditional_on_module(*modules: str) -> Callable[[T], T]:
|
|
65
|
+
"""Enable an auto-configuration or factory when all modules are available."""
|
|
66
|
+
if not modules or any(
|
|
67
|
+
not isinstance(module, str) or not all(part.isidentifier() for part in module.split("."))
|
|
68
|
+
for module in modules
|
|
69
|
+
):
|
|
70
|
+
raise ValueError("Module conditions require non-empty dotted module names")
|
|
71
|
+
return _declare(Condition("module", modules))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def conditional_on_property(
|
|
75
|
+
name: str, *, having_value: Any = _MISSING, match_if_missing: bool = False,
|
|
76
|
+
) -> Callable[[T], T]:
|
|
77
|
+
"""Match a dotted application YAML property without creating any beans."""
|
|
78
|
+
if not isinstance(name, str) or any(not part.strip() for part in name.split(".")):
|
|
79
|
+
raise ValueError("Property conditions require a non-empty dotted name")
|
|
80
|
+
return _declare(Condition("property", (name,), having_value, match_if_missing))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _bean_condition(kind: str, beans: tuple[type | str, ...]) -> Callable[[T], T]:
|
|
84
|
+
if not beans or any(
|
|
85
|
+
not isinstance(bean, (type, str)) or isinstance(bean, str) and not bean.strip()
|
|
86
|
+
for bean in beans
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("Bean conditions require bean types or non-empty bean names")
|
|
89
|
+
return _declare(Condition(kind, beans))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def conditional_on_bean(*beans: type | str) -> Callable[[T], T]:
|
|
93
|
+
"""Match when all specified bean types or names have been registered."""
|
|
94
|
+
return _bean_condition("bean", beans)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def conditional_on_missing_bean(*beans: type | str) -> Callable[[T], T]:
|
|
98
|
+
"""Back off when any specified bean type or name is already registered."""
|
|
99
|
+
return _bean_condition("missing_bean", beans)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def matches_conditions(
|
|
103
|
+
target: object, definitions: tuple[BeanDefinition, ...], configuration: Mapping[str, Any],
|
|
104
|
+
) -> bool:
|
|
105
|
+
return all(
|
|
106
|
+
condition.matches(definitions, configuration)
|
|
107
|
+
for condition in vars(target).get("__pythonboot_conditions__", ())
|
|
108
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Component declarations and scanning metadata."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any, Callable, Literal, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T", bound=type)
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
Scope = Literal["singleton", "prototype"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ComponentOptions:
|
|
14
|
+
name: str | None = None
|
|
15
|
+
init_method: str | None = None
|
|
16
|
+
destroy_method: str | None = None
|
|
17
|
+
lazy: bool = False
|
|
18
|
+
scope: Scope = "singleton"
|
|
19
|
+
primary: bool = False
|
|
20
|
+
qualifiers: tuple[str, ...] = ()
|
|
21
|
+
depends_on: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.scope not in ("singleton", "prototype"):
|
|
25
|
+
raise ValueError("scope must be singleton or prototype")
|
|
26
|
+
for label, value in (("name", self.name), ("init_method", self.init_method), ("destroy_method", self.destroy_method)):
|
|
27
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
28
|
+
raise ValueError(f"{label} must be a non-empty string")
|
|
29
|
+
for label in ("qualifiers", "depends_on"):
|
|
30
|
+
values = getattr(self, label)
|
|
31
|
+
if isinstance(values, str) or any(not isinstance(value, str) or not value.strip() for value in values):
|
|
32
|
+
raise ValueError(f"{label} must be a sequence of non-empty strings")
|
|
33
|
+
object.__setattr__(self, label, tuple(dict.fromkeys(values)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class BeanDefinition:
|
|
38
|
+
name: str
|
|
39
|
+
bean_type: type
|
|
40
|
+
factory: Callable[..., Any]
|
|
41
|
+
owner: type | None = None
|
|
42
|
+
init_method: str | None = None
|
|
43
|
+
destroy_method: str | None = None
|
|
44
|
+
lazy: bool = False
|
|
45
|
+
scope: Scope = "singleton"
|
|
46
|
+
primary: bool = False
|
|
47
|
+
qualifiers: tuple[str, ...] = ()
|
|
48
|
+
depends_on: tuple[str, ...] = ()
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
options = ComponentOptions(
|
|
52
|
+
self.name, self.init_method, self.destroy_method, self.lazy,
|
|
53
|
+
self.scope, self.primary, self.qualifiers, self.depends_on,
|
|
54
|
+
)
|
|
55
|
+
object.__setattr__(self, "qualifiers", options.qualifiers)
|
|
56
|
+
object.__setattr__(self, "depends_on", options.depends_on)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def component(
|
|
60
|
+
cls: T | None = None,
|
|
61
|
+
*,
|
|
62
|
+
name: str | None = None,
|
|
63
|
+
init_method: str | None = None,
|
|
64
|
+
destroy_method: str | None = None,
|
|
65
|
+
lazy: bool = False,
|
|
66
|
+
scope: Scope = "singleton",
|
|
67
|
+
primary: bool = False,
|
|
68
|
+
qualifiers: tuple[str, ...] = (),
|
|
69
|
+
depends_on: tuple[str, ...] = (),
|
|
70
|
+
) -> T | Callable[[T], T]:
|
|
71
|
+
"""Declare a managed component, singleton by default, without instantiating it."""
|
|
72
|
+
def decorate(target: T) -> T:
|
|
73
|
+
if not inspect.isclass(target):
|
|
74
|
+
raise TypeError("Component decorators can only decorate classes")
|
|
75
|
+
target.__pythonboot_component__ = ComponentOptions(
|
|
76
|
+
name, init_method, destroy_method, lazy, scope, primary, qualifiers, depends_on
|
|
77
|
+
)
|
|
78
|
+
return target
|
|
79
|
+
|
|
80
|
+
return decorate(cls) if cls is not None else decorate
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
service = component
|
|
84
|
+
configuration = component
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bean(
|
|
88
|
+
factory: F | None = None,
|
|
89
|
+
*,
|
|
90
|
+
name: str | None = None,
|
|
91
|
+
init_method: str | None = None,
|
|
92
|
+
destroy_method: str | None = None,
|
|
93
|
+
lazy: bool = False,
|
|
94
|
+
scope: Scope = "singleton",
|
|
95
|
+
primary: bool = False,
|
|
96
|
+
qualifiers: tuple[str, ...] = (),
|
|
97
|
+
depends_on: tuple[str, ...] = (),
|
|
98
|
+
) -> F | Callable[[F], F]:
|
|
99
|
+
"""Declare a module-level factory or a configuration bean factory method."""
|
|
100
|
+
def decorate(target: F) -> F:
|
|
101
|
+
if not inspect.isfunction(target):
|
|
102
|
+
raise TypeError("@bean decorates factory methods; use @component for classes")
|
|
103
|
+
target.__pythonboot_bean__ = ComponentOptions(
|
|
104
|
+
name, init_method, destroy_method, lazy, scope, primary, qualifiers, depends_on
|
|
105
|
+
)
|
|
106
|
+
return target
|
|
107
|
+
|
|
108
|
+
return decorate(factory) if factory is not None else decorate
|