Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 51 additions & 47 deletions lite_bootstrap/bootstrappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
BootstrapperNotReadyError,
ConfigurationError,
InstrumentDependencyMissingWarning,
TeardownError,
collect_teardown_errors,
)
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
from lite_bootstrap.types import ApplicationT
Expand All @@ -31,6 +31,52 @@ class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]):
# bootstrapper on the same app from re-attaching. Its accepted limits: ADR-0003.
_TEARDOWN_MARKER: typing.ClassVar[str] = "_lite_bootstrap_teardown_attached"

def __init__(self, bootstrap_config: BaseConfig) -> None:
self.is_bootstrapped = False
# Set when another bootstrapper already owns this application; bootstrap() then refuses.
self._attach_skipped = False
if not self.is_ready():
msg = f"{type(self).__name__} is not ready: {self.not_ready_message}"
raise BootstrapperNotReadyError(msg)

self.bootstrap_config = bootstrap_config
self.instruments = []
self.skipped_instruments = []
self._select_instruments()

if logger.isEnabledFor(logging.INFO):
logger.info(self.build_summary())

def _select_instruments(self) -> None:
"""Instantiate every configured instrument type, recording the two kinds of skip.

Called by ``__init__`` once ``instruments`` and ``skipped_instruments`` exist; it fills
both. The signals differ on purpose — see "Skipped" in CONTEXT.md.
"""
for instrument_type in self.instruments_types:
# Config-level skip first: silent (no warning). Runs before instantiation so a
# missing-optional-dep doesn't fail in a dataclass default_factory before we
# can decide the user opted out.
if not instrument_type.is_configured(self.bootstrap_config):
self.skipped_instruments.append((instrument_type, instrument_type.not_configured_reason))
continue
# Dep-missing for a CONFIGURED instrument is a genuine deployment surprise.
if not instrument_type.dependencies_installed():
# stacklevel counts this frame, __init__'s and the subclass __init__'s, to land
# on the line that constructed the bootstrapper.
warnings.warn(
instrument_type.missing_dependency_message,
category=InstrumentDependencyMissingWarning,
stacklevel=4,
)
logger.warning(
"instrument %s skipped: %s",
instrument_type.__name__,
instrument_type.missing_dependency_message,
)
continue
self.instruments.append(instrument_type(bootstrap_config=self.bootstrap_config))

def _attach_teardown_once(self, target: object, attach: typing.Callable[[], object]) -> None:
"""Run ``attach`` (which wires ``teardown`` into the framework's shutdown) once per target.

Expand Down Expand Up @@ -70,42 +116,6 @@ def build_summary(self) -> str:
lines.append(" (none)")
return "\n".join(lines)

def __init__(self, bootstrap_config: BaseConfig) -> None:
self.is_bootstrapped = False
# Set when another bootstrapper already owns this application; bootstrap() then refuses.
self._attach_skipped = False
if not self.is_ready():
msg = f"{type(self).__name__} is not ready: {self.not_ready_message}"
raise BootstrapperNotReadyError(msg)

self.bootstrap_config = bootstrap_config
self.instruments = []
self.skipped_instruments = []
for instrument_type in self.instruments_types:
# Config-level skip first: silent (no warning). Runs before instantiation so a
# missing-optional-dep doesn't fail in a dataclass default_factory before we
# can decide the user opted out.
if not instrument_type.is_configured(self.bootstrap_config):
self.skipped_instruments.append((instrument_type, instrument_type.not_configured_reason))
continue
# Dep-missing for a CONFIGURED instrument is a genuine deployment surprise.
if not instrument_type.dependencies_installed():
warnings.warn(
instrument_type.missing_dependency_message,
category=InstrumentDependencyMissingWarning,
stacklevel=3,
)
logger.warning(
"instrument %s skipped: %s",
instrument_type.__name__,
instrument_type.missing_dependency_message,
)
continue
self.instruments.append(instrument_type(bootstrap_config=self.bootstrap_config))

if logger.isEnabledFor(logging.INFO):
logger.info(self.build_summary())

@abc.abstractmethod
def _prepare_application(self) -> ApplicationT: ...

Expand All @@ -131,13 +141,7 @@ def teardown(self) -> None:
if not self.is_bootstrapped:
return
self.is_bootstrapped = False
errors: list[tuple[str, BaseException]] = []
for one_instrument in reversed(self.instruments):
try:
one_instrument.teardown()
except Exception as e: # noqa: BLE001, PERF203
name = type(one_instrument).__name__
logger.warning("Error tearing down %s: %s", name, e)
errors.append((name, e))
if errors:
raise TeardownError(errors) from errors[0][1]
with collect_teardown_errors(logger) as teardown_errors:
for one_instrument in reversed(self.instruments):
with teardown_errors.capture(type(one_instrument).__name__):
one_instrument.teardown()
22 changes: 12 additions & 10 deletions lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,16 @@ def dependencies_installed() -> bool:
return import_checker.is_prometheus_fastapi_instrumentator_installed

def bootstrap(self) -> None:
application = self.bootstrap_config.app
Instrumentator(**self.bootstrap_config.prometheus_instrumentator_params).instrument(
config = self.bootstrap_config
application = config.app
Instrumentator(**config.prometheus_instrumentator_params).instrument(
application,
**self.bootstrap_config.prometheus_instrument_params,
**config.prometheus_instrument_params,
).expose(
application,
endpoint=self.bootstrap_config.prometheus_metrics_path,
include_in_schema=self.bootstrap_config.prometheus_metrics_include_in_schema,
**self.bootstrap_config.prometheus_expose_params,
endpoint=config.prometheus_metrics_path,
include_in_schema=config.prometheus_metrics_include_in_schema,
**config.prometheus_expose_params,
)


Expand All @@ -154,14 +155,15 @@ class FastAPISwaggerInstrument(SwaggerInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
application = self.bootstrap_config.app
if self.bootstrap_config.swagger_path != application.docs_url:
config = self.bootstrap_config
application = config.app
if config.swagger_path != application.docs_url:
warnings.warn(
f"swagger_path differs from docs_url, {application.docs_url} will be used for docs path",
stacklevel=2,
)
if self.bootstrap_config.swagger_offline_docs:
enable_offline_docs(application, static_path=self.bootstrap_config.swagger_static_path)
if config.swagger_offline_docs:
enable_offline_docs(application, static_path=config.swagger_static_path)


class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]):
Expand Down
16 changes: 10 additions & 6 deletions lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ class FastMcpHealthChecksInstrument(HealthChecksInstrument):
bootstrap_config: FastMcpConfig

def bootstrap(self) -> None:
@self.bootstrap_config.application.custom_route(
self.bootstrap_config.health_checks_path,
config = self.bootstrap_config

@config.application.custom_route(
config.health_checks_path,
methods=["GET"],
name="health_check",
include_in_schema=self.bootstrap_config.health_checks_include_in_schema,
include_in_schema=config.health_checks_include_in_schema,
)
async def health_check_handler(_: "Request") -> "JSONResponse":
return JSONResponse(dict(self.render_health_check_data()))
Expand All @@ -109,11 +111,13 @@ def dependencies_installed() -> bool:
return import_checker.is_prometheus_client_installed

def bootstrap(self) -> None:
@self.bootstrap_config.application.custom_route(
self.bootstrap_config.prometheus_metrics_path,
config = self.bootstrap_config

@config.application.custom_route(
config.prometheus_metrics_path,
methods=["GET"],
name="metrics",
include_in_schema=self.bootstrap_config.prometheus_metrics_include_in_schema,
include_in_schema=config.prometheus_metrics_include_in_schema,
)
async def metrics_handler(_: "Request") -> "Response":
return Response(
Expand Down
38 changes: 17 additions & 21 deletions lite_bootstrap/bootstrappers/faststream_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ class FastStreamHealthChecksInstrument(HealthChecksInstrument):
bootstrap_config: FastStreamConfig

def bootstrap(self) -> None:
config = self.bootstrap_config

@handle_get
async def check_health(_: object) -> "AsgiResponse":
return (
Expand All @@ -97,23 +99,17 @@ async def check_health(_: object) -> "AsgiResponse":
else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "text/plain"})
)

if (
self.bootstrap_config.opentelemetry_generate_health_check_spans
and import_checker.is_opentelemetry_installed
):
check_health = tracer.start_as_current_span(f"GET {self.bootstrap_config.health_checks_path}")(
check_health,
)
if config.opentelemetry_generate_health_check_spans and import_checker.is_opentelemetry_installed:
check_health = tracer.start_as_current_span(f"GET {config.health_checks_path}")(check_health)

self.bootstrap_config.application.mount(self.bootstrap_config.health_checks_path, check_health)
config.application.mount(config.health_checks_path, check_health)

async def _define_health_status(self) -> bool:
if not self.bootstrap_config.application or not self.bootstrap_config.application.broker:
config = self.bootstrap_config
if not config.application or not config.application.broker:
return False

return await self.bootstrap_config.application.broker.ping(
timeout=self.bootstrap_config.faststream_health_check_broker_timeout,
)
return await config.application.broker.ping(timeout=config.faststream_health_check_broker_timeout)


@dataclasses.dataclass(kw_only=True)
Expand Down Expand Up @@ -154,9 +150,10 @@ def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: # ty: ign
return super().is_configured(bootstrap_config) and bool(bootstrap_config.opentelemetry_middleware_cls)

def bootstrap(self) -> None:
if self.bootstrap_config.opentelemetry_middleware_cls and self.bootstrap_config.application.broker:
self.bootstrap_config.application.broker.add_middleware(
self.bootstrap_config.opentelemetry_middleware_cls(tracer_provider=get_tracer_provider())
config = self.bootstrap_config
if config.opentelemetry_middleware_cls and config.application.broker:
config.application.broker.add_middleware(
config.opentelemetry_middleware_cls(tracer_provider=get_tracer_provider())
)


Expand Down Expand Up @@ -184,13 +181,12 @@ def dependencies_installed() -> bool:
return import_checker.is_prometheus_client_installed

def bootstrap(self) -> None:
self.bootstrap_config.application.mount(
self.bootstrap_config.prometheus_metrics_path, prometheus_client.make_asgi_app(self.collector_registry)
config = self.bootstrap_config
config.application.mount(
config.prometheus_metrics_path, prometheus_client.make_asgi_app(self.collector_registry)
)
if self.bootstrap_config.prometheus_middleware_cls and self.bootstrap_config.application.broker:
self.bootstrap_config.application.broker.add_middleware(
self.bootstrap_config.prometheus_middleware_cls(registry=self.collector_registry)
)
if config.prometheus_middleware_cls and config.application.broker:
config.application.broker.add_middleware(config.prometheus_middleware_cls(registry=self.collector_registry))


class FastStreamBootstrapper(BaseBootstrapper["AsgiFastStream"]):
Expand Down
59 changes: 28 additions & 31 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,12 @@ class LitestarLoggingInstrument(LoggingInstrument):

def _build_logging_middleware_excluded_paths(self) -> list[str]:
"""Regex-escaped path prefixes for infrastructure routes not worth an access log line."""
config = self.bootstrap_config
candidate_paths: typing.Final = (
self.bootstrap_config.swagger_path,
self.bootstrap_config.swagger_static_path if self.bootstrap_config.swagger_offline_docs else "",
self.bootstrap_config.health_checks_path,
self.bootstrap_config.prometheus_metrics_path,
config.swagger_path,
config.swagger_static_path if config.swagger_offline_docs else "",
config.health_checks_path,
config.prometheus_metrics_path,
)
excluded_paths: list[str] = []
for candidate_path in candidate_paths:
Expand Down Expand Up @@ -262,23 +263,22 @@ def dependencies_installed() -> bool:
return import_checker.is_prometheus_client_installed

def bootstrap(self) -> None:
config = self.bootstrap_config

class LitestarPrometheusController(PrometheusController):
path = self.bootstrap_config.prometheus_metrics_path
include_in_schema = self.bootstrap_config.prometheus_metrics_include_in_schema
path = config.prometheus_metrics_path
include_in_schema = config.prometheus_metrics_include_in_schema
openmetrics_format = True

# Merged so prometheus_additional_params can override group_path without a kwarg collision.
prometheus_params: dict[str, typing.Any] = {
"group_path": self.bootstrap_config.prometheus_group_path,
**self.bootstrap_config.prometheus_additional_params,
"group_path": config.prometheus_group_path,
**config.prometheus_additional_params,
}
litestar_prometheus_config = PrometheusConfig(
app_name=self.bootstrap_config.service_name,
**prometheus_params,
)
litestar_prometheus_config = PrometheusConfig(app_name=config.service_name, **prometheus_params)

self.bootstrap_config.application_config.route_handlers.append(LitestarPrometheusController)
self.bootstrap_config.application_config.middleware.append(litestar_prometheus_config.middleware)
config.application_config.route_handlers.append(LitestarPrometheusController)
config.application_config.middleware.append(litestar_prometheus_config.middleware)


@dataclasses.dataclass(kw_only=True)
Expand All @@ -291,33 +291,30 @@ def is_configured(cls, bootstrap_config: "LitestarConfig") -> bool: # ty: ignor
return bool(bootstrap_config.swagger_path) and is_valid_path(bootstrap_config.swagger_path)

def bootstrap(self) -> None:
config = self.bootstrap_config
render_plugins: typing.Final = (
(
SwaggerRenderPlugin(
js_url=f"{self.bootstrap_config.swagger_static_path}/swagger-ui-bundle.js",
css_url=f"{self.bootstrap_config.swagger_static_path}/swagger-ui.css",
standalone_preset_js_url=(
f"{self.bootstrap_config.swagger_static_path}/swagger-ui-standalone-preset.js"
),
js_url=f"{config.swagger_static_path}/swagger-ui-bundle.js",
css_url=f"{config.swagger_static_path}/swagger-ui.css",
standalone_preset_js_url=f"{config.swagger_static_path}/swagger-ui-standalone-preset.js",
),
)
if self.bootstrap_config.swagger_offline_docs
if config.swagger_offline_docs
else (SwaggerRenderPlugin(),)
)
self.bootstrap_config.application_config.openapi_config = OpenAPIConfig(
path=self.bootstrap_config.swagger_path,
title=self.bootstrap_config.service_name,
version=self.bootstrap_config.service_version,
description=self.bootstrap_config.service_description,
config.application_config.openapi_config = OpenAPIConfig(
path=config.swagger_path,
title=config.service_name,
version=config.service_version,
description=config.service_description,
render_plugins=render_plugins,
**self.bootstrap_config.swagger_extra_params,
**config.swagger_extra_params,
)
if self.bootstrap_config.swagger_offline_docs:
if config.swagger_offline_docs:
static_dir_path = pathlib.Path(__file__).parent.parent / "static/litestar_docs"
self.bootstrap_config.application_config.route_handlers.append(
create_static_files_router(
path=self.bootstrap_config.swagger_static_path, directories=[static_dir_path]
)
config.application_config.route_handlers.append(
create_static_files_router(path=config.swagger_static_path, directories=[static_dir_path])
)


Expand Down
Loading
Loading