From 44a79f5bcda8a93bd6e60ce2e1c5a7e5b6093553 Mon Sep 17 00:00:00 2001 From: milo Date: Sun, 11 Oct 2026 03:14:11 -0400 Subject: [PATCH 1/2] 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." From b032d1c694f99d4c5a7543da37b60b3324d074aa Mon Sep 17 00:00:00 2001 From: milo Date: Sat, 17 Oct 2026 11:31:07 -0400 Subject: [PATCH 2/2] refactor(common): re-export typing_extensions.deprecated per PEP 702 Address review feedback (GamelinAl): replace the hand-rolled deprecated decorator with typing_extensions.deprecated. The custom wrapper returned a plain function, so composing it above @classmethod/@property broke descriptor binding; it patched __init__ only (no __new__, no warning on subclass creation) and provided no type-checker call-site flagging. typing_extensions.deprecated handles all of these and is available on Python >= 3.11 via typing_extensions >= 4.5, now declared as a direct dependency. Tests: keep function/class/custom-message/caller-location coverage and add composition tests for @classmethod, @staticmethod and @property in both stacking orders, matching typing_extensions' actual behavior. --- pyaml/common/deprecation.py | 72 +++++------------------ pyproject.toml | 3 +- tests/common/test_deprecation.py | 98 ++++++++++++++++++++++++++++---- 3 files changed, 103 insertions(+), 70 deletions(-) diff --git a/pyaml/common/deprecation.py b/pyaml/common/deprecation.py index 46b4475a..47961c01 100644 --- a/pyaml/common/deprecation.py +++ b/pyaml/common/deprecation.py @@ -1,64 +1,20 @@ """ 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. +Thin re-export of :func:`typing_extensions.deprecated` (PEP 702), used to +mark functions, methods and classes that are being phased out. + +The standard tool is used instead of a hand-rolled decorator because it +preserves descriptor binding when composed with ``@classmethod``, +``@staticmethod`` and ``@property``, warns on subclass creation as well as +instantiation, sets the ``__deprecated__`` attribute, and lets static type +checkers (mypy/pyright) flag call sites. + +``typing_extensions`` is used rather than ``typing`` so the helper is also +available on Python < 3.13; the project therefore declares a direct +dependency on ``typing_extensions >= 4.5``. """ -import functools -import warnings -from typing import Any, Callable, TypeVar +from typing_extensions import deprecated -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 +__all__ = ["deprecated"] diff --git a/pyproject.toml b/pyproject.toml index 51ebb354..7994adca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,8 @@ dependencies = [ "pydantic>=2.11.7", "accelerator-commissioning==1.5.3", #pySC "h5py", - "matplotlib" + "matplotlib", + "typing_extensions>=4.5" ] [project.optional-dependencies] diff --git a/tests/common/test_deprecation.py b/tests/common/test_deprecation.py index 76aec759..ae9e5b6c 100644 --- a/tests/common/test_deprecation.py +++ b/tests/common/test_deprecation.py @@ -6,11 +6,11 @@ def test_function_warns_and_returns_value(): - @deprecated() + @deprecated("old_func is deprecated") def old_func(x): return x * 2 - with pytest.warns(DeprecationWarning, match="old_func is deprecated"): + with pytest.warns(DeprecationWarning, match=r"^old_func is deprecated$"): assert old_func(3) == 6 @@ -19,33 +19,38 @@ def test_custom_message_is_included(): def old_func(): return 1 - with pytest.warns(DeprecationWarning, match="use new_func instead"): + with pytest.warns(DeprecationWarning, match=r"^use new_func instead$"): old_func() -def test_method_warns_with_qualified_name(): +def test_method_warns_with_given_message(): class Thing: - @deprecated("use other()") + @deprecated("Thing.old is deprecated: use other()") def old(self): return "ok" - with pytest.warns(DeprecationWarning, match="Thing.old is deprecated: use other"): + with pytest.warns(DeprecationWarning, match=r"^Thing\.old is deprecated: use other\(\)$"): assert Thing().old() == "ok" -def test_class_warns_on_instantiation(): - @deprecated("use NewThing") +def test_class_warns_on_instantiation_and_subclassing(): + @deprecated("OldThing is deprecated: use NewThing") class OldThing: def __init__(self, value): self.value = value - with pytest.warns(DeprecationWarning, match="OldThing is deprecated: use NewThing"): + with pytest.warns(DeprecationWarning, match=r"^OldThing is deprecated: use NewThing$"): obj = OldThing(5) assert obj.value == 5 + with pytest.warns(DeprecationWarning, match=r"^OldThing is deprecated: use NewThing$"): + + class SubThing(OldThing): + pass + def test_warning_points_at_caller(): - @deprecated() + @deprecated("old_func is deprecated") def old_func(): return None @@ -56,9 +61,80 @@ def old_func(): def test_wrapped_function_keeps_metadata(): - @deprecated() + @deprecated("documented is deprecated") def documented(): """Docstring kept.""" assert documented.__name__ == "documented" assert documented.__doc__ == "Docstring kept." + assert documented.__deprecated__ == "documented is deprecated" # noqa: B009 + + +def test_classmethod_over_deprecated_binds_and_warns(): + class Thing: + @classmethod + @deprecated("Thing.create is deprecated") + def create(cls): + return cls.__name__ + + with pytest.warns(DeprecationWarning, match=r"^Thing\.create is deprecated$"): + assert Thing.create() == "Thing" + + +def test_deprecated_over_classmethod_fails_at_definition(): + # typing_extensions.deprecated accepts classes and callables only; a + # classmethod descriptor object is neither, so this stacking order is + # rejected at class-definition time instead of silently losing binding. + with pytest.raises(TypeError, match="must be applied to a class or callable"): + + class Thing: + @deprecated("Thing.create is deprecated") + @classmethod + def create(cls): + return cls.__name__ + + +def test_staticmethod_composes_in_both_orders(): + class Thing: + @staticmethod + @deprecated("Thing.old_a is deprecated") + def old_a(): + return "a" + + @deprecated("Thing.old_b is deprecated") + @staticmethod + def old_b(): + return "b" + + with pytest.warns(DeprecationWarning, match=r"^Thing\.old_a is deprecated$"): + assert Thing.old_a() == "a" + + with pytest.warns(DeprecationWarning, match=r"^Thing\.old_b is deprecated$"): + assert Thing.old_b() == "b" + + +def test_property_over_deprecated_warns_on_access(): + class Thing: + def __init__(self): + self._value = 7 + + @property + @deprecated("Thing.value is deprecated") + def value(self): + return self._value + + thing = Thing() + with pytest.warns(DeprecationWarning, match=r"^Thing\.value is deprecated$"): + assert thing.value == 7 + + +def test_deprecated_over_property_fails_at_definition(): + # A property object is not callable, so stacking @deprecated on top of + # @property is rejected at class-definition time; put @property on top. + with pytest.raises(TypeError, match="must be applied to a class or callable"): + + class Thing: + @deprecated("Thing.value is deprecated") + @property + def value(self): + return 7