88
99import importlib
1010import warnings
11- from collections .abc import Callable , Mapping
11+ from collections .abc import Callable
1212from dataclasses import dataclass
1313from types import ModuleType
1414from typing import Any
@@ -129,44 +129,50 @@ def __init__( # noqa: DOC503
129129 object .__setattr__ (self , "message" , message )
130130
131131
132- def _checked_aliases ( # noqa: DOC503
133- module : str , aliases : Mapping [ str , str ]
134- ) -> dict [str , tuple [str , str ]]:
132+ def _checked_aliases (
133+ module : str , aliases : tuple [ DeprecatedAlias , ... ]
134+ ) -> dict [str , tuple [str , str , str ]]:
135135 """Check a set of deprecated aliases and resolve them.
136136
137- Everything here is checked when the aliases are declared rather than when one
138- of them is reached, which can be a release later and in somebody else's code,
139- where the failure no longer looks like a typo in an alias table.
137+ Each alias checked its own fields when it was created; what is left is what
138+ only makes sense for the set as a whole.
140139
141140 Args:
142141 module: The fully qualified name of the module defining the aliases.
143- aliases: The mapping to check.
142+ aliases: The aliases to check.
144143
145144 Returns:
146- Each deprecated name mapped to the module its target lives in and the name
147- it has there, so a later change to `aliases` can 't get past these
148- checks and the string doesn't have to be taken apart on every lookup.
145+ Each deprecated name mapped to the module its target lives in, the name it
146+ has there and the message template, so the target doesn 't have to be
147+ taken apart on every lookup.
149148
150149 Raises:
151- TypeError: If `module` is not a string, or a name or target is not one.
152- ValueError: If a target is empty, carries more than one `:`, or has
153- nothing after it .
150+ TypeError: If `module` is not a string, or an alias is not a
151+ [`DeprecatedAlias`][..DeprecatedAlias].
152+ ValueError: If there are no aliases, or two of them have the same name .
154153 """
155154 if not isinstance (module , str ):
156155 raise TypeError (f"module must be a str, got { module !r} " )
157- checked : dict [str , tuple [str , str ]] = {}
158- for name , target in dict (aliases ).items ():
159- if not isinstance (name , str ):
160- raise TypeError (f"alias names must be str, got { name !r} " )
161- checked [name ] = _split_target (name , target )
156+ if not aliases :
157+ raise ValueError (f"no aliases given for module { module !r} " )
158+ checked : dict [str , tuple [str , str , str ]] = {}
159+ for alias in aliases :
160+ # Also what a mapping, the form this function used to take, runs into.
161+ if not isinstance (alias , DeprecatedAlias ):
162+ raise TypeError (f"aliases must be DeprecatedAlias instances, got { alias !r} " )
163+ # A dict literal would keep the last of two entries silently; this is the
164+ # one place that can tell.
165+ if alias .name in checked :
166+ raise ValueError (f"alias { alias .name !r} is given more than once" )
167+ target_module , target_name = _split_target (alias .name , alias .target )
168+ checked [alias .name ] = (target_module , target_name , alias .message )
162169 return checked
163170
164171
165172def deprecated_aliases ( # noqa: DOC502
166173 module : str ,
167- aliases : Mapping [str , str ],
168- * ,
169- message : str = "{old} is deprecated. Use {new} instead." ,
174+ / ,
175+ * aliases : DeprecatedAlias ,
170176 category : type [Warning ] = DeprecationWarning ,
171177 stacklevel : int = 2 ,
172178) -> Callable [[str ], Any ]:
@@ -178,6 +184,11 @@ def deprecated_aliases( # noqa: DOC502
178184 it for everybody, including the users of its new home. The alias also stays the
179185 very same object, so [`isinstance`][] keeps working through both paths.
180186
187+ Each alias is a [`DeprecatedAlias`][..DeprecatedAlias] carrying its own message,
188+ a template that gets the fully qualified old and new names as `{old}` and
189+ `{new}`, so aliases deprecated in different releases can each say since which
190+ version.
191+
181192 Danger:
182193 Follow the usage example structure strictly. In particular never drop
183194 the `else:`, otherwise a type checker will see the `__getattr__`
@@ -191,19 +202,27 @@ def deprecated_aliases( # noqa: DOC502
191202 ```python
192203 from typing import TYPE_CHECKING, TypeAlias
193204
194- from frequenz.core.warnings import deprecated_aliases
205+ from frequenz.core.warnings import DeprecatedAlias, deprecated_aliases
195206
196207 if TYPE_CHECKING:
197208 # Private import, only for type checkers
198209 from decimal import Decimal as _Decimal
199210
200211 Decimal: TypeAlias = _Decimal
201212 else:
202- __getattr__ = deprecated_aliases(__name__, {"Decimal": "decimal"})
213+ __getattr__ = deprecated_aliases(
214+ __name__,
215+ DeprecatedAlias(
216+ "Decimal",
217+ target="decimal",
218+ message="{old} is deprecated since v1.2.0. Use {new} instead.",
219+ ),
220+ )
203221 ```
204222
205223 Reaching `Decimal` through this module now emits a `DeprecationWarning`
206- saying to use `decimal.Decimal` instead.
224+ saying it is deprecated since v1.2.0, and to use `decimal.Decimal`
225+ instead.
207226
208227 Example: Renaming
209228 When the symbol was also renamed on the way out, the new name goes after a
@@ -212,7 +231,7 @@ def deprecated_aliases( # noqa: DOC502
212231 ```python
213232 from typing import TYPE_CHECKING, TypeAlias
214233
215- from frequenz.core.warnings import deprecated_aliases
234+ from frequenz.core.warnings import DeprecatedAlias, deprecated_aliases
216235
217236 if TYPE_CHECKING:
218237 from fractions import Fraction as _Fraction
@@ -221,33 +240,11 @@ def deprecated_aliases( # noqa: DOC502
221240 else:
222241 __getattr__ = deprecated_aliases(
223242 __name__,
224- {
225- "Rational": "fractions:Fraction",
226- },
227- )
228- ```
229-
230- Example: Custom deprecation message
231- The message is a template that gets the old and new fully qualified names,
232- so it can carry a version, a link, or anything else the default doesn't
233- say:
234-
235- ```python
236- from typing import TYPE_CHECKING, TypeAlias
237-
238- from frequenz.core.warnings import deprecated_aliases
239-
240- if TYPE_CHECKING:
241- from decimal import Decimal as _Decimal
242-
243- Decimal: TypeAlias = _Decimal
244- else:
245- __getattr__ = deprecated_aliases(
246- __name__,
247- {
248- "Decimal": "decimal",
249- },
250- message="{old} is deprecated since v2, use {new} instead.",
243+ DeprecatedAlias(
244+ "Rational",
245+ target="fractions:Fraction",
246+ message="{old} is deprecated since v1.3.0. Use {new} instead.",
247+ ),
251248 )
252249 ```
253250
@@ -258,19 +255,13 @@ def deprecated_aliases( # noqa: DOC502
258255 that used to be reachable there.
259256
260257 Warning: Security Warning
261- This function imports the target module , so the mapping is as trusted
262- as an `import` statement in this module. Write it out as a literal ;
263- don't build it from anything that comes from outside the program.
258+ This function imports the target modules , so the aliases are as trusted
259+ as an `import` statement in this module. Write them out as literals ;
260+ don't build them from anything that comes from outside the program.
264261
265262 Args:
266263 module: The fully qualified name of the module defining the aliases.
267- aliases: A mapping of each deprecated symbol to where it lives now, as the
268- fully qualified name of the module that owns it, optionally followed by
269- `:` and the name it has there, when it is not the deprecated one. It is
270- copied, so changing it afterwards has no effect.
271- message: The template for the warning message, formatted with `{old}` and
272- `{new}`, the fully qualified names of the alias and of the symbol it
273- resolves to.
264+ *aliases: The deprecated aliases, at least one, each with its own name.
274265 category: The category of the warning to emit.
275266 stacklevel: How far up the stack the warning is reported, counting from the
276267 `__getattr__` itself. The default of 2 points at the code reaching for
@@ -281,14 +272,12 @@ def deprecated_aliases( # noqa: DOC502
281272 A function suitable for use as the module's `__getattr__`.
282273
283274 Raises:
284- TypeError: If an argument has the wrong type, including a name or target
285- that is not a string. Also later, when an alias is reached, if it
286- resolves to a module rather than to a symbol in one.
287- ValueError: If a target is empty, carries more than one `:`, or has
288- nothing after it, if `message` is not a template taking `{old}` and
289- `{new}`, or if `stacklevel` is smaller than 1.
275+ TypeError: If an argument has the wrong type, including an alias that is
276+ not a [`DeprecatedAlias`][..DeprecatedAlias]. Also later, when an alias
277+ is reached, if it resolves to a module rather than to a symbol in one.
278+ ValueError: If there are no aliases, if two of them have the same name, or
279+ if `stacklevel` is smaller than 1.
290280 """
291- _check_template (message )
292281 if not (isinstance (category , type ) and issubclass (category , Warning )):
293282 raise TypeError (f"category must be a Warning subclass, got { category !r} " )
294283 if not isinstance (stacklevel , int ):
@@ -314,7 +303,7 @@ def module_getattr(name: str) -> Any:
314303 target = targets .get (name )
315304 if target is None :
316305 raise AttributeError (f"module { module !r} has no attribute { name !r} " )
317- target_module , target_name = target
306+ target_module , target_name , message = target
318307 # Resolved before the warning is raised, so an alias that can't work says
319308 # so instead of warning about a move that didn't happen. It also keeps the
320309 # `TypeError` below from being masked by an "error" filter turning that
0 commit comments