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
144 changes: 142 additions & 2 deletions python/lsst/utils/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
109 changes: 109 additions & 0 deletions tests/test_deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,119 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

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})
Comment thread
fred3m marked this conversation as resolved.
self.assertEqual(dict(clone), dict(d))


class DeprecatedTestCase(lsst.utils.tests.TestCase):
Expand Down
Loading