diff --git a/AGENTS.md b/AGENTS.md index cf7db61..d667691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,9 @@ it names. its end of life ships in a minor, as 1.11.0 did for 3.10. Installers skip a release whose `requires-python` excludes the running interpreter, so no deployed service breaks. - **A rename keeps the old name working, with a `DeprecationWarning`, for at least one minor, and - the next major deletes it.** The warning names that major. A module-level name goes through - `deprecated_module_getattr` plus a `typing.TYPE_CHECKING` alias so type checkers still see it; a - config field goes through `BaseConfig._renamed_fields`. + the next major deletes it.** The warning names that major. See + [#350](https://github.com/modern-python/lite-bootstrap/issues/350) and + [#351](https://github.com/modern-python/lite-bootstrap/issues/351) for previous renames. - **Every breaking change gets a line in a "Breaking changes" section of the release notes**, as [1.12.0](https://github.com/modern-python/lite-bootstrap/releases/tag/1.12.0) has. diff --git a/lite_bootstrap/helpers/warn.py b/lite_bootstrap/helpers/warn.py index ca78654..4b15394 100644 --- a/lite_bootstrap/helpers/warn.py +++ b/lite_bootstrap/helpers/warn.py @@ -6,7 +6,6 @@ _DATACLASS_GENERATED_INIT: typing.Final = "" _INTERNAL_MODULES: typing.Final = frozenset({__name__.split(".")[0], "dataclasses"}) -NEXT_MAJOR_VERSION: typing.Final = "3.0" def _is_internal_frame(frame: types.FrameType) -> bool: @@ -35,27 +34,3 @@ 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 {NEXT_MAJOR_VERSION}; {advice}.", - category=DeprecationWarning, - ) - return target - - return __getattr__ diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index 62fb2ed..86f8e92 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -1,10 +1,5 @@ import dataclasses import typing -from collections.abc import Mapping - -from lite_bootstrap.exceptions import ConfigurationError -from lite_bootstrap.helpers.warn import NEXT_MAJOR_VERSION, warn_at_caller -from lite_bootstrap.types import UNSET, UnsetType _DEFAULT_SERVICE_NAME: typing.Final = "micro-service" @@ -18,8 +13,6 @@ class BaseConfig: service_version: str | None = None service_environment: str | None = None service_debug: bool = False - # Deprecated field name -> the field that replaced it. The deprecated field defaults to UNSET. - _renamed_fields: typing.ClassVar[Mapping[str, str]] = {} @property def service_name_or_default(self) -> str: @@ -30,34 +23,7 @@ def service_version_or_default(self) -> str: return self.service_version or _DEFAULT_SERVICE_VERSION def __post_init__(self) -> None: - """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, 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 {NEXT_MAJOR_VERSION}; " - f"use {new_name} instead.", - category=DeprecationWarning, - ) - object.__setattr__(self, new_name, old_value) - object.__setattr__(self, old_name, UNSET) + """Terminate the multiple-inheritance __post_init__ cascade for subclasses calling super().""" @classmethod def from_dict(cls, data: dict[str, typing.Any]) -> typing.Self: diff --git a/tests/test_deprecated_aliases.py b/tests/test_deprecated_aliases.py deleted file mode 100644 index 3508e58..0000000 --- a/tests/test_deprecated_aliases.py +++ /dev/null @@ -1,46 +0,0 @@ -import types - -import pytest - -import lite_bootstrap -from lite_bootstrap.bootstrappers import free_bootstrapper -from lite_bootstrap.helpers.warn import deprecated_module_getattr -from lite_bootstrap.instruments import logging_factory, logging_instrument, opentelemetry_instrument, sentry_instrument - - -REMOVED_PROTOCOLS = ["AddressProtocol", "RequestProtocol", "ScopeType"] -NAMES_REMOVED_IN_2_0 = [ - (opentelemetry_instrument, "OpentelemetryConfig"), - (free_bootstrapper, "FreeBootstrapperConfig"), - (lite_bootstrap, "FreeBootstrapperConfig"), - (sentry_instrument, "IGNORED_STRUCTLOG_ATTRIBUTES"), - *[(module, name) for module in (logging_factory, logging_instrument) for name in REMOVED_PROTOCOLS], -] -TARGET = object() -module_getattr = deprecated_module_getattr("some.module", {"OldName": (TARGET, "NewName"), "GoneName": (TARGET, None)}) - - -@pytest.mark.parametrize(("module", "name"), NAMES_REMOVED_IN_2_0) -def test_a_name_deprecated_in_1_x_is_gone(module: types.ModuleType, name: str) -> None: - with pytest.raises(AttributeError, match=name): - getattr(module, name) - - -def test_a_deprecated_alias_warns_and_still_resolves() -> None: - with pytest.warns( - DeprecationWarning, match=r"OldName is deprecated.* lite-bootstrap 3\.0; use NewName instead" - ) as record: - resolved = module_getattr("OldName") - - assert resolved is TARGET - assert record[0].filename == __file__ - - -def test_a_deprecated_alias_without_a_replacement_says_so() -> None: - with pytest.warns(DeprecationWarning, match="GoneName is deprecated.*; nothing replaces it"): - assert module_getattr("GoneName") is TARGET - - -def test_an_unknown_attribute_still_raises_attribute_error() -> None: - with pytest.raises(AttributeError, match=r"module 'some\.module' has no attribute 'no_such_name'"): - module_getattr("no_such_name") diff --git a/tests/test_renamed_config_fields.py b/tests/test_renamed_config_fields.py deleted file mode 100644 index d81181c..0000000 --- a/tests/test_renamed_config_fields.py +++ /dev/null @@ -1,76 +0,0 @@ -import dataclasses -import types -import typing -from collections.abc import Mapping - -import pytest - -from lite_bootstrap import ConfigurationError, FastAPIConfig, FastMcpConfig, LitestarConfig -from lite_bootstrap.instruments.base import BaseConfig -from lite_bootstrap.types import UNSET, UnsetType - - -PARAMS = {"key": "value"} -FIELDS_REMOVED_IN_2_0 = [ - (FastAPIConfig, "prometheus_instrumentator_params"), - (FastAPIConfig, "prometheus_instrument_params"), - (FastAPIConfig, "prometheus_expose_params"), - (FastAPIConfig, "fastapi_logging_middleware_enabled"), - (FastMcpConfig, "prometheus_instrumentator_params"), - (FastMcpConfig, "prometheus_instrument_params"), - (FastMcpConfig, "fastmcp_logging_middleware_enabled"), - (LitestarConfig, "swagger_extra_params"), - (LitestarConfig, "litestar_logging_middleware_enabled"), -] - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class RenamingConfig(BaseConfig): - new_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - _renamed_fields: typing.ClassVar[Mapping[str, str]] = {"old_params": "new_params"} - old_params: dict[str, typing.Any] | UnsetType = dataclasses.field(default=UNSET, repr=False, compare=False) - - -@pytest.mark.parametrize(("config_class", "old_name"), FIELDS_REMOVED_IN_2_0) -def test_a_field_name_deprecated_in_1_x_is_rejected(config_class: type[BaseConfig], old_name: str) -> None: - kwargs: dict[str, typing.Any] = {old_name: PARAMS} - with pytest.raises(TypeError, match=old_name): - config_class(**kwargs) - - -def test_an_old_field_name_sets_the_new_field_and_warns() -> None: - with pytest.warns( - DeprecationWarning, match=r"old_params is deprecated.* lite-bootstrap 3\.0; use new_params instead" - ) as record: - config = RenamingConfig(old_params=PARAMS) - - assert config.new_params == PARAMS - assert record[0].filename == __file__ - - -def test_setting_an_old_and_a_new_field_name_together_raises() -> None: - with pytest.raises(ConfigurationError, match=r"old_params.*new_params"): - RenamingConfig(old_params=PARAMS, new_params=PARAMS) - - -def test_from_dict_accepts_an_old_field_name() -> None: - with pytest.warns(DeprecationWarning, match="old_params"): - config = RenamingConfig.from_dict({"old_params": PARAMS}) - - assert config.new_params == PARAMS - - -def test_from_object_with_an_old_and_a_new_field_name_raises() -> None: - settings = types.SimpleNamespace(old_params=PARAMS, new_params={"other": "value"}) - - with pytest.raises(ConfigurationError, match=r"old_params.*new_params"): - RenamingConfig.from_object(settings) - - -def test_a_config_built_from_an_old_field_name_can_be_replaced() -> None: - with pytest.warns(DeprecationWarning, match="old_params"): - config = RenamingConfig(old_params=PARAMS) - - replaced = dataclasses.replace(config, new_params={"other": "value"}) - - assert replaced.new_params == {"other": "value"}