From 9d52b76ff801e042cc51fccc19e01ed65ce9669a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 28 Oct 2026 17:14:13 +0300 Subject: [PATCH] feat: warn on deprecated aliases ahead of their removal in 2.0 --- CONTEXT.md | 3 +- lite_bootstrap/__init__.py | 13 +++- .../bootstrappers/free_bootstrapper.py | 7 ++- lite_bootstrap/helpers/warn.py | 24 +++++++ lite_bootstrap/instruments/base.py | 7 +++ lite_bootstrap/instruments/logging_factory.py | 26 ++++++-- .../instruments/logging_instrument.py | 19 ++++-- .../instruments/opentelemetry_instrument.py | 8 ++- .../instruments/sentry_instrument.py | 16 +++-- tests/conftest.py | 11 +++- tests/instruments/test_base_instrument.py | 26 ++++++-- tests/test_deprecated_aliases.py | 62 +++++++++++++++++++ 12 files changed, 188 insertions(+), 34 deletions(-) create mode 100644 tests/test_deprecated_aliases.py diff --git a/CONTEXT.md b/CONTEXT.md index b71963a..24e5fa8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,7 +38,8 @@ A *bootstrapper* is ready when its framework package is importable: `is_ready()` the config, and it is a different question from whether an instrument is **configured**. _Avoid_: configured, for this sense. The attribute names follow the split: a bootstrapper carries `not_ready_message`, an instrument carries `not_configured_reason`. An instrument written against -the old `not_ready_message` still works because `BaseInstrument.__init_subclass__` forwards it. +the old `not_ready_message` still works until 2.0: `BaseInstrument.__init_subclass__` forwards it +and emits a `DeprecationWarning`. **Skipped**: An instrument the bootstrapper decided not to instantiate. Two paths, deliberately different diff --git a/lite_bootstrap/__init__.py b/lite_bootstrap/__init__.py index 7dfa43e..5f69aee 100644 --- a/lite_bootstrap/__init__.py +++ b/lite_bootstrap/__init__.py @@ -1,7 +1,9 @@ +import typing + from lite_bootstrap.bootstrappers.fastapi_bootstrapper import FastAPIBootstrapper, FastAPIConfig from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpBootstrapper, FastMcpConfig from lite_bootstrap.bootstrappers.faststream_bootstrapper import FastStreamBootstrapper, FastStreamConfig -from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig, FreeConfig +from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeConfig from lite_bootstrap.bootstrappers.litestar_bootstrapper import LitestarBootstrapper, LitestarConfig from lite_bootstrap.exceptions import ( BootstrapperNotReadyError, @@ -11,9 +13,14 @@ LiteBootstrapError, TeardownError, ) +from lite_bootstrap.helpers.warn import deprecated_module_getattr from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument +if typing.TYPE_CHECKING: + from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapperConfig as FreeBootstrapperConfig + + __all__ = [ "BootstrapperNotReadyError", "ConfigurationError", @@ -24,7 +31,6 @@ "FastStreamBootstrapper", "FastStreamConfig", "FreeBootstrapper", - "FreeBootstrapperConfig", "FreeConfig", "InstrumentDependencyMissingWarning", "InstrumentSkippedWarning", @@ -35,3 +41,6 @@ "PyroscopeInstrument", "TeardownError", ] + + +__getattr__ = deprecated_module_getattr(__name__, {"FreeBootstrapperConfig": (FreeConfig, "FreeConfig")}) diff --git a/lite_bootstrap/bootstrappers/free_bootstrapper.py b/lite_bootstrap/bootstrappers/free_bootstrapper.py index 4e76bbe..302ae16 100644 --- a/lite_bootstrap/bootstrappers/free_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/free_bootstrapper.py @@ -2,6 +2,7 @@ import typing from lite_bootstrap.bootstrappers.base import BaseBootstrapper +from lite_bootstrap.helpers.warn import deprecated_module_getattr from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument @@ -34,5 +35,7 @@ def _prepare_application(self) -> None: return None -# Backward-compatible alias preserved for users importing the old name. -FreeBootstrapperConfig = FreeConfig +if typing.TYPE_CHECKING: + FreeBootstrapperConfig = FreeConfig + +__getattr__ = deprecated_module_getattr(__name__, {"FreeBootstrapperConfig": (FreeConfig, "FreeConfig")}) diff --git a/lite_bootstrap/helpers/warn.py b/lite_bootstrap/helpers/warn.py index 4b15394..76fc2c7 100644 --- a/lite_bootstrap/helpers/warn.py +++ b/lite_bootstrap/helpers/warn.py @@ -34,3 +34,27 @@ def warn_at_caller(message: str, category: type[Warning] = UserWarning) -> None: stacklevel += 1 frame = frame.f_back warnings.warn(message, category=category, stacklevel=stacklevel) + + +def deprecated_module_getattr( + module_name: str, + aliases: typing.Mapping[str, tuple[object, str | None]], +) -> typing.Callable[[str], object]: + """Build a module `__getattr__` that resolves each old name to its target with a `DeprecationWarning`. + + `aliases` maps an old name to its target and the name that replaces it, or None if nothing does. + """ + + def __getattr__(name: str) -> object: # noqa: N807 + if name not in aliases: + msg = f"module {module_name!r} has no attribute {name!r}" + raise AttributeError(msg) + target, replacement = aliases[name] + advice = f"use {replacement} instead" if replacement else "nothing replaces it" + warn_at_caller( + f"{name} is deprecated and will be removed in lite-bootstrap 2.0; {advice}.", + category=DeprecationWarning, + ) + return target + + return __getattr__ diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index 8b151ec..5d1623d 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -1,6 +1,8 @@ import dataclasses import typing +from lite_bootstrap.helpers.warn import warn_at_caller + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class BaseConfig: @@ -53,6 +55,11 @@ def __init_subclass__(cls, **kwargs: object) -> None: super(BaseInstrument, cls).__init_subclass__(**kwargs) for legacy_name, current_name in _RENAMED_ATTRIBUTES.items(): if legacy_name in cls.__dict__ and current_name not in cls.__dict__: + warn_at_caller( + f"{cls.__name__}.{legacy_name} is deprecated and will be removed in lite-bootstrap 2.0; " + f"use {current_name} instead.", + category=DeprecationWarning, + ) setattr(cls, current_name, cls.__dict__[legacy_name]) def bootstrap(self) -> None: ... diff --git a/lite_bootstrap/instruments/logging_factory.py b/lite_bootstrap/instruments/logging_factory.py index ba293d6..85dc161 100644 --- a/lite_bootstrap/instruments/logging_factory.py +++ b/lite_bootstrap/instruments/logging_factory.py @@ -6,26 +6,39 @@ import typing from lite_bootstrap import import_checker +from lite_bootstrap.helpers.warn import deprecated_module_getattr if import_checker.is_orjson_installed: import orjson -ScopeType = typing.MutableMapping[str, typing.Any] +_ScopeType = typing.MutableMapping[str, typing.Any] -class AddressProtocol(typing.Protocol): +class _AddressProtocol(typing.Protocol): host: str port: int -class RequestProtocol(typing.Protocol): - client: AddressProtocol - scope: ScopeType +class _RequestProtocol(typing.Protocol): + client: _AddressProtocol + scope: _ScopeType method: str +if typing.TYPE_CHECKING: + ScopeType = _ScopeType + AddressProtocol = _AddressProtocol + RequestProtocol = _RequestProtocol + +_DEPRECATED_PROTOCOLS: typing.Final = { + "AddressProtocol": (_AddressProtocol, None), + "RequestProtocol": (_RequestProtocol, None), + "ScopeType": (_ScopeType, None), +} + + @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class _MemoryLoggerFactoryConfig: logging_buffer_capacity: int @@ -141,3 +154,6 @@ def close_handlers(self) -> None: if target is not None: target.close() self._created_handlers.clear() + + +__getattr__ = deprecated_module_getattr(__name__, _DEPRECATED_PROTOCOLS) diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index 24c3b1d..f54a0fc 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -5,12 +5,14 @@ from lite_bootstrap import import_checker from lite_bootstrap.exceptions import collect_teardown_errors +from lite_bootstrap.helpers.warn import deprecated_module_getattr from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument from lite_bootstrap.instruments.logging_factory import ( - AddressProtocol, - RequestProtocol, - ScopeType, + _DEPRECATED_PROTOCOLS, + _AddressProtocol, _MemoryLoggerFactoryConfig, + _RequestProtocol, + _ScopeType, _serialize_log_to_string, ) @@ -54,12 +56,9 @@ def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "E __all__ = [ - "AddressProtocol", "LoggingConfig", "LoggingInstrument", "MemoryLoggerFactory", - "RequestProtocol", - "ScopeType", "tracer_injection", ] @@ -240,3 +239,11 @@ def teardown(self) -> None: self._logger_factory.close_handlers() finally: self._logger_factory = None + + +if typing.TYPE_CHECKING: + AddressProtocol = _AddressProtocol + RequestProtocol = _RequestProtocol + ScopeType = _ScopeType + +__getattr__ = deprecated_module_getattr(__name__, _DEPRECATED_PROTOCOLS) diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index c19348e..bc18f23 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -7,7 +7,7 @@ from lite_bootstrap import import_checker from lite_bootstrap.exceptions import InstrumentDependencyMissingWarning, collect_teardown_errors -from lite_bootstrap.helpers.warn import warn_at_caller +from lite_bootstrap.helpers.warn import deprecated_module_getattr, warn_at_caller from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -372,5 +372,7 @@ def teardown(self) -> None: self._meter_provider = None -# Backward-compatible alias preserved for users importing the old (lowercase t) spelling. -OpentelemetryConfig = OpenTelemetryConfig +if typing.TYPE_CHECKING: + OpentelemetryConfig = OpenTelemetryConfig + +__getattr__ = deprecated_module_getattr(__name__, {"OpentelemetryConfig": (OpenTelemetryConfig, "OpenTelemetryConfig")}) diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 8f0e19e..af350c7 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -4,7 +4,7 @@ import typing from lite_bootstrap import import_checker -from lite_bootstrap.helpers.warn import warn_at_caller +from lite_bootstrap.helpers.warn import deprecated_module_getattr, warn_at_caller from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument from lite_bootstrap.instruments.logging_factory import STRUCTLOG_META_KEYS, StructuredLogPayload @@ -25,11 +25,6 @@ ) -# Back-compat alias: this vocabulary moved to logging_factory and was renamed -# STRUCTLOG_META_KEYS. Preserved here for external importers of the old name. -IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = STRUCTLOG_META_KEYS - - @dataclasses.dataclass(kw_only=True, frozen=True) class SentryConfig(BaseConfig): sentry_dsn: str | None = None @@ -158,3 +153,12 @@ def teardown(self) -> None: sentry_sdk.flush(timeout=2) finally: sentry_sdk.init() + + +if typing.TYPE_CHECKING: + IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = STRUCTLOG_META_KEYS + +__getattr__ = deprecated_module_getattr( + __name__, + {"IGNORED_STRUCTLOG_ATTRIBUTES": (STRUCTLOG_META_KEYS, "STRUCTLOG_META_KEYS")}, +) diff --git a/tests/conftest.py b/tests/conftest.py index 4682a26..989b116 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -137,6 +137,11 @@ def emulate_package_missing(package_name: str) -> Generator[None]: reload(import_checker) +def _is_module_state(key: str) -> bool: + # A module __getattr__ holds the module's own objects, so it is restored along with them. + return not key.startswith("__") or key == "__getattr__" + + @contextlib.contextmanager def emulate_package_missing_with_module_reload( package_name: str, module_names: typing.Iterable[str] @@ -156,7 +161,7 @@ def _wipe_and_reload() -> None: for name in module_names: if name in sys.modules: mod_dict = sys.modules[name].__dict__ - for key in [k for k in mod_dict if not k.startswith("__")]: + for key in [k for k in mod_dict if _is_module_state(k)]: del mod_dict[key] reload(sys.modules[name]) @@ -168,6 +173,6 @@ def _wipe_and_reload() -> None: for name, snap in snapshots.items(): if name in sys.modules: mod_dict = sys.modules[name].__dict__ - for key in [k for k in mod_dict if not k.startswith("__")]: + for key in [k for k in mod_dict if _is_module_state(k)]: del mod_dict[key] - mod_dict.update({k: v for k, v in snap.items() if not k.startswith("__")}) + mod_dict.update({k: v for k, v in snap.items() if _is_module_state(k)}) diff --git a/tests/instruments/test_base_instrument.py b/tests/instruments/test_base_instrument.py index e8dfac1..5a62417 100644 --- a/tests/instruments/test_base_instrument.py +++ b/tests/instruments/test_base_instrument.py @@ -1,5 +1,7 @@ import dataclasses +import pytest + from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -20,17 +22,29 @@ def test_an_instrument_written_against_the_old_attribute_names_still_works() -> `dependencies_installed`, which answers True, and construct an instrument whose optional package is absent. """ + with pytest.warns(DeprecationWarning, match="is deprecated") as record: - @dataclasses.dataclass(kw_only=True, slots=True) - class LegacyInstrument(BaseInstrument[BaseConfig]): - not_ready_message = "legacy reason" + @dataclasses.dataclass(kw_only=True, slots=True) + class LegacyInstrument(BaseInstrument[BaseConfig]): + not_ready_message = "legacy reason" - @staticmethod - def check_dependencies() -> bool: - return False + @staticmethod + def check_dependencies() -> bool: + return False assert LegacyInstrument.not_configured_reason == "legacy reason" assert LegacyInstrument.dependencies_installed() is False + assert [str(warning.message) for warning in record] == [ + ( + "LegacyInstrument.not_ready_message is deprecated and will be removed in lite-bootstrap 2.0; " + "use not_configured_reason instead." + ), + ( + "LegacyInstrument.check_dependencies is deprecated and will be removed in lite-bootstrap 2.0; " + "use dependencies_installed instead." + ), + ] + assert {warning.filename for warning in record} == {__file__} def test_a_subclass_declaring_both_names_keeps_the_current_one() -> None: diff --git a/tests/test_deprecated_aliases.py b/tests/test_deprecated_aliases.py new file mode 100644 index 0000000..1cf59dc --- /dev/null +++ b/tests/test_deprecated_aliases.py @@ -0,0 +1,62 @@ +import types +import warnings + +import pytest + +import lite_bootstrap +from lite_bootstrap.bootstrappers import free_bootstrapper +from lite_bootstrap.instruments import logging_factory, logging_instrument, opentelemetry_instrument, sentry_instrument + + +# Tests elsewhere reload instrument modules, so a replacement is looked up when the test runs. +DEPRECATED_ALIASES = [ + (opentelemetry_instrument, "OpentelemetryConfig", opentelemetry_instrument, "OpenTelemetryConfig"), + (free_bootstrapper, "FreeBootstrapperConfig", free_bootstrapper, "FreeConfig"), + (lite_bootstrap, "FreeBootstrapperConfig", free_bootstrapper, "FreeConfig"), + (sentry_instrument, "IGNORED_STRUCTLOG_ATTRIBUTES", logging_factory, "STRUCTLOG_META_KEYS"), +] +DEPRECATED_PROTOCOLS = ["AddressProtocol", "RequestProtocol", "ScopeType"] +MODULES_WITH_DEPRECATIONS = [ + lite_bootstrap, + free_bootstrapper, + logging_factory, + logging_instrument, + opentelemetry_instrument, + sentry_instrument, +] + + +@pytest.mark.parametrize(("module", "name", "replacement_module", "replacement"), DEPRECATED_ALIASES) +def test_a_deprecated_alias_warns_and_still_resolves( + module: types.ModuleType, + name: str, + replacement_module: types.ModuleType, + replacement: str, +) -> None: + with pytest.warns(DeprecationWarning, match=f"{name} is deprecated.*; use {replacement} instead") as record: + resolved = getattr(module, name) + + assert resolved is getattr(replacement_module, replacement) + assert record[0].filename == __file__ + + +@pytest.mark.parametrize("module", [logging_factory, logging_instrument]) +@pytest.mark.parametrize("name", DEPRECATED_PROTOCOLS) +def test_an_unused_logging_protocol_warns_and_still_resolves(module: types.ModuleType, name: str) -> None: + with pytest.warns(DeprecationWarning, match=f"{name} is deprecated.*; nothing replaces it"): + resolved = getattr(module, name) + + assert resolved is not None + + +@pytest.mark.parametrize("module", MODULES_WITH_DEPRECATIONS) +def test_an_unknown_attribute_still_raises_attribute_error(module: types.ModuleType) -> None: + with pytest.raises(AttributeError, match="no_such_name"): + _ = module.no_such_name + + +@pytest.mark.parametrize("module", MODULES_WITH_DEPRECATIONS) +def test_a_star_import_does_not_warn(module: types.ModuleType) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + exec(f"from {module.__name__} import *", {}) # noqa: S102