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.
@@ -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,5 @@
1
+ """Application startup and assembly."""
2
+
3
+ from pythonboot.boot.autoconfiguration import auto_configuration
4
+
5
+ __all__ = ["auto_configuration"]
@@ -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
@@ -0,0 +1,6 @@
1
+ """IoC container, dependency resolution, scopes, and lifecycle."""
2
+
3
+ from pythonboot.context.container import ApplicationContext
4
+ from pythonboot.context.injection import autowired
5
+
6
+ __all__ = ["ApplicationContext", "autowired"]