From 44a79f5bcda8a93bd6e60ce2e1c5a7e5b6093553 Mon Sep 17 00:00:00 2001 From: milo Date: Sun, 11 Oct 2026 03:14:11 -0400 Subject: [PATCH] feat(common): add deprecated decorator (#300) Add pyaml.common.deprecation.deprecated, which emits a DeprecationWarning naming the deprecated function, method or class, with an optional custom message. Works on functions, methods and classes; keeps metadata via functools.wraps. Tests in tests/common/test_deprecation.py (6 passing). Full suite: 459 passed; test_tuning_orm fails on base too (Windows temp-file PermissionError). --- pyaml/common/deprecation.py | 64 ++++++++++++++++++++++++++++++++ tests/common/test_deprecation.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 pyaml/common/deprecation.py create mode 100644 tests/common/test_deprecation.py diff --git a/pyaml/common/deprecation.py b/pyaml/common/deprecation.py new file mode 100644 index 00000000..46b4475a --- /dev/null +++ b/pyaml/common/deprecation.py @@ -0,0 +1,64 @@ +""" +PyAML deprecation helper. + +Provides the ``deprecated`` decorator, used to mark functions, methods and +classes that are being phased out. Using a decorated object emits a +``DeprecationWarning`` pointing at the caller. +""" + +import functools +import warnings +from typing import Any, Callable, TypeVar + +T = TypeVar("T") + + +def deprecated(message: str | None = None) -> Callable[[T], T]: + """ + Mark a function, method or class as deprecated. + + Each use emits a ``DeprecationWarning`` that names the deprecated object + and, if given, the custom ``message``. + + Parameters + ---------- + message : str, optional + Extra guidance appended to the warning, e.g. the replacement to use. + + Returns + ------- + Callable + A decorator that returns the wrapped object unchanged in behaviour. + + Examples + -------- + >>> @deprecated("use new_api_function instead") + ... def old_api_function(): + ... return 1 + """ + + def decorator(obj: T) -> T: + name = getattr(obj, "__qualname__", getattr(obj, "__name__", repr(obj))) + text = f"{name} is deprecated" + if message: + text = f"{text}: {message}" + + if isinstance(obj, type): + original_init: Any = obj.__init__ + + @functools.wraps(original_init) + def init_wrapper(self, *args, **kwargs): + warnings.warn(text, DeprecationWarning, stacklevel=2) + return original_init(self, *args, **kwargs) + + obj.__init__ = init_wrapper # type: ignore[misc] + return obj + + @functools.wraps(obj) # type: ignore[arg-type] + def wrapper(*args: Any, **kwargs: Any) -> Any: + warnings.warn(text, DeprecationWarning, stacklevel=2) + return obj(*args, **kwargs) # type: ignore[operator] + + return wrapper # type: ignore[return-value] + + return decorator diff --git a/tests/common/test_deprecation.py b/tests/common/test_deprecation.py new file mode 100644 index 00000000..76aec759 --- /dev/null +++ b/tests/common/test_deprecation.py @@ -0,0 +1,64 @@ +import warnings + +import pytest + +from pyaml.common.deprecation import deprecated + + +def test_function_warns_and_returns_value(): + @deprecated() + def old_func(x): + return x * 2 + + with pytest.warns(DeprecationWarning, match="old_func is deprecated"): + assert old_func(3) == 6 + + +def test_custom_message_is_included(): + @deprecated("use new_func instead") + def old_func(): + return 1 + + with pytest.warns(DeprecationWarning, match="use new_func instead"): + old_func() + + +def test_method_warns_with_qualified_name(): + class Thing: + @deprecated("use other()") + def old(self): + return "ok" + + with pytest.warns(DeprecationWarning, match="Thing.old is deprecated: use other"): + assert Thing().old() == "ok" + + +def test_class_warns_on_instantiation(): + @deprecated("use NewThing") + class OldThing: + def __init__(self, value): + self.value = value + + with pytest.warns(DeprecationWarning, match="OldThing is deprecated: use NewThing"): + obj = OldThing(5) + assert obj.value == 5 + + +def test_warning_points_at_caller(): + @deprecated() + def old_func(): + return None + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + old_func() + assert record[0].filename == __file__ + + +def test_wrapped_function_keeps_metadata(): + @deprecated() + def documented(): + """Docstring kept.""" + + assert documented.__name__ == "documented" + assert documented.__doc__ == "Docstring kept."