From c09910a227d14e04452636b01f6cb4d4e125ff46 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 17 Apr 2025 13:36:08 +0300 Subject: [PATCH] raise warnings only if dependencies are missing --- lite_bootstrap/bootstrappers/base.py | 22 +++++++++++++++---- .../bootstrappers/fastapi_bootstrapper.py | 9 ++++---- .../bootstrappers/faststream_bootstrapper.py | 11 +++++----- .../bootstrappers/free_bootstrapper.py | 4 ++-- .../bootstrappers/litestar_bootstrapper.py | 7 +++--- lite_bootstrap/instruments/base.py | 5 +++++ .../instruments/logging_instrument.py | 7 +++++- .../instruments/opentelemetry_instrument.py | 7 +++++- .../instruments/sentry_instrument.py | 7 +++++- pyproject.toml | 5 ++++- tests/conftest.py | 8 +++++++ tests/test_free_bootstrap.py | 22 +++++++++++-------- 12 files changed, 82 insertions(+), 32 deletions(-) diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index ab53e33..fc30d96 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -1,11 +1,20 @@ import abc +import logging import typing import warnings +import structlog + from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument from lite_bootstrap.types import ApplicationT +try: + logger = structlog.getLogger(__name__) +except ImportError: + logger = logging.getLogger(__name__) + + InstrumentT = typing.TypeVar("InstrumentT", bound=BaseInstrument) @@ -24,10 +33,15 @@ def __init__(self, bootstrap_config: BaseConfig) -> None: self.instruments = [] for instrument_type in self.instruments_types: instrument = instrument_type(bootstrap_config=bootstrap_config) - if instrument.is_ready(): - self.instruments.append(instrument) - else: - warnings.warn(instrument.not_ready_message, stacklevel=2) + if not instrument.check_dependencies(): + warnings.warn(instrument.missing_dependency_message, stacklevel=2) + continue + + if not instrument.is_ready(): + logger.info(instrument.not_ready_message) + continue + + self.instruments.append(instrument) @property @abc.abstractmethod diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 96857cf..6ae762f 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -116,12 +116,11 @@ class FastAPISentryInstrument(SentryInstrument): @dataclasses.dataclass(kw_only=True, frozen=True) class FastAPIPrometheusInstrument(PrometheusInstrument): bootstrap_config: FastAPIConfig - not_ready_message = ( - PrometheusInstrument.not_ready_message + " or prometheus_fastapi_instrumentator is not installed" - ) + missing_dependency_message = "prometheus_fastapi_instrumentator is not installed" - def is_ready(self) -> bool: - return super().is_ready() and import_checker.is_prometheus_fastapi_instrumentator_installed + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_prometheus_fastapi_instrumentator_installed def bootstrap(self) -> None: Instrumentator(**self.bootstrap_config.prometheus_instrument_params).instrument( diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index 49b8107..568f5cb 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -113,19 +113,20 @@ class FastStreamPrometheusInstrument(PrometheusInstrument): collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field( default_factory=lambda: prometheus_client.CollectorRegistry(), init=False ) - not_ready_message = ( - PrometheusInstrument.not_ready_message - + " or prometheus_middleware_cls is missing or prometheus_client is not installed" - ) + not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_middleware_cls is missing" + missing_dependency_message = "prometheus_client is not installed" def is_ready(self) -> bool: return ( super().is_ready() and import_checker.is_prometheus_client_installed and bool(self.bootstrap_config.prometheus_middleware_cls) - and import_checker.is_prometheus_client_installed ) + @staticmethod + def check_dependencies() -> 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) diff --git a/lite_bootstrap/bootstrappers/free_bootstrapper.py b/lite_bootstrap/bootstrappers/free_bootstrapper.py index 9237fc6..2fd743f 100644 --- a/lite_bootstrap/bootstrappers/free_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/free_bootstrapper.py @@ -15,9 +15,9 @@ class FreeBootstrapper(BaseBootstrapper[None]): __slots__ = "bootstrap_config", "instruments" instruments_types: typing.ClassVar = [ - OpenTelemetryInstrument, - SentryInstrument, LoggingInstrument, + SentryInstrument, + OpenTelemetryInstrument, ] bootstrap_config: FreeBootstrapperConfig not_ready_message = "" diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 7548f47..07b06e9 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -121,10 +121,11 @@ class LitestarSentryInstrument(SentryInstrument): @dataclasses.dataclass(kw_only=True, frozen=True) class LitestarPrometheusInstrument(PrometheusInstrument): bootstrap_config: LitestarConfig - not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_client is not installed" + missing_dependency_message = "prometheus_client is not installed" - def is_ready(self) -> bool: - return super().is_ready() and import_checker.is_prometheus_client_installed + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_prometheus_client_installed def bootstrap(self) -> None: class LitestarPrometheusController(PrometheusController): diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index 35de4f1..59f3685 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -14,6 +14,7 @@ class BaseConfig: @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class BaseInstrument(abc.ABC): bootstrap_config: BaseConfig + missing_dependency_message = "" @property @abc.abstractmethod @@ -25,3 +26,7 @@ def teardown(self) -> None: ... # noqa: B027 @abc.abstractmethod def is_ready(self) -> bool: ... + + @staticmethod + def check_dependencies() -> bool: + return True diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index c9cd1aa..93b70a2 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -93,11 +93,16 @@ class LoggingConfig(BaseConfig): @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class LoggingInstrument(BaseInstrument): bootstrap_config: LoggingConfig - not_ready_message = "service_debug is True or structlog is not installed" + not_ready_message = "service_debug is True" + missing_dependency_message = "structlog is not installed" def is_ready(self) -> bool: return not self.bootstrap_config.service_debug and import_checker.is_structlog_installed + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_structlog_installed + def bootstrap(self) -> None: # Configure basic logging to allow structlog to catch its events logging.basicConfig( diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index a9855e6..080eb12 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -40,11 +40,16 @@ class OpentelemetryConfig(BaseConfig): @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class OpenTelemetryInstrument(BaseInstrument): bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty or opentelemetry is not installed" + not_ready_message = "opentelemetry_endpoint is empty" + missing_dependency_message = "opentelemetry is not installed" def is_ready(self) -> bool: return bool(self.bootstrap_config.opentelemetry_endpoint) and import_checker.is_opentelemetry_installed + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_opentelemetry_installed + def bootstrap(self) -> None: attributes = { resources.SERVICE_NAME: self.bootstrap_config.service_name diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 84c4119..dd59f6b 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -29,11 +29,16 @@ class SentryConfig(BaseConfig): @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class SentryInstrument(BaseInstrument): bootstrap_config: SentryConfig - not_ready_message = "sentry_dsn is empty or sentry_sdk is not installed" + not_ready_message = "sentry_dsn is empty" + missing_dependency_message = "sentry_sdk is not installed" def is_ready(self) -> bool: return bool(self.bootstrap_config.sentry_dsn) and import_checker.is_sentry_installed + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_sentry_installed + def bootstrap(self) -> None: sentry_sdk.init( dsn=self.bootstrap_config.sentry_dsn, diff --git a/pyproject.toml b/pyproject.toml index 588d445..f435c44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,4 +161,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] -exclude_also = ["if typing.TYPE_CHECKING:"] +exclude_also = [ + "if typing.TYPE_CHECKING:", + "except ImportError:" +] diff --git a/tests/conftest.py b/tests/conftest.py index 6a171c1..1fe8c27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import pytest from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] +from structlog.testing import capture_logs +from structlog.typing import EventDict from lite_bootstrap import import_checker @@ -33,3 +35,9 @@ def emulate_package_missing(package_name: str) -> typing.Iterator[None]: finally: sys.modules[package_name] = old_module reload(import_checker) + + +@pytest.fixture(name="log_output") +def fixture_log_output() -> typing.Iterator[list[EventDict]]: + with capture_logs() as cap_logs: + yield cap_logs diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 7b2f0e1..6ea9a96 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -1,6 +1,7 @@ import pytest import structlog from opentelemetry.sdk.trace.export import ConsoleSpanExporter +from structlog.typing import EventDict from lite_bootstrap import FreeBootstrapper, FreeBootstrapperConfig from tests.conftest import CustomInstrumentor, emulate_package_missing @@ -30,15 +31,18 @@ def test_free_bootstrap(free_bootstrapper_config: FreeBootstrapperConfig) -> Non bootstrapper.teardown() -def test_free_bootstrap_logging_not_ready() -> None: - with pytest.warns(UserWarning, match="service_debug is True or structlog is not installed"): - FreeBootstrapper( - bootstrap_config=FreeBootstrapperConfig( - service_debug=True, - opentelemetry_endpoint="otl", - sentry_dsn="https://testdsn@localhost/1", - ), - ) +def test_free_bootstrap_logging_not_ready(log_output: list[EventDict]) -> None: + FreeBootstrapper( + bootstrap_config=FreeBootstrapperConfig( + service_debug=True, + opentelemetry_endpoint="otl", + opentelemetry_instrumentors=[CustomInstrumentor()], + opentelemetry_span_exporter=ConsoleSpanExporter(), + sentry_dsn="https://testdsn@localhost/1", + logging_buffer_capacity=0, + ), + ) + assert log_output == [{"event": "service_debug is True", "log_level": "info"}] @pytest.mark.parametrize(