Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 0 additions & 25 deletions lite_bootstrap/helpers/warn.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

_DATACLASS_GENERATED_INIT: typing.Final = "<string>"
_INTERNAL_MODULES: typing.Final = frozenset({__name__.split(".")[0], "dataclasses"})
NEXT_MAJOR_VERSION: typing.Final = "3.0"


def _is_internal_frame(frame: types.FrameType) -> bool:
Expand Down Expand Up @@ -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__
36 changes: 1 addition & 35 deletions lite_bootstrap/instruments/base.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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:
Expand All @@ -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:
Expand Down
46 changes: 0 additions & 46 deletions tests/test_deprecated_aliases.py

This file was deleted.

76 changes: 0 additions & 76 deletions tests/test_renamed_config_fields.py

This file was deleted.

Loading