Coverage for python/lsst/utils/deprecated.py: 92%

71 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 01:48 -0700

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ["DeprecatedDict", "deprecate_pybind11", "suppress_deprecations"] 

15 

16import copy 

17import functools 

18import unittest.mock 

19import warnings 

20from collections.abc import Iterator, Mapping 

21from contextlib import contextmanager 

22from typing import Any 

23 

24import deprecated.sphinx 

25 

26 

27def deprecate_pybind11(obj: Any, reason: str, version: str, category: type[Warning] = FutureWarning) -> Any: 

28 """Deprecate a pybind11-wrapped C++ interface function, method or class. 

29 

30 This needs to use a pass-through Python wrapper so that 

31 `~deprecated.sphinx.deprecated` can update its docstring; pybind11 

32 docstrings are native and cannot be modified. 

33 

34 Note that this is not a decorator; its output must be assigned to 

35 replace the method being deprecated. 

36 

37 Parameters 

38 ---------- 

39 obj : `~collections.abc.Callable` 

40 The function, method, or class to deprecate. 

41 reason : `str` 

42 Reason for deprecation, passed to `~deprecated.sphinx.deprecated`. 

43 version : `str` 

44 Next major version in which the interface will be deprecated, 

45 passed to `~deprecated.sphinx.deprecated`. 

46 category : `Warning` 

47 Warning category, passed to `~deprecated.sphinx.deprecated`. 

48 

49 Returns 

50 ------- 

51 obj : `~collections.abc.Callable` 

52 Wrapped function, method, or class. 

53 

54 Examples 

55 -------- 

56 .. code-block:: python 

57 

58 ExposureF.getCalib = deprecate_pybind11(ExposureF.getCalib, 

59 reason="Replaced by getPhotoCalib. (Will be removed in 18.0)", 

60 version="17.0", category=FutureWarning)) 

61 """ 

62 

63 @functools.wraps(obj) 

64 def internal(*args: Any, **kwargs: Any) -> Any: 

65 return obj(*args, **kwargs) 

66 

67 # Typeshed seems to have an incorrect definition for the deprecated 

68 # function and believes the category should be 

69 # Optional[Type[DeprecationWarning]] but this is wrong on two counts: 

70 # 1. None is not actually allowed. 

71 # 2. FutureWarning is explicitly allowed but that is not a subclass 

72 # of DeprecationWarning. 

73 # This has been fixed upstream at 

74 # https://github.com/python/typeshed/pull/593 

75 # and will pass once that is released. 

76 return deprecated.sphinx.deprecated(reason=reason, version=version, category=category)(internal) 

77 

78 

79@contextmanager 

80def suppress_deprecations(category: type[Warning] = FutureWarning) -> Iterator[None]: 

81 """Suppress warnings generated by `deprecated.sphinx.deprecated`. 

82 

83 Naively, one might attempt to suppress these warnings by using 

84 `~warnings.catch_warnings`. However, `~deprecated.sphinx.deprecated` 

85 attempts to install its own filter, overriding that. This convenience 

86 method works around this and properly suppresses the warnings by providing 

87 a mock `~warnings.simplefilter` for `~deprecated.sphinx.deprecated` to 

88 call. 

89 

90 Parameters 

91 ---------- 

92 category : `Warning` 

93 The category of warning to suppress. 

94 """ 

95 with warnings.catch_warnings(): 

96 warnings.simplefilter("ignore", category) 

97 with unittest.mock.patch.object(warnings, "simplefilter"): 

98 yield 

99 

100 

101class DeprecatedDict(dict): 

102 """A `dict` that warns when deprecated keys are first used. 

103 

104 DeprecatedDict behaves exactly like a built-in dict, except that each 

105 deprecated key carries a reason. The first time a deprecated key is used 

106 through a single-key operation (d[key], ~dict.get, ~dict.pop, 

107 ~dict.setdefault, d[key] = value and del d[key]), it warns with that 

108 reason. Bulk operations (iteration, copy, ``dict(d)``, ``update``) never 

109 warn. 

110 

111 Parameters 

112 ---------- 

113 *args : `~typing.Any` 

114 Forwarded to `dict` to populate the initial contents. 

115 deprecations : `~collections.abc.Mapping` 

116 Maps each deprecated key to the reason shown in its warning. An 

117 empty mapping warns that a plain `dict` should be used instead. 

118 version : `str`, optional 

119 Version in which the keys were deprecated, added to the message. 

120 category : `type` [ `Warning` ], optional 

121 Default warning category for deprecated keys. 

122 deprecated_categories : `~collections.abc.Mapping` 

123 Per-key overrides of ``category``. 

124 stacklevel : `int`, optional 

125 ``stacklevel`` for `warnings.warn`, so the warning points at the 

126 caller rather than at ``DeprecatedDict`` internals. 

127 **kwargs : `~typing.Any` 

128 Forwarded to `dict` to populate the initial contents. 

129 """ 

130 

131 def __init__( 

132 self, 

133 *args: Any, 

134 deprecations: Mapping[Any, str], 

135 version: str = "", 

136 category: type[Warning] = FutureWarning, 

137 deprecated_categories: Mapping[Any, type[Warning]] | None = None, 

138 stacklevel: int = 2, 

139 **kwargs: Any, 

140 ) -> None: 

141 super().__init__(*args, **kwargs) 

142 self._deprecations: dict[Any, str] = dict(deprecations) 

143 self._version = version 

144 overrides = deprecated_categories or {} 

145 self._categories: dict[Any, type[Warning]] = { 

146 key: overrides.get(key, category) for key in self._deprecations 

147 } 

148 self._stacklevel = stacklevel 

149 # Fire each key at most once. 

150 self._warned: set[Any] = set() 

151 if not self._deprecations: 

152 warnings.warn( 

153 "DeprecatedDict was created with no deprecated keys; use a regular `dict` instead.", 

154 UserWarning, 

155 stacklevel=stacklevel, 

156 ) 

157 

158 def _warn(self, key: Any) -> None: 

159 """Emit a one-time deprecation warning for ``key``. 

160 

161 Parameters 

162 ---------- 

163 key : `~typing.Any` 

164 The key being accessed. 

165 """ 

166 if key not in self._deprecations or key in self._warned: 

167 return 

168 self._warned.add(key) 

169 message = f"Key {key!r} is deprecated" 

170 if self._version: 

171 message += f" since {self._version}" 

172 message += " and should no longer be used." 

173 if reason := self._deprecations[key]: 173 ↛ 176line 173 didn't jump to line 176 because the condition on line 173 was always true

174 message += f" {reason}" 

175 # +1 for the ``_warn`` frame between the public method and warn(). 

176 warnings.warn(message, self._categories[key], stacklevel=self._stacklevel + 1) 

177 

178 # Targeted single-key access: these warn. 

179 def __getitem__(self, key: Any) -> Any: 

180 self._warn(key) 

181 return super().__getitem__(key) 

182 

183 def __setitem__(self, key: Any, value: Any) -> None: 

184 self._warn(key) 

185 super().__setitem__(key, value) 

186 

187 def __delitem__(self, key: Any) -> None: 

188 self._warn(key) 

189 super().__delitem__(key) 

190 

191 def get(self, key: Any, default: Any = None) -> Any: 

192 # Docstring inherited. 

193 self._warn(key) 

194 return super().get(key, default) 

195 

196 def pop(self, key: Any, *args: Any) -> Any: 

197 # Docstring inherited. 

198 self._warn(key) 

199 return super().pop(key, *args) 

200 

201 def setdefault(self, key: Any, default: Any = None) -> Any: 

202 # Docstring inherited. 

203 self._warn(key) 

204 return super().setdefault(key, default) 

205 

206 # Copying: rebuild without routing items through __setitem__. 

207 def _clone(self, data: dict) -> DeprecatedDict: 

208 """Build a sibling that shares this dict's deprecation config. 

209 

210 Parameters 

211 ---------- 

212 data : `dict` 

213 The contents of the new mapping. 

214 

215 Returns 

216 ------- 

217 new : `DeprecatedDict` 

218 A new mapping with the same deprecation configuration and the 

219 same set of already-warned keys. 

220 """ 

221 new = DeprecatedDict( 

222 data, 

223 deprecations=self._deprecations, 

224 version=self._version, 

225 deprecated_categories=self._categories, 

226 stacklevel=self._stacklevel, 

227 ) 

228 new._warned = set(self._warned) 

229 return new 

230 

231 def __copy__(self) -> DeprecatedDict: 

232 return self._clone(dict(self)) 

233 

234 def __deepcopy__(self, memo: dict) -> DeprecatedDict: 

235 new = self._clone({k: copy.deepcopy(v, memo) for k, v in dict(self).items()}) 

236 memo[id(self)] = new 

237 return new