microbootstrap 0.0.1__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.
- microbootstrap/__init__.py +15 -0
- microbootstrap/bootstrappers/__init__.py +0 -0
- microbootstrap/bootstrappers/base.py +102 -0
- microbootstrap/bootstrappers/litestar.py +86 -0
- microbootstrap/console_writer.py +34 -0
- microbootstrap/exceptions.py +10 -0
- microbootstrap/granian_server.py +40 -0
- microbootstrap/helpers.py +96 -0
- microbootstrap/instruments/__init__.py +7 -0
- microbootstrap/instruments/base.py +60 -0
- microbootstrap/instruments/instrument_box.py +51 -0
- microbootstrap/instruments/logging_instrument.py +172 -0
- microbootstrap/instruments/opentelemetry_instrument.py +91 -0
- microbootstrap/instruments/prometheus_instrument.py +45 -0
- microbootstrap/instruments/sentry_instrument.py +58 -0
- microbootstrap/middlewares/__init__.py +0 -0
- microbootstrap/middlewares/fastapi.py +38 -0
- microbootstrap/middlewares/litestar.py +41 -0
- microbootstrap/py.typed +0 -0
- microbootstrap/settings.py +43 -0
- microbootstrap-0.0.1.dist-info/METADATA +561 -0
- microbootstrap-0.0.1.dist-info/RECORD +23 -0
- microbootstrap-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from microbootstrap.instruments.logging_instrument import LoggingConfig
|
|
2
|
+
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
|
|
3
|
+
from microbootstrap.instruments.prometheus_instrument import PrometheusConfig
|
|
4
|
+
from microbootstrap.instruments.sentry_instrument import SentryConfig
|
|
5
|
+
from microbootstrap.settings import LitestarSettings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"SentryConfig",
|
|
10
|
+
"OpentelemetryConfig",
|
|
11
|
+
"PrometheusConfig",
|
|
12
|
+
"LoggingConfig",
|
|
13
|
+
"LitestarBootstrapper",
|
|
14
|
+
"LitestarSettings",
|
|
15
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import abc
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import typing_extensions
|
|
6
|
+
|
|
7
|
+
from microbootstrap.console_writer import ConsoleWriter
|
|
8
|
+
from microbootstrap.helpers import dataclass_to_dict_no_defaults, merge_dataclasses_configs, merge_dict_configs
|
|
9
|
+
from microbootstrap.instruments.instrument_box import InstrumentBox
|
|
10
|
+
from microbootstrap.settings import SettingsT
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if typing.TYPE_CHECKING:
|
|
14
|
+
from _typeshed import DataclassInstance
|
|
15
|
+
|
|
16
|
+
from microbootstrap.instruments.base import Instrument, InstrumentConfigT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ApplicationT = typing.TypeVar("ApplicationT")
|
|
20
|
+
DataclassT = typing.TypeVar("DataclassT", bound="DataclassInstance")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApplicationBootstrapper(abc.ABC, typing.Generic[SettingsT, ApplicationT, DataclassT]):
|
|
24
|
+
application_type: type[ApplicationT]
|
|
25
|
+
application_config: DataclassT
|
|
26
|
+
console_writer: ConsoleWriter
|
|
27
|
+
__instrument_box: InstrumentBox
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: SettingsT) -> None:
|
|
30
|
+
self.settings = settings
|
|
31
|
+
self.console_writer = ConsoleWriter(writer_enabled=settings.service_debug)
|
|
32
|
+
|
|
33
|
+
if not hasattr(self, "__instrument_box"):
|
|
34
|
+
self.__instrument_box = InstrumentBox()
|
|
35
|
+
self.__instrument_box.initialize(self.settings)
|
|
36
|
+
|
|
37
|
+
def configure_application(
|
|
38
|
+
self: typing_extensions.Self,
|
|
39
|
+
application_config: DataclassT,
|
|
40
|
+
) -> typing_extensions.Self:
|
|
41
|
+
self.application_config = merge_dataclasses_configs(self.application_config, application_config)
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def configure_instrument(
|
|
45
|
+
self: typing_extensions.Self,
|
|
46
|
+
instrument_config: InstrumentConfigT,
|
|
47
|
+
) -> typing_extensions.Self:
|
|
48
|
+
self.__instrument_box.configure_instrument(instrument_config)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def configure_instruments(
|
|
52
|
+
self: typing_extensions.Self,
|
|
53
|
+
*instrument_configs: InstrumentConfigT,
|
|
54
|
+
) -> typing_extensions.Self:
|
|
55
|
+
for instrument_config in instrument_configs:
|
|
56
|
+
self.configure_instrument(instrument_config)
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def use_instrument(
|
|
61
|
+
cls,
|
|
62
|
+
) -> typing.Callable[
|
|
63
|
+
[type[Instrument[InstrumentConfigT]]],
|
|
64
|
+
type[Instrument[InstrumentConfigT]],
|
|
65
|
+
]:
|
|
66
|
+
if not hasattr(cls, "__instrument_box"):
|
|
67
|
+
cls.__instrument_box = InstrumentBox()
|
|
68
|
+
return cls.__instrument_box.extend_instruments
|
|
69
|
+
|
|
70
|
+
def bootstrap(self: typing_extensions.Self) -> ApplicationT:
|
|
71
|
+
resulting_application_config = dataclass_to_dict_no_defaults(self.application_config)
|
|
72
|
+
for instrument in self.__instrument_box.instruments:
|
|
73
|
+
if instrument.is_ready():
|
|
74
|
+
instrument.bootstrap()
|
|
75
|
+
|
|
76
|
+
resulting_application_config = merge_dict_configs(
|
|
77
|
+
resulting_application_config,
|
|
78
|
+
instrument.bootstrap_before(),
|
|
79
|
+
)
|
|
80
|
+
instrument.write_status(self.console_writer)
|
|
81
|
+
|
|
82
|
+
application = self.application_type(
|
|
83
|
+
**merge_dict_configs(resulting_application_config, self.bootstrap_before()),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
for instrument in self.__instrument_box.instruments:
|
|
87
|
+
if instrument.is_ready():
|
|
88
|
+
application = instrument.bootstrap_after(application)
|
|
89
|
+
|
|
90
|
+
return self.bootstrap_after(application)
|
|
91
|
+
|
|
92
|
+
def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:
|
|
93
|
+
"""Add some framework-related parameters to final bootstrap result before application creation."""
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
def bootstrap_after(self: typing_extensions.Self, application: ApplicationT) -> ApplicationT:
|
|
97
|
+
"""Add some framework-related parameters to final bootstrap result after application creation."""
|
|
98
|
+
return application
|
|
99
|
+
|
|
100
|
+
def teardown(self: typing_extensions.Self) -> None:
|
|
101
|
+
for instrument in self.__instrument_box.instruments:
|
|
102
|
+
instrument.teardown()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import litestar
|
|
5
|
+
import litestar.types
|
|
6
|
+
import sentry_sdk
|
|
7
|
+
import typing_extensions
|
|
8
|
+
from litestar import status_codes
|
|
9
|
+
from litestar.config.app import AppConfig as LitestarConfig
|
|
10
|
+
from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig
|
|
11
|
+
from litestar.contrib.prometheus import PrometheusConfig as LitestarPrometheusConfig
|
|
12
|
+
from litestar.contrib.prometheus import PrometheusController
|
|
13
|
+
from litestar.exceptions.http_exceptions import HTTPException
|
|
14
|
+
|
|
15
|
+
from microbootstrap.bootstrappers.base import ApplicationBootstrapper
|
|
16
|
+
from microbootstrap.instruments.logging_instrument import LoggingInstrument
|
|
17
|
+
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
|
|
18
|
+
from microbootstrap.instruments.prometheus_instrument import PrometheusInstrument
|
|
19
|
+
from microbootstrap.instruments.sentry_instrument import SentryInstrument
|
|
20
|
+
from microbootstrap.middlewares.litestar import build_litestar_logging_middleware
|
|
21
|
+
from microbootstrap.settings import LitestarSettings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LitestarBootstrapper(
|
|
25
|
+
ApplicationBootstrapper[LitestarSettings, litestar.Litestar, LitestarConfig],
|
|
26
|
+
):
|
|
27
|
+
application_config = LitestarConfig()
|
|
28
|
+
application_type = litestar.Litestar
|
|
29
|
+
|
|
30
|
+
def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:
|
|
31
|
+
return {
|
|
32
|
+
"debug": self.settings.service_debug,
|
|
33
|
+
"on_shutdown": [self.teardown],
|
|
34
|
+
"on_startup": [self.console_writer.print_bootstrap_table],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@LitestarBootstrapper.use_instrument()
|
|
39
|
+
class LitestarSentryInstrument(SentryInstrument):
|
|
40
|
+
@staticmethod
|
|
41
|
+
async def sentry_exception_catcher_hook(
|
|
42
|
+
exception: Exception,
|
|
43
|
+
_request_scope: litestar.types.Scope,
|
|
44
|
+
) -> None:
|
|
45
|
+
if (
|
|
46
|
+
not isinstance(exception, HTTPException)
|
|
47
|
+
or exception.status_code >= status_codes.HTTP_500_INTERNAL_SERVER_ERROR
|
|
48
|
+
):
|
|
49
|
+
sentry_sdk.capture_exception(exception)
|
|
50
|
+
|
|
51
|
+
def bootstrap_before(self) -> dict[str, typing.Any]:
|
|
52
|
+
return {"after_exception": [self.sentry_exception_catcher_hook]}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@LitestarBootstrapper.use_instrument()
|
|
56
|
+
class LitetstarOpentelemetryInstrument(OpentelemetryInstrument):
|
|
57
|
+
def bootstrap_before(self) -> dict[str, typing.Any]:
|
|
58
|
+
return {
|
|
59
|
+
"middleware": [
|
|
60
|
+
LitestarOpentelemetryConfig(
|
|
61
|
+
tracer_provider=self.tracer_provider,
|
|
62
|
+
exclude=self.instrument_config.opentelemetry_exclude_urls,
|
|
63
|
+
).middleware,
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@LitestarBootstrapper.use_instrument()
|
|
69
|
+
class LitestarLoggingInstrument(LoggingInstrument):
|
|
70
|
+
def bootstrap_before(self) -> dict[str, typing.Any]:
|
|
71
|
+
return {"middleware": [build_litestar_logging_middleware(self.instrument_config.logging_exclude_endpoints)]}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@LitestarBootstrapper.use_instrument()
|
|
75
|
+
class LitestarPrometheusInstrument(PrometheusInstrument):
|
|
76
|
+
def bootstrap_before(self) -> dict[str, typing.Any]:
|
|
77
|
+
class LitestarPrometheusController(PrometheusController):
|
|
78
|
+
path = self.instrument_config.prometheus_metrics_path
|
|
79
|
+
openmetrics_format = True
|
|
80
|
+
|
|
81
|
+
litestar_prometheus_config: typing.Final = LitestarPrometheusConfig(
|
|
82
|
+
app_name=self.instrument_config.service_name,
|
|
83
|
+
**self.instrument_config.prometheus_additional_params,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return {"route_handlers": [LitestarPrometheusController], "middleware": [litestar_prometheus_config.middleware]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import dataclasses
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.rule import Rule
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass
|
|
11
|
+
class ConsoleWriter:
|
|
12
|
+
writer_enabled: bool = True
|
|
13
|
+
rich_console: Console = dataclasses.field(init=False, default_factory=Console)
|
|
14
|
+
rich_table: Table = dataclasses.field(init=False)
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
self.rich_table = Table(show_header=False, header_style="cyan")
|
|
18
|
+
self.rich_table.add_column("Item", style="cyan")
|
|
19
|
+
self.rich_table.add_column("Status")
|
|
20
|
+
self.rich_table.add_column("Reason", style="yellow")
|
|
21
|
+
|
|
22
|
+
def write_instrument_status(
|
|
23
|
+
self,
|
|
24
|
+
instrument_name: str,
|
|
25
|
+
is_enabled: bool,
|
|
26
|
+
disable_reason: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
is_enabled_value: typing.Final = "[green]Enabled[/green]" if is_enabled else "[red]Disabled[/red]"
|
|
29
|
+
self.rich_table.add_row(rf"{instrument_name}", is_enabled_value, disable_reason or "")
|
|
30
|
+
|
|
31
|
+
def print_bootstrap_table(self) -> None:
|
|
32
|
+
if self.writer_enabled:
|
|
33
|
+
self.rich_console.print(Rule("[yellow]Bootstrapping application[/yellow]", align="left"))
|
|
34
|
+
self.rich_console.print(self.rich_table)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class MicroBootstrapBaseError(Exception):
|
|
2
|
+
"""Base for all exceptions."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigMergeError(MicroBootstrapBaseError):
|
|
6
|
+
"""Raises when it's impossible to merge configs due to type mismatch."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MissingInstrumentError(MicroBootstrapBaseError):
|
|
10
|
+
"""Raises when attempting to configure instrument, that is not supported yet."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import granian
|
|
6
|
+
from granian.constants import Interfaces, Loops
|
|
7
|
+
from granian.log import LogLevels
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if typing.TYPE_CHECKING:
|
|
11
|
+
from microbootstrap.settings import BaseBootstrapSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
GRANIAN_LOG_LEVELS_MAP = {
|
|
15
|
+
logging.CRITICAL: LogLevels.critical,
|
|
16
|
+
logging.ERROR: LogLevels.error,
|
|
17
|
+
logging.WARNING: LogLevels.warning,
|
|
18
|
+
logging.WARNING: LogLevels.warn,
|
|
19
|
+
logging.INFO: LogLevels.info,
|
|
20
|
+
logging.DEBUG: LogLevels.debug,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# TODO: create bootstrappers for application servers. granian/uvicorn # noqa: TD002
|
|
25
|
+
def create_granian_server(
|
|
26
|
+
target: str,
|
|
27
|
+
settings: BaseBootstrapSettings,
|
|
28
|
+
**granian_options: typing.Any, # noqa: ANN401
|
|
29
|
+
) -> granian.Granian:
|
|
30
|
+
return granian.Granian(
|
|
31
|
+
target=target,
|
|
32
|
+
address=settings.server_host,
|
|
33
|
+
port=settings.server_port,
|
|
34
|
+
interface=Interfaces.ASGI,
|
|
35
|
+
loop=Loops.uvloop,
|
|
36
|
+
workers=settings.server_workers_count,
|
|
37
|
+
log_level=GRANIAN_LOG_LEVELS_MAP[settings.logging_log_level],
|
|
38
|
+
reload=settings.server_reload,
|
|
39
|
+
**granian_options,
|
|
40
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import re
|
|
3
|
+
import typing
|
|
4
|
+
from dataclasses import _MISSING_TYPE
|
|
5
|
+
|
|
6
|
+
from microbootstrap import exceptions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if typing.TYPE_CHECKING:
|
|
10
|
+
from dataclasses import _DataclassT
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PydanticConfigT = typing.TypeVar("PydanticConfigT", bound="BaseModel")
|
|
16
|
+
VALID_PATH_PATTERN: typing.Final = r"^(/[a-zA-Z0-9_-]+)+/?$"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dataclass_to_dict_no_defaults(dataclass_to_convert: "_DataclassT") -> dict[str, typing.Any]:
|
|
20
|
+
conversion_result: typing.Final = {}
|
|
21
|
+
for dataclass_field in dataclasses.fields(dataclass_to_convert):
|
|
22
|
+
value = getattr(dataclass_to_convert, dataclass_field.name)
|
|
23
|
+
if isinstance(dataclass_field.default, _MISSING_TYPE):
|
|
24
|
+
conversion_result[dataclass_field.name] = value
|
|
25
|
+
continue
|
|
26
|
+
if dataclass_field.default != value and isinstance(dataclass_field.default_factory, _MISSING_TYPE):
|
|
27
|
+
conversion_result[dataclass_field.name] = value
|
|
28
|
+
continue
|
|
29
|
+
if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[misc]
|
|
30
|
+
conversion_result[dataclass_field.name] = value
|
|
31
|
+
|
|
32
|
+
return conversion_result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def merge_pydantic_configs(
|
|
36
|
+
config_to_merge: PydanticConfigT,
|
|
37
|
+
config_with_changes: PydanticConfigT,
|
|
38
|
+
) -> PydanticConfigT:
|
|
39
|
+
config_class: typing.Final = config_to_merge.__class__
|
|
40
|
+
resulting_dict_config: typing.Final = merge_dict_configs(
|
|
41
|
+
config_to_merge.model_dump(exclude_defaults=True, exclude_unset=True),
|
|
42
|
+
config_with_changes.model_dump(exclude_defaults=True, exclude_unset=True),
|
|
43
|
+
)
|
|
44
|
+
return config_class(**resulting_dict_config)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge_dataclasses_configs(
|
|
48
|
+
config_to_merge: "_DataclassT",
|
|
49
|
+
config_with_changes: "_DataclassT",
|
|
50
|
+
) -> "_DataclassT":
|
|
51
|
+
config_class: typing.Final = config_to_merge.__class__
|
|
52
|
+
resulting_dict_config: typing.Final = merge_dict_configs(
|
|
53
|
+
dataclass_to_dict_no_defaults(config_to_merge),
|
|
54
|
+
dataclass_to_dict_no_defaults(config_with_changes),
|
|
55
|
+
)
|
|
56
|
+
return config_class(**resulting_dict_config)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def merge_dict_configs(
|
|
60
|
+
config_dict: dict[str, typing.Any],
|
|
61
|
+
changes_dict: dict[str, typing.Any],
|
|
62
|
+
) -> dict[str, typing.Any]:
|
|
63
|
+
for change_key, change_value in changes_dict.items():
|
|
64
|
+
config_value = config_dict.get(change_key)
|
|
65
|
+
|
|
66
|
+
if isinstance(config_value, set):
|
|
67
|
+
if not isinstance(change_value, set):
|
|
68
|
+
raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
|
|
69
|
+
config_dict[change_key] = {*config_value, *change_value}
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if isinstance(config_value, tuple):
|
|
73
|
+
if not isinstance(change_value, tuple):
|
|
74
|
+
raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
|
|
75
|
+
config_dict[change_key] = (*config_value, *change_value)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if isinstance(config_value, list):
|
|
79
|
+
if not isinstance(change_value, list):
|
|
80
|
+
raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
|
|
81
|
+
config_dict[change_key] = [*config_value, *change_value]
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if isinstance(config_value, dict):
|
|
85
|
+
if not isinstance(change_value, dict):
|
|
86
|
+
raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
|
|
87
|
+
config_dict[change_key] = {**config_value, **change_value}
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
config_dict[change_key] = change_value
|
|
91
|
+
|
|
92
|
+
return config_dict
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_valid_path(maybe_path: str) -> bool:
|
|
96
|
+
return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from microbootstrap.instruments.logging_instrument import LoggingConfig
|
|
2
|
+
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
|
|
3
|
+
from microbootstrap.instruments.prometheus_instrument import PrometheusConfig
|
|
4
|
+
from microbootstrap.instruments.sentry_instrument import SentryConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = ("SentryConfig", "OpentelemetryConfig", "PrometheusConfig", "LoggingConfig")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import abc
|
|
3
|
+
import dataclasses
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
|
|
8
|
+
from microbootstrap.helpers import merge_pydantic_configs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if typing.TYPE_CHECKING:
|
|
12
|
+
from microbootstrap.console_writer import ConsoleWriter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
InstrumentConfigT = typing.TypeVar("InstrumentConfigT", bound="BaseInstrumentConfig")
|
|
16
|
+
ApplicationT = typing.TypeVar("ApplicationT")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseInstrumentConfig(pydantic.BaseModel):
|
|
20
|
+
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass
|
|
24
|
+
class Instrument(abc.ABC, typing.Generic[InstrumentConfigT]):
|
|
25
|
+
instrument_config: InstrumentConfigT
|
|
26
|
+
|
|
27
|
+
def configure_instrument(
|
|
28
|
+
self,
|
|
29
|
+
incoming_config: InstrumentConfigT,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.instrument_config = merge_pydantic_configs(self.instrument_config, incoming_config)
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
def write_status(self, console_writer: ConsoleWriter) -> None:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
def is_ready(self) -> bool:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
@abc.abstractmethod
|
|
42
|
+
def bootstrap(self) -> None:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
@abc.abstractmethod
|
|
46
|
+
def teardown(self) -> None:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
@abc.abstractmethod
|
|
51
|
+
def get_config_type(cls) -> type[InstrumentConfigT]:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def bootstrap_before(self) -> dict[str, typing.Any]:
|
|
55
|
+
"""Add some framework-related parameters to final bootstrap result before application creation."""
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
|
|
59
|
+
"""Add some framework-related parameters to final bootstrap result after application creation."""
|
|
60
|
+
return application
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import typing_extensions
|
|
4
|
+
|
|
5
|
+
from microbootstrap import exceptions
|
|
6
|
+
from microbootstrap.instruments.base import Instrument, InstrumentConfigT
|
|
7
|
+
from microbootstrap.settings import SettingsT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InstrumentBox:
|
|
11
|
+
__instruments__: typing.ClassVar[list[type[Instrument[typing.Any]]]] = []
|
|
12
|
+
__initialized_instruments__: list[Instrument[typing.Any]]
|
|
13
|
+
|
|
14
|
+
def initialize(self, settings: SettingsT) -> None:
|
|
15
|
+
settings_dump = settings.model_dump()
|
|
16
|
+
self.__initialized_instruments__ = [
|
|
17
|
+
instrument_type(instrument_type.get_config_type()(**settings_dump))
|
|
18
|
+
for instrument_type in self.__instruments__
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def configure_instrument(
|
|
22
|
+
self: typing_extensions.Self,
|
|
23
|
+
instrument_config: InstrumentConfigT,
|
|
24
|
+
) -> None:
|
|
25
|
+
for instrument in self.__initialized_instruments__:
|
|
26
|
+
if isinstance(instrument_config, instrument.get_config_type()):
|
|
27
|
+
instrument.configure_instrument(instrument_config)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
raise exceptions.MissingInstrumentError(
|
|
31
|
+
f"Instrument for config {instrument_config.__class__.__name__} is not supported yet.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def extend_instruments(
|
|
36
|
+
cls,
|
|
37
|
+
instrument_class: type[Instrument[InstrumentConfigT]],
|
|
38
|
+
) -> type[Instrument[InstrumentConfigT]]:
|
|
39
|
+
"""Extend list of instruments, excluding one whose config is already in use."""
|
|
40
|
+
cls.__instruments__ = list(
|
|
41
|
+
filter(
|
|
42
|
+
lambda instrument: instrument.get_config_type() is not instrument_class.get_config_type(),
|
|
43
|
+
cls.__instruments__,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
cls.__instruments__.append(instrument_class)
|
|
47
|
+
return instrument_class
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def instruments(self) -> list[Instrument[typing.Any]]:
|
|
51
|
+
return self.__initialized_instruments__
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import logging.handlers
|
|
4
|
+
import time
|
|
5
|
+
import typing
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
import pydantic
|
|
9
|
+
import structlog
|
|
10
|
+
from opentelemetry import trace
|
|
11
|
+
|
|
12
|
+
from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if typing.TYPE_CHECKING:
|
|
16
|
+
import fastapi
|
|
17
|
+
import litestar
|
|
18
|
+
from structlog.typing import EventDict, WrappedLogger
|
|
19
|
+
|
|
20
|
+
from microbootstrap.console_writer import ConsoleWriter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ScopeType = typing.MutableMapping[str, typing.Any]
|
|
24
|
+
|
|
25
|
+
access_logger: typing.Final = structlog.get_logger("api.access")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_path_with_query_string(scope: ScopeType) -> str:
|
|
29
|
+
path_with_query_string: typing.Final = urllib.parse.quote(scope["path"])
|
|
30
|
+
if scope["query_string"]:
|
|
31
|
+
return f'{path_with_query_string}?{scope["query_string"].decode("ascii")}'
|
|
32
|
+
return path_with_query_string
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fill_log_message(
|
|
36
|
+
log_level: str,
|
|
37
|
+
request: litestar.Request | fastapi.Request,
|
|
38
|
+
status_code: int,
|
|
39
|
+
start_time: int,
|
|
40
|
+
) -> None:
|
|
41
|
+
process_time: typing.Final = time.perf_counter_ns() - start_time
|
|
42
|
+
url_with_query: typing.Final = make_path_with_query_string(typing.cast(ScopeType, request.scope))
|
|
43
|
+
client_host: typing.Final = request.client.host if request.client is not None else None
|
|
44
|
+
client_port: typing.Final = request.client.port if request.client is not None else None
|
|
45
|
+
http_method: typing.Final = request.method
|
|
46
|
+
http_version: typing.Final = request.scope["http_version"]
|
|
47
|
+
log_on_correct_level: typing.Final = getattr(access_logger, log_level)
|
|
48
|
+
log_on_correct_level(
|
|
49
|
+
http={
|
|
50
|
+
"url": url_with_query,
|
|
51
|
+
"status_code": status_code,
|
|
52
|
+
"method": http_method,
|
|
53
|
+
"version": http_version,
|
|
54
|
+
},
|
|
55
|
+
network={"client": {"ip": client_host, "port": client_port}},
|
|
56
|
+
duration=process_time,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tracer_injection(_: WrappedLogger, __: str, event_dict: EventDict) -> EventDict:
|
|
61
|
+
event_dict["tracing"] = {}
|
|
62
|
+
current_span: typing.Final[trace.Span] = trace.get_current_span()
|
|
63
|
+
if current_span == trace.INVALID_SPAN:
|
|
64
|
+
return event_dict
|
|
65
|
+
|
|
66
|
+
span_context: typing.Final[trace.SpanContext] = current_span.get_span_context()
|
|
67
|
+
if span_context == trace.INVALID_SPAN_CONTEXT:
|
|
68
|
+
return event_dict
|
|
69
|
+
|
|
70
|
+
event_dict["tracing"]["trace_id"] = format(span_context.span_id, "016x")
|
|
71
|
+
event_dict["tracing"]["span_id"] = format(span_context.trace_id, "032x")
|
|
72
|
+
|
|
73
|
+
return event_dict
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
DEFAULT_STRUCTLOG_PROCESSORS: typing.Final[list[typing.Any]] = [
|
|
77
|
+
structlog.stdlib.filter_by_level,
|
|
78
|
+
structlog.stdlib.add_log_level,
|
|
79
|
+
structlog.stdlib.add_logger_name,
|
|
80
|
+
tracer_injection,
|
|
81
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
82
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
|
|
83
|
+
structlog.processors.StackInfoRenderer(),
|
|
84
|
+
structlog.processors.format_exc_info,
|
|
85
|
+
structlog.processors.UnicodeDecoder(),
|
|
86
|
+
]
|
|
87
|
+
DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR: typing.Final = structlog.processors.JSONRenderer()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MemoryLoggerFactory(structlog.stdlib.LoggerFactory):
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*args: typing.Any, # noqa: ANN401
|
|
94
|
+
logging_buffer_capacity: int,
|
|
95
|
+
logging_flush_level: int,
|
|
96
|
+
logging_log_level: int,
|
|
97
|
+
log_stream: typing.Any = None, # noqa: ANN401
|
|
98
|
+
**kwargs: typing.Any, # noqa: ANN401
|
|
99
|
+
) -> None:
|
|
100
|
+
super().__init__(*args, **kwargs)
|
|
101
|
+
self.logging_buffer_capacity = logging_buffer_capacity
|
|
102
|
+
self.logging_flush_level = logging_flush_level
|
|
103
|
+
self.logging_log_level = logging_log_level
|
|
104
|
+
self.log_stream = log_stream
|
|
105
|
+
|
|
106
|
+
def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401
|
|
107
|
+
logger: typing.Final = super().__call__(*args)
|
|
108
|
+
stream_handler: typing.Final = logging.StreamHandler(stream=self.log_stream)
|
|
109
|
+
handler: typing.Final = logging.handlers.MemoryHandler(
|
|
110
|
+
capacity=self.logging_buffer_capacity,
|
|
111
|
+
flushLevel=self.logging_flush_level,
|
|
112
|
+
target=stream_handler,
|
|
113
|
+
)
|
|
114
|
+
logger.addHandler(handler)
|
|
115
|
+
logger.setLevel(self.logging_log_level)
|
|
116
|
+
logger.propagate = False
|
|
117
|
+
return logger
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class LoggingConfig(BaseInstrumentConfig):
|
|
121
|
+
service_debug: bool = True
|
|
122
|
+
|
|
123
|
+
logging_log_level: int = pydantic.Field(default=logging.INFO)
|
|
124
|
+
logging_flush_level: int = pydantic.Field(default=logging.ERROR)
|
|
125
|
+
logging_buffer_capacity: int = pydantic.Field(default=10)
|
|
126
|
+
logging_extra_processors: list[typing.Any] = pydantic.Field(default_factory=list)
|
|
127
|
+
logging_unset_handlers: list[str] = pydantic.Field(
|
|
128
|
+
default_factory=lambda: ["uvicorn", "uvicorn.access"],
|
|
129
|
+
)
|
|
130
|
+
logging_exclude_endpoints: list[str] = pydantic.Field(default_factory=lambda: ["/health"])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LoggingInstrument(Instrument[LoggingConfig]):
|
|
134
|
+
def write_status(self, console_writer: ConsoleWriter) -> None:
|
|
135
|
+
if self.is_ready():
|
|
136
|
+
console_writer.write_instrument_status("Logging", is_enabled=True)
|
|
137
|
+
else:
|
|
138
|
+
console_writer.write_instrument_status(
|
|
139
|
+
"Logging",
|
|
140
|
+
is_enabled=False,
|
|
141
|
+
disable_reason="Works only in non-debug mode",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def is_ready(self) -> bool:
|
|
145
|
+
return not self.instrument_config.service_debug
|
|
146
|
+
|
|
147
|
+
def teardown(self) -> None:
|
|
148
|
+
structlog.reset_defaults()
|
|
149
|
+
|
|
150
|
+
def bootstrap(self) -> None:
|
|
151
|
+
for unset_handlers_logger in self.instrument_config.logging_unset_handlers:
|
|
152
|
+
logging.getLogger(unset_handlers_logger).handlers = []
|
|
153
|
+
|
|
154
|
+
structlog.configure(
|
|
155
|
+
processors=[
|
|
156
|
+
*DEFAULT_STRUCTLOG_PROCESSORS,
|
|
157
|
+
*self.instrument_config.logging_extra_processors,
|
|
158
|
+
DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR,
|
|
159
|
+
],
|
|
160
|
+
context_class=dict,
|
|
161
|
+
logger_factory=MemoryLoggerFactory(
|
|
162
|
+
logging_buffer_capacity=self.instrument_config.logging_buffer_capacity,
|
|
163
|
+
logging_flush_level=self.instrument_config.logging_flush_level,
|
|
164
|
+
logging_log_level=self.instrument_config.logging_log_level,
|
|
165
|
+
),
|
|
166
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
167
|
+
cache_logger_on_first_use=True,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def get_config_type(cls) -> type[LoggingConfig]:
|
|
172
|
+
return LoggingConfig
|