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 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)
@@ -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