diff --git a/python/lsst/utils/deprecated.py b/python/lsst/utils/deprecated.py index 79e67fd4..b283c29c 100644 --- a/python/lsst/utils/deprecated.py +++ b/python/lsst/utils/deprecated.py @@ -11,12 +11,13 @@ from __future__ import annotations -__all__ = ["deprecate_pybind11", "suppress_deprecations"] +__all__ = ["DeprecatedDict", "deprecate_pybind11", "suppress_deprecations"] +import copy import functools import unittest.mock import warnings -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from contextlib import contextmanager from typing import Any @@ -95,3 +96,142 @@ def suppress_deprecations(category: type[Warning] = FutureWarning) -> Iterator[N warnings.simplefilter("ignore", category) with unittest.mock.patch.object(warnings, "simplefilter"): yield + + +class DeprecatedDict(dict): + """A `dict` that warns when deprecated keys are first used. + + DeprecatedDict behaves exactly like a built-in dict, except that each + deprecated key carries a reason. The first time a deprecated key is used + through a single-key operation (d[key], ~dict.get, ~dict.pop, + ~dict.setdefault, d[key] = value and del d[key]), it warns with that + reason. Bulk operations (iteration, copy, ``dict(d)``, ``update``) never + warn. + + Parameters + ---------- + *args : `~typing.Any` + Forwarded to `dict` to populate the initial contents. + deprecations : `~collections.abc.Mapping` + Maps each deprecated key to the reason shown in its warning. An + empty mapping warns that a plain `dict` should be used instead. + version : `str`, optional + Version in which the keys were deprecated, added to the message. + category : `type` [ `Warning` ], optional + Default warning category for deprecated keys. + deprecated_categories : `~collections.abc.Mapping` + Per-key overrides of ``category``. + stacklevel : `int`, optional + ``stacklevel`` for `warnings.warn`, so the warning points at the + caller rather than at ``DeprecatedDict`` internals. + **kwargs : `~typing.Any` + Forwarded to `dict` to populate the initial contents. + """ + + def __init__( + self, + *args: Any, + deprecations: Mapping[Any, str], + version: str = "", + category: type[Warning] = FutureWarning, + deprecated_categories: Mapping[Any, type[Warning]] | None = None, + stacklevel: int = 2, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._deprecations: dict[Any, str] = dict(deprecations) + self._version = version + overrides = deprecated_categories or {} + self._categories: dict[Any, type[Warning]] = { + key: overrides.get(key, category) for key in self._deprecations + } + self._stacklevel = stacklevel + # Fire each key at most once. + self._warned: set[Any] = set() + if not self._deprecations: + warnings.warn( + "DeprecatedDict was created with no deprecated keys; use a regular `dict` instead.", + UserWarning, + stacklevel=stacklevel, + ) + + def _warn(self, key: Any) -> None: + """Emit a one-time deprecation warning for ``key``. + + Parameters + ---------- + key : `~typing.Any` + The key being accessed. + """ + if key not in self._deprecations or key in self._warned: + return + self._warned.add(key) + message = f"Key {key!r} is deprecated" + if self._version: + message += f" since {self._version}" + message += " and should no longer be used." + if reason := self._deprecations[key]: + message += f" {reason}" + # +1 for the ``_warn`` frame between the public method and warn(). + warnings.warn(message, self._categories[key], stacklevel=self._stacklevel + 1) + + # Targeted single-key access: these warn. + def __getitem__(self, key: Any) -> Any: + self._warn(key) + return super().__getitem__(key) + + def __setitem__(self, key: Any, value: Any) -> None: + self._warn(key) + super().__setitem__(key, value) + + def __delitem__(self, key: Any) -> None: + self._warn(key) + super().__delitem__(key) + + def get(self, key: Any, default: Any = None) -> Any: + # Docstring inherited. + self._warn(key) + return super().get(key, default) + + def pop(self, key: Any, *args: Any) -> Any: + # Docstring inherited. + self._warn(key) + return super().pop(key, *args) + + def setdefault(self, key: Any, default: Any = None) -> Any: + # Docstring inherited. + self._warn(key) + return super().setdefault(key, default) + + # Copying: rebuild without routing items through __setitem__. + def _clone(self, data: dict) -> DeprecatedDict: + """Build a sibling that shares this dict's deprecation config. + + Parameters + ---------- + data : `dict` + The contents of the new mapping. + + Returns + ------- + new : `DeprecatedDict` + A new mapping with the same deprecation configuration and the + same set of already-warned keys. + """ + new = DeprecatedDict( + data, + deprecations=self._deprecations, + version=self._version, + deprecated_categories=self._categories, + stacklevel=self._stacklevel, + ) + new._warned = set(self._warned) + return new + + def __copy__(self) -> DeprecatedDict: + return self._clone(dict(self)) + + def __deepcopy__(self, memo: dict) -> DeprecatedDict: + new = self._clone({k: copy.deepcopy(v, memo) for k, v in dict(self).items()}) + memo[id(self)] = new + return new diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index 358de078..2da80932 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -19,10 +19,119 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import copy import unittest +import warnings import lsst.utils import lsst.utils.tests +from lsst.utils.deprecated import DeprecatedDict + + +class DeprecatedDictTestCase(lsst.utils.tests.TestCase): + """Test `~lsst.utils.deprecated.DeprecatedDict`.""" + + def makeDict(self, **kwargs): + # A DeprecatedDict with one deprecated key ("old") and one live key + # ("new"). Construction with a non-empty `deprecations` never warns. + return DeprecatedDict( + {"old": 1, "new": 2}, + deprecations={"old": "Use `.new` instead."}, + version="v30.0", + **kwargs, + ) + + def testIsADict(self): + d = self.makeDict() + self.assertIsInstance(d, dict) + self.assertEqual(d["new"], 2) # Live key never warns. + + def testNoDeprecatedKeysWarns(self): + with self.assertWarns(UserWarning): + DeprecatedDict({"a": 1}, deprecations={}) + + def testGetItemWarnsOnce(self): + d = self.makeDict() + with self.assertWarns(FutureWarning) as cm: + self.assertEqual(d["old"], 1) + message = str(cm.warning) + self.assertIn("v30.0", message) + self.assertIn("Use `.new` instead.", message) + # A second access of the same key is silent. + with warnings.catch_warnings(): + warnings.simplefilter("error") + self.assertEqual(d["old"], 1) + + def testTargetedAccessorsWarn(self): + cases = { + "getitem": lambda d: d["old"], + "get": lambda d: d.get("old"), + "pop": lambda d: d.pop("old"), + "setdefault": lambda d: d.setdefault("old", 9), + "setitem": lambda d: d.__setitem__("old", 9), + "delitem": lambda d: d.__delitem__("old"), + } + for name, op in cases.items(): + with self.subTest(accessor=name): + d = self.makeDict() + with self.assertWarns(FutureWarning): + op(d) + + def testCustomCategory(self): + d = self.makeDict(category=DeprecationWarning) + with self.assertWarns(DeprecationWarning): + _ = d["old"] + + def testPerKeyReasonAndCategory(self): + d = DeprecatedDict( + {"a": 1, "b": 2}, + deprecations={"a": "Use `.a` instead.", "b": "Use `.b` instead."}, + deprecated_categories={"b": DeprecationWarning}, + ) + # "a" uses the default category and its own reason. + with self.assertWarns(FutureWarning) as cm: + _ = d["a"] + self.assertIn("Use `.a` instead.", str(cm.warning)) + # "b" overrides the category and carries its own reason. + with self.assertWarns(DeprecationWarning) as cm: + _ = d["b"] + self.assertIn("Use `.b` instead.", str(cm.warning)) + + def testLiveKeyNeverWarns(self): + d = self.makeDict() + with warnings.catch_warnings(): + warnings.simplefilter("error") + _ = d["new"] + _ = d.get("new") + d["new"] = 3 + + def testBulkOperationsAreSilent(self): + d = self.makeDict() + with warnings.catch_warnings(): + warnings.simplefilter("error") + self.assertEqual(dict(d), {"old": 1, "new": 2}) + self.assertEqual({**d}, {"old": 1, "new": 2}) + self.assertEqual(sorted(d.keys()), ["new", "old"]) + self.assertEqual(sorted(d.items()), [("new", 2), ("old", 1)]) + self.assertIn("old", d) + other = {} + other.update(d) + self.assertEqual(other, {"old": 1, "new": 2}) + + def testDeepcopyIsSilentAndIndependent(self): + d = self.makeDict() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + _ = d["old"] # Latch the original. + with warnings.catch_warnings(): + warnings.simplefilter("error") + clone = copy.deepcopy(d) + # The clone inherits the "already warned" latch, so it too is + # silent for the key already warned on the original. + _ = clone["old"] + self.assertIsInstance(clone, DeprecatedDict) + self.assertEqual(dict(clone), {"old": 1, "new": 2}) + self.assertEqual(dict(clone), dict(d)) class DeprecatedTestCase(lsst.utils.tests.TestCase):