diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index f3ab54a..d50cabb 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -26,7 +26,7 @@ class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]): def __init__(self, bootstrap_config: BaseConfig) -> None: self.is_bootstrapped = False if not self.is_ready(): - msg = f"{type(self).__name__} is not ready because {self.not_ready_message}" + msg = f"{type(self).__name__} is not ready: {self.not_ready_message}" raise RuntimeError(msg) self.bootstrap_config = bootstrap_config @@ -38,7 +38,7 @@ def __init__(self, bootstrap_config: BaseConfig) -> None: continue if not instrument.is_ready(): - logger.info(f"{instrument_type.__name__} is not ready, because {instrument.not_ready_message}") + logger.info(f"{instrument_type.__name__} is not ready: {instrument.not_ready_message}") continue self.instruments.append(instrument) diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 2096a27..a3fe283 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -170,7 +170,7 @@ class FastAPISwaggerInstrument(SwaggerInstrument): def bootstrap(self) -> None: if self.bootstrap_config.swagger_path != self.bootstrap_config.application.docs_url: warnings.warn( - f"swagger_path is differ from docs_url, " + f"swagger_path differs from docs_url, " f"{self.bootstrap_config.application.docs_url} will be used for docs path", stacklevel=2, ) diff --git a/lite_bootstrap/helpers/path.py b/lite_bootstrap/helpers/path.py index 53d3846..0b3577d 100644 --- a/lite_bootstrap/helpers/path.py +++ b/lite_bootstrap/helpers/path.py @@ -2,7 +2,7 @@ import typing -VALID_PATH_PATTERN: typing.Final = re.compile(r"^(/[a-zA-Z0-9_-]+)+/?$") +VALID_PATH_PATTERN: typing.Final = re.compile(r"^(/[a-zA-Z0-9._-]+)+/?$") def is_valid_path(maybe_path: str) -> bool: diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index 52aa338..fe1e9d8 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -68,6 +68,7 @@ def __init__( self.logging_flush_level = logging_flush_level self.logging_log_level = logging_log_level self.log_stream = log_stream + self._created_handlers: list[tuple[logging.Logger, logging.handlers.MemoryHandler]] = [] def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 logger: typing.Final = super().__call__(*args) @@ -80,8 +81,19 @@ def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 logger.addHandler(handler) logger.setLevel(self.logging_log_level) logger.propagate = False + self._created_handlers.append((logger, handler)) return logger + def close_handlers(self) -> None: + for created_logger, handler in self._created_handlers: + created_logger.removeHandler(handler) + created_logger.propagate = True + target = handler.target + handler.close() + if target is not None: + target.close() + self._created_handlers.clear() + def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 return orjson.dumps(value, **kwargs).decode() @@ -103,6 +115,9 @@ class LoggingInstrument(BaseInstrument): bootstrap_config: LoggingConfig not_ready_message = "service_debug is True" missing_dependency_message = "structlog is not installed" + _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) @property def structlog_pre_chain_processors(self) -> list[typing.Any]: @@ -139,11 +154,15 @@ def structlog_processors(self) -> list[typing.Any]: @property def memory_logger_factory(self) -> "MemoryLoggerFactory": - return MemoryLoggerFactory( - logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, - logging_flush_level=self.bootstrap_config.logging_flush_level, - logging_log_level=self.bootstrap_config.logging_log_level, - ) + cached: MemoryLoggerFactory | None = self._logger_factory + if cached is None: + cached = MemoryLoggerFactory( + logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, + logging_flush_level=self.bootstrap_config.logging_flush_level, + logging_log_level=self.bootstrap_config.logging_log_level, + ) + object.__setattr__(self, "_logger_factory", cached) + return cached def _configure_structlog_loggers(self) -> None: structlog.configure( @@ -183,3 +202,6 @@ def teardown(self) -> None: root_logger.removeHandler(h) h.close() root_logger.setLevel(logging.WARNING) + if self._logger_factory is not None: + self._logger_factory.close_handlers() + object.__setattr__(self, "_logger_factory", None) diff --git a/tests/instruments/test_sentry_instrument.py b/tests/instruments/test_sentry_instrument.py index 3c14e16..0473744 100644 --- a/tests/instruments/test_sentry_instrument.py +++ b/tests/instruments/test_sentry_instrument.py @@ -5,7 +5,6 @@ import pytest import sentry_sdk import structlog -from sentry_sdk.integrations.logging import LoggingIntegration from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument from tests.conftest import LoggingMock, SentryTestTransport @@ -62,7 +61,6 @@ def test_sentry_instrument_with_structlog_error( logger.error("some error") logger.error("some error, skipping sentry", skip_sentry=True) assert len(sentry_mock.mock_envelopes) == 1 - LoggingIntegration() finally: sentry_sdk.init() logging_instrument.teardown() diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index f3054a6..60d187e 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -87,7 +87,7 @@ def test_fastapi_bootstrapper_not_ready() -> None: def test_fastapi_bootstrapper_docs_url_differ(fastapi_config: FastAPIConfig) -> None: new_config = dataclasses.replace(fastapi_config, application=fastapi.FastAPI(docs_url="/custom-docs/")) bootstrapper = FastAPIBootstrapper(bootstrap_config=new_config) - with pytest.warns(UserWarning, match="swagger_path is differ from docs_url"): + with pytest.warns(UserWarning, match="swagger_path differs from docs_url"): bootstrapper.bootstrap() bootstrapper.teardown() diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index cb9d147..714cd9f 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -44,8 +44,8 @@ def test_free_bootstrap_logging_not_ready() -> None: ), ) assert cap_logs == [ - {"event": "LoggingInstrument is not ready, because service_debug is True", "log_level": "info"}, - {"event": "PyroscopeInstrument is not ready, because pyroscope_endpoint is empty", "log_level": "info"}, + {"event": "LoggingInstrument is not ready: service_debug is True", "log_level": "info"}, + {"event": "PyroscopeInstrument is not ready: pyroscope_endpoint is empty", "log_level": "info"}, ]