diff --git a/benchmarks/README.md b/benchmarks/README.md index 70a863d..f9da5f5 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -200,7 +200,7 @@ structlog 26.1.0. In-process, baseline 17.7 µs per request. The handler does no | scenario | +µs/req | note | |---|---:|---| | logging configured, no records | +0.1 | | -| `fastapi_logging_middleware_enabled=True` | +18.3 | one access-log record per request, about 10 µs of it the record | +| `logging_access_log_enabled=True` | +18.3 | one access-log record per request, about 10 µs of it the record | | Prometheus | +19.1 | | | OpenTelemetry, 100% sampling | +147.9 | | | OpenTelemetry, `opentelemetry_exclude_spans=["receive", "send"]` | +101.6 | -46.3 | @@ -354,7 +354,7 @@ prometheus-client 0.26.0, structlog 26.1.0. In-process, baseline 12.1 µs per re |---|---:|---| | logging configured, no records | -0.1 | free until something logs | | logging, 3 structlog records in the handler | +33.8 | 11.3 µs per record | -| `litestar_logging_middleware_enabled=True` | +70.7 | 2 records per request, each written twice; a second run gave +74.4 | +| `logging_access_log_enabled=True` | +70.7 | 2 records per request, each written twice; a second run gave +74.4 | | Prometheus | +11.6 | | | Prometheus, `prometheus_group_path=False` | +11.8 | | | OpenTelemetry, 100% sampling | +145.4 | a second run gave +131.8 | @@ -477,7 +477,7 @@ it FastMCP and the MCP SDK. Noise on this suite is about ±30 µs even with 3 re | scenario | +µs/call | note | |---|---:|---| | logging configured | +15 | noise | -| `fastmcp_logging_middleware_enabled=True` | +51 | one record per MCP message | +| `logging_access_log_enabled=True` | +51 | one record per MCP message | | Prometheus (HTTP instrumentator and tool-call middleware) | +35 | the split between the two is within noise | | OpenTelemetry, 100% | +272 | 3 ASGI spans plus FastMCP's own server span | | OpenTelemetry, 100%, FastMCP's own spans replaced by a no-op tracer | +226 | FastMCP's spans cost 46 | diff --git a/benchmarks/frameworks/fastapi_suite.py b/benchmarks/frameworks/fastapi_suite.py index ee31da6..a7f6a23 100644 --- a/benchmarks/frameworks/fastapi_suite.py +++ b/benchmarks/frameworks/fastapi_suite.py @@ -31,7 +31,7 @@ SCENARIOS: typing.Final = { "bare": Scenario(config=base_config()), "log": Scenario(config=base_config(**LOG)), - "log_mw": Scenario(config=base_config(**LOG, fastapi_logging_middleware_enabled=True)), + "log_mw": Scenario(config=base_config(**LOG, logging_access_log_enabled=True)), "prom": Scenario(config=base_config(**PROM)), "otel": Scenario(config=base_config(**OTEL)), "otel_excl": Scenario(config=base_config(**OTEL, **EXCL)), diff --git a/benchmarks/frameworks/fastmcp_suite.py b/benchmarks/frameworks/fastmcp_suite.py index 95ee2e0..ee779aa 100644 --- a/benchmarks/frameworks/fastmcp_suite.py +++ b/benchmarks/frameworks/fastmcp_suite.py @@ -41,7 +41,7 @@ def no_fastmcp_spans() -> None: SCENARIOS: typing.Final = { "bare": Scenario(config=base_config()), "log": Scenario(config=base_config(**LOG)), - "log_mw": Scenario(config=base_config(**LOG, fastmcp_logging_middleware_enabled=True)), + "log_mw": Scenario(config=base_config(**LOG, logging_access_log_enabled=True)), "prom": Scenario(config=base_config(**PROM)), "otel": Scenario(config=base_config(**OTEL)), "otel_nofastmcp_spans": Scenario(config=base_config(**OTEL), patch=no_fastmcp_spans), diff --git a/benchmarks/frameworks/litestar_suite.py b/benchmarks/frameworks/litestar_suite.py index bff8c5f..12d58ff 100644 --- a/benchmarks/frameworks/litestar_suite.py +++ b/benchmarks/frameworks/litestar_suite.py @@ -45,7 +45,7 @@ "defaults_noobs": Scenario(config=base_config(**LITESTAR_DEFAULTS)), "log": Scenario(config=base_config(**LOG)), "log_log3": Scenario(config=base_config(**LOG), kind="structlog"), - "log_mw": Scenario(config=base_config(**LOG, litestar_logging_middleware_enabled=True)), + "log_mw": Scenario(config=base_config(**LOG, logging_access_log_enabled=True)), "prom": Scenario(config=base_config(**PROM)), "prom_nogroup": Scenario(config=base_config(**PROM, prometheus_group_path=False)), "otel": Scenario(config=base_config(**OTEL)), diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index 602e3d6..e5a57be 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -58,7 +58,7 @@ place to add a log record. Turn it on explicitly: ```python FastAPIConfig( service_name="microservice", - fastapi_logging_middleware_enabled=True, + logging_access_log_enabled=True, ) ``` @@ -81,7 +81,7 @@ structured one, clear uvicorn's handlers: ```python FastAPIConfig( service_name="microservice", - fastapi_logging_middleware_enabled=True, + logging_access_log_enabled=True, logging_unset_handlers=["uvicorn.access"], ) ``` diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index 58124f4..8aa0539 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -55,7 +55,7 @@ explicitly: ```python FastMcpConfig( service_name="microservice", - fastmcp_logging_middleware_enabled=True, + logging_access_log_enabled=True, ) ``` diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index dfe33d4..8745801 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -68,7 +68,7 @@ Swagger bundle into your logs. Turn it on explicitly: ```python LitestarConfig( service_name="microservice", - litestar_logging_middleware_enabled=True, + logging_access_log_enabled=True, ) ``` @@ -92,7 +92,7 @@ from litestar.middleware.logging import LoggingMiddlewareConfig LitestarConfig( service_name="microservice", - litestar_logging_middleware_enabled=True, + logging_access_log_enabled=True, litestar_logging_middleware_config=LoggingMiddlewareConfig(request_log_fields=("path", "method", "content_type")), ) ``` diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index edd8fba..43814fe 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -93,9 +93,9 @@ Prometheus's integration for FastAPI uses `prometheus_fastapi_instrumentator` pa Additional parameters for FastAPI integration: -- `prometheus_instrumentator_params` - passed to `prometheus_fastapi_instrumentator.Instrumentator` -- `prometheus_instrument_params` - passed to `method Instrumentator(...).instrument` -- `prometheus_expose_params` - passed to `method Instrumentator(...).expose`. +- `prometheus_instrumentator_additional_params` - passed to `prometheus_fastapi_instrumentator.Instrumentator` +- `prometheus_instrument_additional_params` - passed to `method Instrumentator(...).instrument` +- `prometheus_expose_additional_params` - passed to `method Instrumentator(...).expose`. ### Prometheus FastMCP @@ -110,9 +110,9 @@ route itself is not counted. Tool calls are counted in `fastmcp_tool_calls_total Additional parameters for FastMCP integration: -- `prometheus_instrumentator_params` - passed to `prometheus_fastapi_instrumentator.Instrumentator`; +- `prometheus_instrumentator_additional_params` - passed to `prometheus_fastapi_instrumentator.Instrumentator`; `excluded_handlers` defaults to the metrics route. -- `prometheus_instrument_params` - passed to `method Instrumentator(...).instrument`. +- `prometheus_instrument_additional_params` - passed to `method Instrumentator(...).instrument`. - `fastmcp_prometheus_tool_metrics_enabled` - count and time tool calls (default: `True`). @@ -321,8 +321,8 @@ async def handler(request: Request) -> dict[str, str]: Additional parameters for Litestar's access-log middleware: -- `litestar_logging_middleware_enabled` - turn on request/response access logging (default: `False`). -- `litestar_logging_middleware_config` - a caller-supplied `LoggingMiddlewareConfig` that replaces the built-in defaults wholesale. It is ignored, with a warning, unless `litestar_logging_middleware_enabled` is `True`. +- `logging_access_log_enabled` - turn on request/response access logging (default: `False`). +- `litestar_logging_middleware_config` - a caller-supplied `LoggingMiddlewareConfig` that replaces the built-in defaults wholesale. It is ignored, with a warning, unless `logging_access_log_enabled` is `True`. See [the Litestar integration guide](../integrations/litestar.md#logging) for what gets logged and why access logging defaults to off. @@ -330,7 +330,7 @@ See [the Litestar integration guide](../integrations/litestar.md#logging) for wh FastAPI ships no access log of its own, so lite-bootstrap provides one. It is **off by default**: -- `fastapi_logging_middleware_enabled` - turn on the structured access log (default: `False`). +- `logging_access_log_enabled` - turn on the structured access log (default: `False`). See [the FastAPI integration guide](../integrations/fastapi.md#logging) for what gets logged and why it defaults to off. @@ -339,7 +339,7 @@ it defaults to off. The per-MCP-message access log is **off by default**: -- `fastmcp_logging_middleware_enabled` - turn on the access log (default: `False`). +- `logging_access_log_enabled` - turn on the access log (default: `False`). See [the FastMCP integration guide](../integrations/fastmcp.md#logging) for what gets logged. @@ -383,6 +383,7 @@ To bootstrap swagger, you have the following parameters: - `swagger_static_path` - path for offline docs static - `swagger_path` - `swagger_offline_docs` - option to turn on offline docs. +- `swagger_additional_params` - Litestar only: extra keyword arguments for `litestar.openapi.OpenAPIConfig`. It cannot repeat `path`, `title`, `version`, `description` or `render_plugins`, which lite-bootstrap already passes. Swagger is on by default at `swagger_path` (default: `/docs`). For Litestar, set `swagger_path` to an empty string to disable it. diff --git a/docs/introduction/performance.md b/docs/introduction/performance.md index edfe698..30c9134 100644 --- a/docs/introduction/performance.md +++ b/docs/introduction/performance.md @@ -59,7 +59,7 @@ so there is nothing else material hiding in it. That configuration is: --8<-- "benchmarks/README.md:tuned" One thing to leave off rather than turn on: the FastAPI access log -([`fastapi_logging_middleware_enabled`](../integrations/fastapi.md#logging)) is off by default, and +([`logging_access_log_enabled`](../integrations/fastapi.md#logging)) is off by default, and turning it on makes every request emit a log record. On a service that otherwise logs nothing per request, every request then pays the per-record cost measured here: @@ -102,7 +102,7 @@ The same app on Granian, over real sockets: **Sentry costs more on Litestar than on FastAPI**, and all of it is `LitestarIntegration`: turning off auto-enabling integrations removes the cost and, with it, request error capture. The access log -([`litestar_logging_middleware_enabled`](../integrations/litestar.md#logging)) is the other large opt-in +([`logging_access_log_enabled`](../integrations/litestar.md#logging)) is the other large opt-in item, at about 70 µs per request in-process. ## FastStream diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index fb38f11..c55b6c3 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -2,7 +2,7 @@ import dataclasses import time import typing -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper @@ -63,10 +63,27 @@ class FastAPIConfig( application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) # Not on OpenTelemetryConfig: only the FastAPI and Litestar instrumentors take `exclude_spans`. opentelemetry_exclude_spans: list[typing.Literal["receive", "send"]] = dataclasses.field(default_factory=list) - prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - prometheus_instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - prometheus_expose_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - fastapi_logging_middleware_enabled: bool = False + prometheus_instrumentator_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + prometheus_expose_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + logging_access_log_enabled: bool = False + # Deprecated names, removed in 2.0. + _renamed_fields: typing.ClassVar[Mapping[str, str]] = { + "prometheus_instrumentator_params": "prometheus_instrumentator_additional_params", + "prometheus_instrument_params": "prometheus_instrument_additional_params", + "prometheus_expose_params": "prometheus_expose_additional_params", + "fastapi_logging_middleware_enabled": "logging_access_log_enabled", + } + prometheus_instrumentator_params: dict[str, typing.Any] | UnsetType = dataclasses.field( + default=UNSET, repr=False, compare=False + ) + prometheus_instrument_params: dict[str, typing.Any] | UnsetType = dataclasses.field( + default=UNSET, repr=False, compare=False + ) + prometheus_expose_params: dict[str, typing.Any] | UnsetType = dataclasses.field( + default=UNSET, repr=False, compare=False + ) + fastapi_logging_middleware_enabled: bool | UnsetType = dataclasses.field(default=UNSET, repr=False, compare=False) def __post_init__(self) -> None: # @dataclass(slots=True) replaces the class object, breaking bare super(). @@ -158,7 +175,7 @@ async def send_wrapper(message: "Message") -> None: class FastAPILoggingInstrument(LoggingInstrument[FastAPIConfig]): def bootstrap(self) -> None: super().bootstrap() - if not self.bootstrap_config.fastapi_logging_middleware_enabled: + if not self.bootstrap_config.logging_access_log_enabled: return self.bootstrap_config.app.add_middleware(_AccessLogMiddleware, excluded_paths=self._build_excluded_paths()) @@ -218,14 +235,14 @@ def dependencies_installed() -> bool: def bootstrap(self) -> None: config = self.bootstrap_config application = config.app - Instrumentator(**config.prometheus_instrumentator_params).instrument( + Instrumentator(**config.prometheus_instrumentator_additional_params).instrument( application, - **config.prometheus_instrument_params, + **config.prometheus_instrument_additional_params, ).expose( application, endpoint=config.prometheus_metrics_path, include_in_schema=config.prometheus_metrics_include_in_schema, - **config.prometheus_expose_params, + **config.prometheus_expose_additional_params, ) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index a71085d..bb26672 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -5,7 +5,7 @@ import re import time import typing -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper @@ -15,6 +15,7 @@ from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument +from lite_bootstrap.types import UNSET, UnsetType if import_checker.is_fastmcp_installed: @@ -193,10 +194,23 @@ class FastMcpConfig( HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig ): application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp) - fastmcp_logging_middleware_enabled: bool = False + logging_access_log_enabled: bool = False fastmcp_prometheus_tool_metrics_enabled: bool = True - prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - prometheus_instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + prometheus_instrumentator_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + # Deprecated names, removed in 2.0. + _renamed_fields: typing.ClassVar[Mapping[str, str]] = { + "fastmcp_logging_middleware_enabled": "logging_access_log_enabled", + "prometheus_instrumentator_params": "prometheus_instrumentator_additional_params", + "prometheus_instrument_params": "prometheus_instrument_additional_params", + } + fastmcp_logging_middleware_enabled: bool | UnsetType = dataclasses.field(default=UNSET, repr=False, compare=False) + prometheus_instrumentator_params: dict[str, typing.Any] | UnsetType = dataclasses.field( + default=UNSET, repr=False, compare=False + ) + prometheus_instrument_params: dict[str, typing.Any] | UnsetType = dataclasses.field( + default=UNSET, repr=False, compare=False + ) @dataclasses.dataclass(kw_only=True) @@ -279,19 +293,19 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St config: typing.Final = self.bootstrap_config instrumentator_params: typing.Final[dict[str, typing.Any]] = { "excluded_handlers": [f"^{re.escape(config.prometheus_metrics_path)}$"], - **config.prometheus_instrumentator_params, + **config.prometheus_instrumentator_additional_params, } # A second http_app() would register the default metrics again, which the instrumentator # treats as a duplicate and silently skips, so every application shares the first set. if self._default_metrics is None: default_metrics_params: typing.Final[dict[str, typing.Any]] = { name: value - for name, value in {**instrumentator_params, **config.prometheus_instrument_params}.items() + for name, value in {**instrumentator_params, **config.prometheus_instrument_additional_params}.items() if name in _DEFAULT_METRICS_PARAMETERS } self._default_metrics = instrumentator_metrics.default(**default_metrics_params) Instrumentator(**instrumentator_params).add(self._default_metrics).instrument( - http_application, **config.prometheus_instrument_params + http_application, **config.prometheus_instrument_additional_params ) return http_application @@ -323,7 +337,7 @@ def teardown(self) -> None: class FastMcpLoggingInstrument(LoggingInstrument[FastMcpConfig]): def bootstrap(self) -> None: super().bootstrap() - if not self.bootstrap_config.fastmcp_logging_middleware_enabled: + if not self.bootstrap_config.logging_access_log_enabled: return self.bootstrap_config.application.add_middleware(FastMcpLoggingMiddleware()) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index ec37235..bb71f42 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -2,6 +2,7 @@ import pathlib import re import typing +from collections.abc import Mapping from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper @@ -24,6 +25,7 @@ from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument +from lite_bootstrap.types import UNSET, UnsetType if import_checker.is_litestar_installed: @@ -124,20 +126,29 @@ class LitestarConfig( ): application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None - litestar_logging_middleware_enabled: bool = False + logging_access_log_enabled: bool = False opentelemetry_exclude_spans: list[typing.Literal["receive", "send"]] = dataclasses.field(default_factory=list) prometheus_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) # Bounds path-label cardinality (Litestar defaults False -> raw URLs leak memory). See litestar#4891. prometheus_group_path: bool = True - swagger_extra_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + swagger_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + # Deprecated names, removed in 2.0. + _renamed_fields: typing.ClassVar[Mapping[str, str]] = { + "litestar_logging_middleware_enabled": "logging_access_log_enabled", + "swagger_extra_params": "swagger_additional_params", + } + litestar_logging_middleware_enabled: bool | UnsetType = dataclasses.field(default=UNSET, repr=False, compare=False) + swagger_extra_params: dict[str, typing.Any] | UnsetType = dataclasses.field( + default=UNSET, repr=False, compare=False + ) def __post_init__(self) -> None: # @dataclass(slots=True) replaces the class object, breaking bare super(). super(LitestarConfig, self).__post_init__() - if self.litestar_logging_middleware_config is not None and not self.litestar_logging_middleware_enabled: + if self.litestar_logging_middleware_config is not None and not self.logging_access_log_enabled: warn_at_caller( - "litestar_logging_middleware_config is ignored while litestar_logging_middleware_enabled is False; " - "set litestar_logging_middleware_enabled=True to turn access logging on." + "litestar_logging_middleware_config is ignored while logging_access_log_enabled is False; " + "set logging_access_log_enabled=True to turn access logging on." ) @@ -212,7 +223,7 @@ def _configure_structlog_loggers(self) -> None: standard_lib_logging_config=self._build_standard_lib_logging_config(), ), # Litestar defaults this to True, which logs full request/response bodies. - enable_middleware_logging=self.bootstrap_config.litestar_logging_middleware_enabled, + enable_middleware_logging=self.bootstrap_config.logging_access_log_enabled, middleware_logging_config=self._build_logging_middleware_config(), ), ) @@ -315,7 +326,7 @@ def bootstrap(self) -> None: version=config.service_version, description=config.service_description, render_plugins=render_plugins, - **config.swagger_extra_params, + **config.swagger_additional_params, ) if config.swagger_offline_docs: static_dir_path = pathlib.Path(__file__).parent.parent / "static/litestar_docs" diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index 5d1623d..c9d4166 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -1,7 +1,10 @@ import dataclasses import typing +from collections.abc import Mapping +from lite_bootstrap.exceptions import ConfigurationError from lite_bootstrap.helpers.warn import warn_at_caller +from lite_bootstrap.types import UNSET, UnsetType @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) @@ -11,14 +14,37 @@ class BaseConfig: service_version: str = "1.0.0" service_environment: str | None = None service_debug: bool = True + # Deprecated field name -> the field that replaced it. The deprecated field defaults to UNSET. + _renamed_fields: typing.ClassVar[Mapping[str, str]] = {} def __post_init__(self) -> None: - """Terminate the MRO __post_init__ cascade safely. + """Terminate the MRO __post_init__ cascade safely, after forwarding deprecated field names. Subclasses call super().__post_init__() to propagate through multiple-inheritance chains (e.g. FastAPIConfig → CorsConfig → OpenTelemetryConfig → BaseConfig). - Without this no-op, the chain would raise AttributeError on object. + Without this, the chain would raise AttributeError on object. + + Running first in the cascade, the forwarding copies each deprecated field the caller set into + its replacement before any subclass reads it, then resets the deprecated field to UNSET so + `dataclasses.replace()` does not see both set. """ + for old_name, new_name in self._renamed_fields.items(): + old_value = getattr(self, old_name) + if isinstance(old_value, UnsetType): + continue + new_field = next(field for field in dataclasses.fields(self) if field.name == new_name) + new_default = ( + new_field.default if new_field.default_factory is dataclasses.MISSING else new_field.default_factory() + ) + if getattr(self, new_name) != new_default: + msg = f"{old_name} and {new_name} are both set; {old_name} is deprecated, set only {new_name}." + raise ConfigurationError(msg) + warn_at_caller( + f"{old_name} is deprecated and will be removed in lite-bootstrap 2.0; use {new_name} instead.", + category=DeprecationWarning, + ) + object.__setattr__(self, new_name, old_value) + object.__setattr__(self, old_name, UNSET) @classmethod def from_dict(cls, data: dict[str, typing.Any]) -> typing.Self: diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index ed5e32e..ebf75a6 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -343,7 +343,7 @@ def test_fastapi_access_log_is_off_by_default( def test_fastapi_access_log_records_the_request_when_enabled( fastapi_config: FastAPIConfig, access_logger: RecordingAccessLogger ) -> None: - config = dataclasses.replace(fastapi_config, fastapi_logging_middleware_enabled=True) + config = dataclasses.replace(fastapi_config, logging_access_log_enabled=True) with _bootstrapped_with_route(config) as application, TestClient(application) as test_client: test_client.post("/items/abc", json={"a": 1}) @@ -369,7 +369,7 @@ def test_fastapi_access_log_never_records_bodies( credentials into the log. Pinning the field set, not just one secret, is what stops a later change quietly adding headers, cookies or a body back. """ - config = dataclasses.replace(fastapi_config, fastapi_logging_middleware_enabled=True) + config = dataclasses.replace(fastapi_config, logging_access_log_enabled=True) secret = f"secret-{uuid.uuid4().hex}" with _bootstrapped_with_route(config) as application, TestClient(application) as test_client: response = test_client.post("/items/abc", json={"password": secret}) @@ -389,7 +389,7 @@ def test_fastapi_access_log_never_records_bodies( def test_fastapi_access_log_excludes_infrastructure_paths( fastapi_config: FastAPIConfig, access_logger: RecordingAccessLogger, path_attribute: str ) -> None: - config = dataclasses.replace(fastapi_config, fastapi_logging_middleware_enabled=True) + config = dataclasses.replace(fastapi_config, logging_access_log_enabled=True) with _bootstrapped_with_route(config) as application, TestClient(application) as test_client: test_client.get(getattr(config, path_attribute)) @@ -413,7 +413,7 @@ def test_fastapi_access_log_keeps_lookalike_paths( fastapi_config: FastAPIConfig, access_logger: RecordingAccessLogger ) -> None: """A route merely sharing a prefix with an excluded path is still logged.""" - config = dataclasses.replace(fastapi_config, fastapi_logging_middleware_enabled=True) + config = dataclasses.replace(fastapi_config, logging_access_log_enabled=True) lookalike_path = f"{config.health_checks_path.rstrip('/')}y" with _bootstrapped(config) as application: @@ -431,7 +431,7 @@ async def lookalike() -> str: def test_fastapi_access_log_records_a_raising_request( fastapi_config: FastAPIConfig, access_logger: RecordingAccessLogger ) -> None: - config = dataclasses.replace(fastapi_config, fastapi_logging_middleware_enabled=True) + config = dataclasses.replace(fastapi_config, logging_access_log_enabled=True) with _bootstrapped(config) as application: @application.get("/boom") diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index a348c4a..573f452 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -259,7 +259,7 @@ def test_fastmcp_logging_middleware_is_not_mounted_by_default() -> None: def test_fastmcp_logging_middleware_is_mounted_when_enabled() -> None: - config = _make_test_config(fastmcp_logging_middleware_enabled=True) + config = _make_test_config(logging_access_log_enabled=True) assert len(_mounted_logging_middleware(config)) == 1 @@ -273,7 +273,7 @@ def test_fastmcp_config_rejects_the_removed_turn_off_field() -> None: _make_test_config(logging_turn_off_middleware=True) -@pytest.mark.parametrize("overrides", [{}, {"fastmcp_logging_middleware_enabled": True}], ids=["default", "enabled"]) +@pytest.mark.parametrize("overrides", [{}, {"logging_access_log_enabled": True}], ids=["default", "enabled"]) def test_fastmcp_logging_config_warns_nothing(overrides: dict[str, bool]) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") @@ -537,7 +537,7 @@ def test_fastmcp_prometheus_instrumentator_params_are_passed() -> None: health_checks_path = f"/health-{uuid.uuid4().hex}/" config = _make_test_config( health_checks_path=health_checks_path, - prometheus_instrumentator_params={"excluded_handlers": [health_checks_path]}, + prometheus_instrumentator_additional_params={"excluded_handlers": [health_checks_path]}, ) bootstrapper = FastMcpBootstrapper(bootstrap_config=config) application = bootstrapper.bootstrap() diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 9d982cf..341e15d 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -324,7 +324,7 @@ async def _ping_handler() -> dict[str, bool]: config = dataclasses.replace( litestar_config, - litestar_logging_middleware_enabled=True, + logging_access_log_enabled=True, application_config=AppConfig(route_handlers=[_ping_handler]), ) application = LitestarBootstrapper(bootstrap_config=config).bootstrap() @@ -342,7 +342,7 @@ def test_litestar_access_logging_disabled_by_default(litestar_config: LitestarCo def test_litestar_access_logging_opt_in_emits_access_logs(litestar_config: LitestarConfig) -> None: - log_lines = _post_password(dataclasses.replace(litestar_config, litestar_logging_middleware_enabled=True)) + log_lines = _post_password(dataclasses.replace(litestar_config, logging_access_log_enabled=True)) events = [record["event"] for record in _access_log_records(log_lines)] assert "HTTP Request" in events @@ -350,7 +350,7 @@ def test_litestar_access_logging_opt_in_emits_access_logs(litestar_config: Lites def test_litestar_access_logging_logs_metadata_only(litestar_config: LitestarConfig) -> None: - log_lines = _post_password(dataclasses.replace(litestar_config, litestar_logging_middleware_enabled=True)) + log_lines = _post_password(dataclasses.replace(litestar_config, logging_access_log_enabled=True)) assert not any("hunter2" in log_line for log_line in log_lines) records = _access_log_records(log_lines) @@ -364,7 +364,7 @@ def test_litestar_access_logging_logs_metadata_only(litestar_config: LitestarCon def test_litestar_access_logging_excludes_infrastructure_paths(litestar_config: LitestarConfig) -> None: - config = dataclasses.replace(litestar_config, litestar_logging_middleware_enabled=True) + config = dataclasses.replace(litestar_config, logging_access_log_enabled=True) application = LitestarBootstrapper(bootstrap_config=config).bootstrap() with TestClient(app=application) as client, _recorded_litestar_logs() as log_lines: @@ -383,7 +383,7 @@ async def lookalike_handler() -> dict[str, str]: config = dataclasses.replace( litestar_config, - litestar_logging_middleware_enabled=True, + logging_access_log_enabled=True, application_config=AppConfig(route_handlers=[lookalike_handler]), ) application = LitestarBootstrapper(bootstrap_config=config).bootstrap() @@ -403,7 +403,7 @@ def test_litestar_access_logging_custom_config_replaces_defaults(litestar_config log_lines = _post_password( dataclasses.replace( litestar_config, - litestar_logging_middleware_enabled=True, + logging_access_log_enabled=True, litestar_logging_middleware_config=custom_config, ) ) @@ -415,7 +415,7 @@ def test_litestar_access_logging_custom_config_replaces_defaults(litestar_config def test_litestar_logging_middleware_config_without_flag_warns(litestar_config: LitestarConfig) -> None: - with pytest.warns(UserWarning, match="litestar_logging_middleware_enabled") as caught: + with pytest.warns(UserWarning, match="logging_access_log_enabled") as caught: dataclasses.replace(litestar_config, litestar_logging_middleware_config=LoggingMiddlewareConfig()) assert caught[0].filename == __file__ diff --git a/tests/test_renamed_config_fields.py b/tests/test_renamed_config_fields.py new file mode 100644 index 0000000..d74d1f5 --- /dev/null +++ b/tests/test_renamed_config_fields.py @@ -0,0 +1,78 @@ +import dataclasses +import types +import typing + +import pytest + +from lite_bootstrap import ConfigurationError, FastAPIConfig, FastMcpConfig, LitestarConfig +from lite_bootstrap.instruments.base import BaseConfig + + +PARAMS = {"key": "value"} +RENAMED_FIELDS = [ + (FastAPIConfig, "prometheus_instrumentator_params", "prometheus_instrumentator_additional_params", PARAMS), + (FastAPIConfig, "prometheus_instrument_params", "prometheus_instrument_additional_params", PARAMS), + (FastAPIConfig, "prometheus_expose_params", "prometheus_expose_additional_params", PARAMS), + (FastAPIConfig, "fastapi_logging_middleware_enabled", "logging_access_log_enabled", True), + (FastMcpConfig, "prometheus_instrumentator_params", "prometheus_instrumentator_additional_params", PARAMS), + (FastMcpConfig, "prometheus_instrument_params", "prometheus_instrument_additional_params", PARAMS), + (FastMcpConfig, "fastmcp_logging_middleware_enabled", "logging_access_log_enabled", True), + (LitestarConfig, "swagger_extra_params", "swagger_additional_params", PARAMS), + (LitestarConfig, "litestar_logging_middleware_enabled", "logging_access_log_enabled", True), +] + + +@pytest.mark.parametrize(("config_class", "old_name", "new_name", "value"), RENAMED_FIELDS) +def test_an_old_field_name_sets_the_new_field_and_warns( + config_class: type[BaseConfig], + old_name: str, + new_name: str, + value: object, +) -> None: + kwargs: dict[str, typing.Any] = {old_name: value} + with pytest.warns(DeprecationWarning, match=f"{old_name} is deprecated.*; use {new_name} instead") as record: + config = config_class(**kwargs) + + assert getattr(config, new_name) == value + assert record[0].filename == __file__ + + +@pytest.mark.parametrize(("config_class", "old_name", "new_name", "value"), RENAMED_FIELDS) +def test_setting_an_old_and_a_new_field_name_together_raises( + config_class: type[BaseConfig], + old_name: str, + new_name: str, + value: object, +) -> None: + kwargs: dict[str, typing.Any] = {old_name: value, new_name: value} + with pytest.raises(ConfigurationError, match=f"{old_name}.*{new_name}"): + config_class(**kwargs) + + +@pytest.mark.parametrize(("config_class", "old_name", "new_name", "value"), RENAMED_FIELDS) +def test_from_dict_accepts_an_old_field_name( + config_class: type[BaseConfig], + old_name: str, + new_name: str, + value: object, +) -> None: + with pytest.warns(DeprecationWarning, match=old_name): + config = config_class.from_dict({old_name: value}) + + assert getattr(config, new_name) == value + + +def test_from_object_with_an_old_and_a_new_field_name_raises() -> None: + settings = types.SimpleNamespace(swagger_extra_params=PARAMS, swagger_additional_params={"other": "value"}) + + with pytest.raises(ConfigurationError, match=r"swagger_extra_params.*swagger_additional_params"): + LitestarConfig.from_object(settings) + + +def test_a_config_built_from_an_old_field_name_can_be_replaced() -> None: + with pytest.warns(DeprecationWarning, match="swagger_extra_params"): + config = LitestarConfig(swagger_extra_params=PARAMS) + + replaced = dataclasses.replace(config, swagger_additional_params={"other": "value"}) + + assert replaced.swagger_additional_params == {"other": "value"}