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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Bean initialization callback declarations."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
from pythonboot.exceptions import BeanDefinitionError
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def postconstruct(method: F | None = None) -> F | Callable[[F], F]:
|
|
12
|
+
"""Invoke an instance method once after dependency injection completes."""
|
|
13
|
+
def decorate(target: F) -> F:
|
|
14
|
+
if not inspect.isfunction(target):
|
|
15
|
+
raise TypeError("@postconstruct can only decorate instance methods")
|
|
16
|
+
parameters = tuple(inspect.signature(target).parameters.values())
|
|
17
|
+
if (
|
|
18
|
+
len(parameters) != 1 or parameters[0].name != "self"
|
|
19
|
+
or parameters[0].kind not in (
|
|
20
|
+
inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
21
|
+
)
|
|
22
|
+
):
|
|
23
|
+
raise TypeError("@postconstruct methods must accept only self")
|
|
24
|
+
if inspect.isgeneratorfunction(target) or inspect.isasyncgenfunction(target):
|
|
25
|
+
raise TypeError("@postconstruct does not support generator methods")
|
|
26
|
+
target.__pythonboot_postconstruct__ = True
|
|
27
|
+
return target
|
|
28
|
+
|
|
29
|
+
return decorate(method) if method is not None else decorate
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ondestroy(method: F | None = None) -> F | Callable[[F], F]:
|
|
33
|
+
"""Invoke an instance method once before a singleton bean is destroyed."""
|
|
34
|
+
def decorate(target: F) -> F:
|
|
35
|
+
if not inspect.isfunction(target):
|
|
36
|
+
raise TypeError("@ondestroy can only decorate instance methods")
|
|
37
|
+
parameters = tuple(inspect.signature(target).parameters.values())
|
|
38
|
+
if (
|
|
39
|
+
len(parameters) != 1 or parameters[0].name != "self"
|
|
40
|
+
or parameters[0].kind not in (
|
|
41
|
+
inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
42
|
+
)
|
|
43
|
+
):
|
|
44
|
+
raise TypeError("@ondestroy methods must accept only self")
|
|
45
|
+
if inspect.isgeneratorfunction(target) or inspect.isasyncgenfunction(target):
|
|
46
|
+
raise TypeError("@ondestroy does not support generator methods")
|
|
47
|
+
target.__pythonboot_ondestroy__ = True
|
|
48
|
+
return target
|
|
49
|
+
|
|
50
|
+
return decorate(method) if method is not None else decorate
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_postconstruct_methods(bean_type: type) -> tuple[str, ...]:
|
|
54
|
+
"""Discover callbacks from base to derived classes, respecting method resolution."""
|
|
55
|
+
methods: list[str] = []
|
|
56
|
+
for cls in reversed(bean_type.__mro__):
|
|
57
|
+
for name, descriptor in vars(cls).items():
|
|
58
|
+
method = descriptor.__func__ if isinstance(descriptor, (staticmethod, classmethod)) else descriptor
|
|
59
|
+
if not getattr(method, "__pythonboot_postconstruct__", False):
|
|
60
|
+
continue
|
|
61
|
+
if inspect.getattr_static(bean_type, name) is not descriptor:
|
|
62
|
+
continue
|
|
63
|
+
if isinstance(descriptor, (staticmethod, classmethod)):
|
|
64
|
+
raise BeanDefinitionError(f"@postconstruct does not support static or class methods: {bean_type.__name__}.{name}")
|
|
65
|
+
methods.append(name)
|
|
66
|
+
return tuple(methods)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_ondestroy_methods(bean_type: type) -> tuple[str, ...]:
|
|
70
|
+
"""Discover callbacks from derived to base classes, respecting method resolution."""
|
|
71
|
+
methods: list[str] = []
|
|
72
|
+
for cls in bean_type.__mro__:
|
|
73
|
+
for name, descriptor in vars(cls).items():
|
|
74
|
+
method = descriptor.__func__ if isinstance(descriptor, (staticmethod, classmethod)) else descriptor
|
|
75
|
+
if not getattr(method, "__pythonboot_ondestroy__", False):
|
|
76
|
+
continue
|
|
77
|
+
if inspect.getattr_static(bean_type, name) is not descriptor:
|
|
78
|
+
continue
|
|
79
|
+
if isinstance(descriptor, (staticmethod, classmethod)):
|
|
80
|
+
raise BeanDefinitionError(f"@ondestroy does not support static or class methods: {bean_type.__name__}.{name}")
|
|
81
|
+
methods.append(name)
|
|
82
|
+
return tuple(methods)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Recursive application package scanning with segmented wildcard matching."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from fnmatch import fnmatchcase
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Callable, get_type_hints
|
|
11
|
+
|
|
12
|
+
from pythonboot.beans.definitions import BeanDefinition, ComponentOptions
|
|
13
|
+
from pythonboot.exceptions import BeanDefinitionError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ScanResult:
|
|
18
|
+
definitions: tuple[BeanDefinition, ...]
|
|
19
|
+
modules: tuple[ModuleType, ...]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _matches(pattern: tuple[str, ...], name: tuple[str, ...]) -> bool:
|
|
23
|
+
if not pattern:
|
|
24
|
+
return not name
|
|
25
|
+
if pattern[0] == "**":
|
|
26
|
+
return _matches(pattern[1:], name) or (
|
|
27
|
+
bool(name) and _matches(pattern, name[1:])
|
|
28
|
+
)
|
|
29
|
+
return bool(name) and fnmatchcase(name[0], pattern[0]) and _matches(pattern[1:], name[1:])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _module_names(package: ModuleType) -> set[str]:
|
|
33
|
+
"""Discover modules in package paths, including namespace directories."""
|
|
34
|
+
names = {package.__name__}
|
|
35
|
+
for location in getattr(package, "__path__", ()):
|
|
36
|
+
root = Path(location)
|
|
37
|
+
for file in root.rglob("*.py"):
|
|
38
|
+
relative = file.relative_to(root)
|
|
39
|
+
parts = (*relative.parts[:-1], file.stem)
|
|
40
|
+
if any(not part.isidentifier() or part == "__pycache__" for part in parts):
|
|
41
|
+
continue
|
|
42
|
+
if parts[-1] == "__init__":
|
|
43
|
+
parts = parts[:-1]
|
|
44
|
+
for depth in range(1, len(parts) + 1):
|
|
45
|
+
names.add(".".join((package.__name__, *parts[:depth])))
|
|
46
|
+
return names
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan_components(
|
|
50
|
+
packages: tuple[str, ...], *, exclude_modules: tuple[str, ...] = (),
|
|
51
|
+
exclude: tuple[type, ...] = (),
|
|
52
|
+
) -> ScanResult:
|
|
53
|
+
modules: dict[str, ModuleType] = {}
|
|
54
|
+
candidates: set[str] = set()
|
|
55
|
+
for pattern in packages:
|
|
56
|
+
if not isinstance(pattern, str) or not pattern:
|
|
57
|
+
raise BeanDefinitionError("Scan package names must be non-empty strings")
|
|
58
|
+
parts = tuple(pattern.split("."))
|
|
59
|
+
wildcard_index = next((
|
|
60
|
+
index for index, part in enumerate(parts)
|
|
61
|
+
if any(character in part for character in "*?[")
|
|
62
|
+
), len(parts))
|
|
63
|
+
if not wildcard_index or any(not part.isidentifier() for part in parts[:wildcard_index]):
|
|
64
|
+
raise BeanDefinitionError(f"Scan patterns must start with a concrete package name: {pattern}")
|
|
65
|
+
if any(not part or ("**" in part and part != "**") for part in parts):
|
|
66
|
+
raise BeanDefinitionError(f"Invalid scan pattern: {pattern}")
|
|
67
|
+
package = importlib.import_module(".".join(parts[:wildcard_index]))
|
|
68
|
+
discovered = _module_names(package)
|
|
69
|
+
if wildcard_index == len(parts):
|
|
70
|
+
candidates.update(discovered)
|
|
71
|
+
else:
|
|
72
|
+
candidates.update(name for name in discovered if _matches(parts, tuple(name.split("."))))
|
|
73
|
+
|
|
74
|
+
for name in sorted(candidates, key=lambda item: (item.count("."), item)):
|
|
75
|
+
if name in exclude_modules:
|
|
76
|
+
# Reuse the initializing entry module to avoid importing it again under python -m.
|
|
77
|
+
module = sys.modules.get(name)
|
|
78
|
+
main = sys.modules.get("__main__")
|
|
79
|
+
if module is None and getattr(getattr(main, "__spec__", None), "name", None) == name:
|
|
80
|
+
module = main
|
|
81
|
+
if module is not None:
|
|
82
|
+
modules[module.__name__] = module
|
|
83
|
+
continue
|
|
84
|
+
module = importlib.import_module(name)
|
|
85
|
+
modules[module.__name__] = module
|
|
86
|
+
|
|
87
|
+
targets = tuple(
|
|
88
|
+
value for module in modules.values() for value in vars(module).values()
|
|
89
|
+
if getattr(value, "__module__", None) == module.__name__
|
|
90
|
+
and not getattr(value, "__pythonboot_auto_configuration__", None)
|
|
91
|
+
)
|
|
92
|
+
return ScanResult(collect_definitions(targets, exclude=exclude), tuple(modules.values()))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect_definitions(
|
|
96
|
+
targets: tuple[object, ...], *, include_factory: Callable[[object], bool] | None = None,
|
|
97
|
+
exclude: tuple[type, ...] = (),
|
|
98
|
+
) -> tuple[BeanDefinition, ...]:
|
|
99
|
+
"""Extract declarations from explicit targets without scanning their packages."""
|
|
100
|
+
if isinstance(exclude, (str, type)) or any(not isinstance(target, type) for target in exclude):
|
|
101
|
+
raise TypeError("exclude must be a sequence of classes")
|
|
102
|
+
excluded_classes = set(exclude)
|
|
103
|
+
definitions: list[BeanDefinition] = []
|
|
104
|
+
seen: set[int] = set()
|
|
105
|
+
|
|
106
|
+
def add_factory(factory: object, owner: type | None = None) -> None:
|
|
107
|
+
options = getattr(factory, "__pythonboot_bean__", None)
|
|
108
|
+
if options is None:
|
|
109
|
+
return
|
|
110
|
+
if include_factory is not None and not include_factory(factory):
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
bean_type = get_type_hints(factory).get("return")
|
|
114
|
+
except (NameError, TypeError) as error:
|
|
115
|
+
raise BeanDefinitionError(f"Cannot resolve type hints for bean factory {factory}") from error
|
|
116
|
+
if not isinstance(bean_type, type) or bean_type is type(None):
|
|
117
|
+
raise BeanDefinitionError(f"Bean factory {factory} must declare a concrete return type")
|
|
118
|
+
if bean_type in excluded_classes:
|
|
119
|
+
return
|
|
120
|
+
if inspect.iscoroutinefunction(factory) or inspect.isasyncgenfunction(factory):
|
|
121
|
+
raise BeanDefinitionError("Bean factories must create objects synchronously; use init_method for async initialization")
|
|
122
|
+
definitions.append(BeanDefinition(
|
|
123
|
+
name=options.name or factory.__name__,
|
|
124
|
+
bean_type=bean_type,
|
|
125
|
+
factory=factory,
|
|
126
|
+
owner=owner,
|
|
127
|
+
init_method=options.init_method,
|
|
128
|
+
destroy_method=options.destroy_method,
|
|
129
|
+
lazy=options.lazy,
|
|
130
|
+
scope=options.scope,
|
|
131
|
+
primary=options.primary,
|
|
132
|
+
qualifiers=options.qualifiers,
|
|
133
|
+
depends_on=options.depends_on,
|
|
134
|
+
))
|
|
135
|
+
|
|
136
|
+
for value in targets:
|
|
137
|
+
if id(value) in seen:
|
|
138
|
+
continue
|
|
139
|
+
seen.add(id(value))
|
|
140
|
+
if inspect.isclass(value):
|
|
141
|
+
if value in excluded_classes:
|
|
142
|
+
continue
|
|
143
|
+
options: ComponentOptions | None = vars(value).get("__pythonboot_component__")
|
|
144
|
+
if options is None:
|
|
145
|
+
continue
|
|
146
|
+
definitions.append(BeanDefinition(
|
|
147
|
+
name=options.name or value.__name__[:1].lower() + value.__name__[1:],
|
|
148
|
+
bean_type=value,
|
|
149
|
+
factory=value,
|
|
150
|
+
init_method=options.init_method,
|
|
151
|
+
destroy_method=options.destroy_method,
|
|
152
|
+
lazy=options.lazy,
|
|
153
|
+
scope=options.scope,
|
|
154
|
+
primary=options.primary,
|
|
155
|
+
qualifiers=options.qualifiers,
|
|
156
|
+
depends_on=options.depends_on,
|
|
157
|
+
))
|
|
158
|
+
for method in vars(value).values():
|
|
159
|
+
if inspect.isfunction(method):
|
|
160
|
+
add_factory(method, owner=value)
|
|
161
|
+
elif inspect.isfunction(value):
|
|
162
|
+
add_factory(value)
|
|
163
|
+
|
|
164
|
+
return tuple(definitions)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Discover installed starter entry points and assemble conditional bean definitions."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
import inspect
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from pythonboot.beans.conditions import matches_conditions
|
|
11
|
+
from pythonboot.beans.definitions import BeanDefinition, component
|
|
12
|
+
from pythonboot.beans.scanner import collect_definitions
|
|
13
|
+
from pythonboot.exceptions import BeanDefinitionError
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T", bound=type)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AutoConfigurationOptions:
|
|
20
|
+
order: int = 0
|
|
21
|
+
imports: tuple[type, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def auto_configuration(
|
|
25
|
+
cls: T | None = None, *, order: int = 0, imports: tuple[type, ...] = (),
|
|
26
|
+
) -> T | Callable[[T], T]:
|
|
27
|
+
"""Declare a starter configuration and its explicitly imported companion beans."""
|
|
28
|
+
if not isinstance(order, int):
|
|
29
|
+
raise TypeError("Auto-configuration order must be an integer")
|
|
30
|
+
if any(not inspect.isclass(target) for target in imports):
|
|
31
|
+
raise TypeError("Auto-configuration imports must contain classes")
|
|
32
|
+
|
|
33
|
+
def decorate(target: T) -> T:
|
|
34
|
+
if not inspect.isclass(target):
|
|
35
|
+
raise TypeError("@auto_configuration can only decorate classes")
|
|
36
|
+
if "__pythonboot_component__" not in vars(target):
|
|
37
|
+
component(target)
|
|
38
|
+
target.__pythonboot_auto_configuration__ = AutoConfigurationOptions(order, tuple(imports))
|
|
39
|
+
return target
|
|
40
|
+
|
|
41
|
+
return decorate(cls) if cls is not None else decorate
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def assemble_auto_configurations(
|
|
45
|
+
definitions: tuple[BeanDefinition, ...], modules: tuple[ModuleType, ...],
|
|
46
|
+
configuration: Mapping[str, Any], *, exclude: tuple[str, ...] = (),
|
|
47
|
+
exclude_classes: tuple[type, ...] = (),
|
|
48
|
+
) -> tuple[BeanDefinition, ...]:
|
|
49
|
+
"""Apply user definitions first, then ordered, explicitly advertised configurations."""
|
|
50
|
+
if isinstance(exclude, str) or any(not isinstance(name, str) or not name for name in exclude):
|
|
51
|
+
raise ValueError("Auto-configuration exclusions must be a sequence of non-empty names")
|
|
52
|
+
candidates: dict[str, type] = {}
|
|
53
|
+
excluded_classes = set(exclude_classes)
|
|
54
|
+
excluded_names = {f"{target.__module__}.{target.__qualname__}" for target in excluded_classes}
|
|
55
|
+
|
|
56
|
+
def add(target: object) -> None:
|
|
57
|
+
if isinstance(target, ModuleType):
|
|
58
|
+
for value in vars(target).values():
|
|
59
|
+
if inspect.isclass(value) and "__pythonboot_auto_configuration__" in vars(value):
|
|
60
|
+
add(value)
|
|
61
|
+
return
|
|
62
|
+
if not inspect.isclass(target) or "__pythonboot_auto_configuration__" not in vars(target):
|
|
63
|
+
raise BeanDefinitionError("Auto-configuration entry points must reference decorated classes or modules")
|
|
64
|
+
name = f"{target.__module__}.{target.__qualname__}"
|
|
65
|
+
if name not in exclude and target not in excluded_classes:
|
|
66
|
+
candidates[name] = target
|
|
67
|
+
|
|
68
|
+
from pythonboot.boot.task_configuration import TaskExecutionAutoConfiguration
|
|
69
|
+
|
|
70
|
+
add(TaskExecutionAutoConfiguration)
|
|
71
|
+
for module in modules:
|
|
72
|
+
add(module)
|
|
73
|
+
for entry in sorted(entry_points(group="pythonboot.autoconfigure"), key=lambda item: (item.name, item.value)):
|
|
74
|
+
qualified_name = entry.value.split("[", 1)[0].strip().replace(":", ".")
|
|
75
|
+
if entry.name in exclude or qualified_name in exclude or qualified_name in excluded_names:
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
add(entry.load())
|
|
79
|
+
except Exception as error:
|
|
80
|
+
raise BeanDefinitionError(f"Cannot load auto-configuration entry point {entry.name}: {entry.value}") from error
|
|
81
|
+
|
|
82
|
+
registered = list(definitions)
|
|
83
|
+
sources = {(id(item.factory), item.owner) for item in definitions}
|
|
84
|
+
for name, target in sorted(
|
|
85
|
+
candidates.items(), key=lambda item: (vars(item[1])["__pythonboot_auto_configuration__"].order, item[0]),
|
|
86
|
+
):
|
|
87
|
+
if not matches_conditions(target, tuple(registered), configuration):
|
|
88
|
+
continue
|
|
89
|
+
options: AutoConfigurationOptions = vars(target)["__pythonboot_auto_configuration__"]
|
|
90
|
+
companions = tuple(
|
|
91
|
+
imported for imported in options.imports
|
|
92
|
+
if imported not in excluded_classes
|
|
93
|
+
and matches_conditions(imported, tuple(registered), configuration)
|
|
94
|
+
)
|
|
95
|
+
def include_factory(factory: object) -> bool:
|
|
96
|
+
return all(
|
|
97
|
+
condition.matches(tuple(registered), configuration)
|
|
98
|
+
for condition in vars(factory).get("__pythonboot_conditions__", ())
|
|
99
|
+
if condition.kind in ("module", "property")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
pending = collect_definitions(
|
|
103
|
+
(*companions, target), include_factory=include_factory, exclude=exclude_classes,
|
|
104
|
+
)
|
|
105
|
+
# Unconditional definitions precede bean-dependent fallback factories.
|
|
106
|
+
pending = tuple(sorted(pending, key=lambda item: any(
|
|
107
|
+
condition.kind in ("bean", "missing_bean")
|
|
108
|
+
for condition in vars(item.factory).get("__pythonboot_conditions__", ())
|
|
109
|
+
)))
|
|
110
|
+
for definition in pending:
|
|
111
|
+
source = (id(definition.factory), definition.owner)
|
|
112
|
+
if source in sources:
|
|
113
|
+
continue
|
|
114
|
+
if definition.factory is not target and not matches_conditions(
|
|
115
|
+
definition.factory, tuple(registered), configuration,
|
|
116
|
+
):
|
|
117
|
+
continue
|
|
118
|
+
registered.append(definition)
|
|
119
|
+
sources.add(source)
|
|
120
|
+
return tuple(registered)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Conditional application task executor and YAML property binding."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
from pythonboot.beans import bean
|
|
7
|
+
from pythonboot.beans.conditions import conditional_on_missing_bean, conditional_on_property
|
|
8
|
+
from pythonboot.boot.autoconfiguration import auto_configuration
|
|
9
|
+
from pythonboot.config import configuration_properties
|
|
10
|
+
from pythonboot.task import RejectionPolicy, TaskExecutor, ThreadPoolTaskExecutor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@configuration_properties("pythonboot.task.execution")
|
|
14
|
+
class TaskExecutionProperties(BaseSettings):
|
|
15
|
+
"""Settings for the default executor; environment variables override YAML."""
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(env_prefix="PYTHONBOOT_TASK_EXECUTION_", extra="ignore")
|
|
18
|
+
|
|
19
|
+
core_pool_size: int = Field(default=8, ge=1)
|
|
20
|
+
max_pool_size: int = Field(default=32, ge=1)
|
|
21
|
+
queue_capacity: int | None = Field(default=256, ge=0)
|
|
22
|
+
keep_alive_seconds: float = Field(default=60, gt=0, allow_inf_nan=False)
|
|
23
|
+
thread_name_prefix: str = Field(default="pythonboot-task-", min_length=1)
|
|
24
|
+
allow_core_thread_timeout: bool = False
|
|
25
|
+
rejection_policy: RejectionPolicy = RejectionPolicy.ABORT
|
|
26
|
+
wait_for_tasks_to_complete_on_shutdown: bool = True
|
|
27
|
+
await_termination_seconds: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@auto_configuration(order=1000, imports=(TaskExecutionProperties,))
|
|
31
|
+
@conditional_on_property("pythonboot.task.execution.enabled", having_value=True, match_if_missing=True)
|
|
32
|
+
class TaskExecutionAutoConfiguration:
|
|
33
|
+
"""Provide a default executor unless an application supplies its own."""
|
|
34
|
+
|
|
35
|
+
@bean(name="applicationTaskExecutor", destroy_method="close")
|
|
36
|
+
@conditional_on_missing_bean(TaskExecutor, "applicationTaskExecutor")
|
|
37
|
+
def application_task_executor(self, properties: TaskExecutionProperties) -> ThreadPoolTaskExecutor:
|
|
38
|
+
return ThreadPoolTaskExecutor(**properties.model_dump())
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Configuration loading and property binding interfaces."""
|
|
2
|
+
|
|
3
|
+
from pythonboot.config.logging import configure_logging
|
|
4
|
+
from pythonboot.config.properties import ConfigurationPropertiesOptions, configuration_properties, load_configuration
|
|
5
|
+
|
|
6
|
+
__all__ = ["ConfigurationPropertiesOptions", "configuration_properties", "configure_logging", "load_configuration"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Application logging configuration."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from copy import copy
|
|
5
|
+
import logging
|
|
6
|
+
import logging.config
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pythonboot.exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
DEFAULT_LOG_FORMAT = (
|
|
13
|
+
"%(asctime)s.%(msecs)03d %(levelname)-5s %(process)5d "
|
|
14
|
+
"[Thread-%(thread)d] %(name)s : %(message)s"
|
|
15
|
+
)
|
|
16
|
+
DEFAULT_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
17
|
+
_LEVEL_COLORS = {
|
|
18
|
+
logging.DEBUG: "\033[36m",
|
|
19
|
+
logging.INFO: "\033[32m",
|
|
20
|
+
logging.WARNING: "\033[33m",
|
|
21
|
+
logging.ERROR: "\033[31m",
|
|
22
|
+
logging.CRITICAL: "\033[31m",
|
|
23
|
+
}
|
|
24
|
+
_RESET_COLOR = "\033[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConsoleFormatter(logging.Formatter):
|
|
28
|
+
"""Render log levels with terminal colors when supported."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *args: Any, colors: bool, **kwargs: Any) -> None:
|
|
31
|
+
super().__init__(*args, **kwargs)
|
|
32
|
+
self._colors = colors
|
|
33
|
+
|
|
34
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
35
|
+
if not self._colors:
|
|
36
|
+
return super().format(record)
|
|
37
|
+
colored_record = copy(record)
|
|
38
|
+
color = _LEVEL_COLORS.get(record.levelno)
|
|
39
|
+
if color is not None:
|
|
40
|
+
colored_record.levelname = f"{color}{record.levelname:<5}{_RESET_COLOR}"
|
|
41
|
+
return super().format(colored_record)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _log_level(value: Any) -> int:
|
|
45
|
+
if not isinstance(value, str) or value.upper() not in logging.getLevelNamesMapping():
|
|
46
|
+
raise ConfigurationError("logging.level must be a valid logging level name")
|
|
47
|
+
return logging.getLevelNamesMapping()[value.upper()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_formatter(formatter: logging.Formatter) -> None:
|
|
51
|
+
for logger_name in ("", "uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
52
|
+
for handler in logging.getLogger(logger_name).handlers:
|
|
53
|
+
handler.setFormatter(formatter)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def configure_logging(configuration: Mapping[str, Any]) -> None:
|
|
57
|
+
"""Configure logging from the application logging section."""
|
|
58
|
+
pythonboot = configuration.get("pythonboot", {})
|
|
59
|
+
if not isinstance(pythonboot, Mapping):
|
|
60
|
+
raise ConfigurationError("pythonboot configuration must be a mapping")
|
|
61
|
+
options = pythonboot.get("logging", {})
|
|
62
|
+
if not isinstance(options, Mapping):
|
|
63
|
+
raise ConfigurationError("pythonboot.logging configuration must be a mapping")
|
|
64
|
+
if "version" in options:
|
|
65
|
+
try:
|
|
66
|
+
logging.config.dictConfig(dict(options))
|
|
67
|
+
except (TypeError, ValueError) as error:
|
|
68
|
+
raise ConfigurationError("Invalid logging dictConfig configuration") from error
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
allowed = {"level", "format", "date_format", "colors", "loggers"}
|
|
72
|
+
unexpected = set(options).difference(allowed)
|
|
73
|
+
if unexpected:
|
|
74
|
+
raise ConfigurationError("Unsupported logging options: " + ", ".join(sorted(unexpected)))
|
|
75
|
+
level = _log_level(options.get("level", "INFO"))
|
|
76
|
+
log_format = options.get("format", DEFAULT_LOG_FORMAT)
|
|
77
|
+
date_format = options.get("date_format", DEFAULT_DATE_FORMAT)
|
|
78
|
+
colors = options.get("colors", sys.stdout.isatty())
|
|
79
|
+
if not isinstance(log_format, str) or not isinstance(date_format, str) or not isinstance(colors, bool):
|
|
80
|
+
raise ConfigurationError("logging.format and logging.date_format must be strings; logging.colors must be a boolean")
|
|
81
|
+
logging.basicConfig(level=level, format=log_format, datefmt=date_format, stream=sys.stdout)
|
|
82
|
+
logging.getLogger().setLevel(level)
|
|
83
|
+
_apply_formatter(ConsoleFormatter(log_format, datefmt=date_format, colors=colors))
|
|
84
|
+
logger_levels = options.get("loggers", {})
|
|
85
|
+
if not isinstance(logger_levels, Mapping):
|
|
86
|
+
raise ConfigurationError("logging.loggers must be a mapping of logger names to levels")
|
|
87
|
+
for name, logger_level in logger_levels.items():
|
|
88
|
+
if not isinstance(name, str) or not name:
|
|
89
|
+
raise ConfigurationError("logging.loggers keys must be non-empty strings")
|
|
90
|
+
logging.getLogger(name).setLevel(_log_level(logger_level))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""YAML configuration loading and Pydantic settings property binding."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import inspect
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, TypeVar
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic_settings import BaseSettings, InitSettingsSource
|
|
13
|
+
|
|
14
|
+
from pythonboot.beans import component
|
|
15
|
+
from pythonboot.exceptions import ConfigurationError
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T", bound=type)
|
|
18
|
+
_configuration_values: ContextVar[Mapping[str, Any]] = ContextVar(
|
|
19
|
+
"pythonboot_configuration_values", default={},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ConfigurationPropertiesOptions:
|
|
25
|
+
"""Configuration metadata attached to a settings bean type."""
|
|
26
|
+
|
|
27
|
+
prefix: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _merge(target: dict[str, Any], source: Mapping[str, Any]) -> dict[str, Any]:
|
|
31
|
+
for key, value in source.items():
|
|
32
|
+
existing = target.get(key)
|
|
33
|
+
if isinstance(existing, dict) and isinstance(value, Mapping):
|
|
34
|
+
_merge(existing, value)
|
|
35
|
+
else:
|
|
36
|
+
target[key] = value
|
|
37
|
+
return target
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_yaml_file(file: Path) -> Mapping[str, Any]:
|
|
41
|
+
try:
|
|
42
|
+
with file.open(encoding="utf-8") as stream:
|
|
43
|
+
document = yaml.safe_load(stream)
|
|
44
|
+
except yaml.YAMLError as error:
|
|
45
|
+
raise ConfigurationError(f"Invalid YAML configuration file: {file}") from error
|
|
46
|
+
if document is None:
|
|
47
|
+
return {}
|
|
48
|
+
if not isinstance(document, Mapping):
|
|
49
|
+
raise ConfigurationError(f"YAML configuration root must be a mapping: {file}")
|
|
50
|
+
return document
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _active_profiles(values: Mapping[str, Any]) -> tuple[str, ...]:
|
|
54
|
+
active = values.get("active")
|
|
55
|
+
if active is None:
|
|
56
|
+
return ()
|
|
57
|
+
if not isinstance(active, Mapping):
|
|
58
|
+
raise ConfigurationError("Configuration property active must be a mapping")
|
|
59
|
+
profile = active.get("profile")
|
|
60
|
+
if profile is None:
|
|
61
|
+
return ()
|
|
62
|
+
if isinstance(profile, str):
|
|
63
|
+
profiles = (profile,)
|
|
64
|
+
elif isinstance(profile, Sequence) and not isinstance(profile, (str, bytes)):
|
|
65
|
+
profiles = tuple(profile)
|
|
66
|
+
else:
|
|
67
|
+
raise ConfigurationError("Configuration property active.profile must be a string or sequence of strings")
|
|
68
|
+
if any(
|
|
69
|
+
not isinstance(item, str) or not item or "/" in item or "\\" in item or item in (".", "..")
|
|
70
|
+
for item in profiles
|
|
71
|
+
):
|
|
72
|
+
raise ConfigurationError("Configuration property active.profile contains an invalid profile name")
|
|
73
|
+
return profiles
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_configuration(config_dir: str | Path) -> dict[str, Any]:
|
|
77
|
+
"""Load application.yml and the active application-{profile}.yml files."""
|
|
78
|
+
directory = Path(config_dir)
|
|
79
|
+
if not directory.is_dir():
|
|
80
|
+
raise ConfigurationError(f"Configuration directory does not exist or is not a directory: {directory}")
|
|
81
|
+
values: dict[str, Any] = {}
|
|
82
|
+
base_file = directory / "application.yml"
|
|
83
|
+
if base_file.is_file():
|
|
84
|
+
_merge(values, _load_yaml_file(base_file))
|
|
85
|
+
for profile in _active_profiles(values):
|
|
86
|
+
profile_file = directory / f"application-{profile}.yml"
|
|
87
|
+
if profile_file.is_file():
|
|
88
|
+
_merge(values, _load_yaml_file(profile_file))
|
|
89
|
+
return values
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _values_for_prefix(prefix: str) -> dict[str, Any]:
|
|
93
|
+
values: Mapping[str, Any] = _configuration_values.get()
|
|
94
|
+
for part in prefix.split("."):
|
|
95
|
+
value = values.get(part)
|
|
96
|
+
if not isinstance(value, Mapping):
|
|
97
|
+
return {}
|
|
98
|
+
values = value
|
|
99
|
+
return dict(values)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def use_configuration_values(values: Mapping[str, Any]) -> Iterator[None]:
|
|
104
|
+
"""Bind application configuration while a bean factory executes."""
|
|
105
|
+
token = _configuration_values.set(values)
|
|
106
|
+
try:
|
|
107
|
+
yield
|
|
108
|
+
finally:
|
|
109
|
+
_configuration_values.reset(token)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def configuration_properties(prefix: str) -> Callable[[T], T]:
|
|
113
|
+
"""Register a Pydantic settings class bound to a YAML configuration prefix."""
|
|
114
|
+
normalized_prefix = prefix.strip(".") if isinstance(prefix, str) else ""
|
|
115
|
+
if not normalized_prefix or any(not part for part in normalized_prefix.split(".")):
|
|
116
|
+
raise ValueError("configuration_properties prefix must be a non-empty dotted path")
|
|
117
|
+
|
|
118
|
+
def decorate(target: T) -> T:
|
|
119
|
+
if not inspect.isclass(target) or not issubclass(target, BaseSettings):
|
|
120
|
+
raise TypeError("@configuration_properties can only decorate BaseSettings subclasses")
|
|
121
|
+
if "__pythonboot_configuration_properties__" in vars(target):
|
|
122
|
+
raise ConfigurationError(f"Configuration properties already declared for {target.__name__}")
|
|
123
|
+
if "__pythonboot_component__" not in vars(target):
|
|
124
|
+
component(target)
|
|
125
|
+
target.__pythonboot_configuration_properties__ = ConfigurationPropertiesOptions(normalized_prefix)
|
|
126
|
+
original_sources = target.settings_customise_sources
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def settings_customise_sources(
|
|
130
|
+
cls: type[BaseSettings], settings_cls: type[BaseSettings], init_settings: Any, env_settings: Any,
|
|
131
|
+
dotenv_settings: Any, file_secret_settings: Any,
|
|
132
|
+
) -> tuple[Any, ...]:
|
|
133
|
+
sources = original_sources(
|
|
134
|
+
settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings,
|
|
135
|
+
)
|
|
136
|
+
yaml_settings = InitSettingsSource(
|
|
137
|
+
settings_cls, init_kwargs=_values_for_prefix(normalized_prefix),
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
index = sources.index(env_settings) + 1
|
|
141
|
+
except ValueError:
|
|
142
|
+
index = len(sources)
|
|
143
|
+
return (*sources[:index], yaml_settings, *sources[index:])
|
|
144
|
+
|
|
145
|
+
target.settings_customise_sources = settings_customise_sources
|
|
146
|
+
return target
|
|
147
|
+
|
|
148
|
+
return decorate
|