Skip to content

Commit cfbbd10

Browse files
committed
Take DeprecatedAlias entries in deprecated_aliases()
`deprecated_aliases()` now takes the module followed by one `DeprecatedAlias` per alias, instead of a mapping and a single `message` for all of them, so every alias carries its own message and with it the version it was deprecated in: __getattr__ = deprecated_aliases( __name__, DeprecatedAlias( "Decimal", target="decimal", message="{old} is deprecated since v1.2.0. Use {new} instead.", ), ) The mapping goes away rather than staying next to the new form: it was never released, and two ways of writing the same table would be two shapes for `griffe-frequenz-core` to read, which is what renders these messages in the documentation. A table-wide `message` stays out too, so no alias can end up with a message that says no version by accident. A mapping passed anyway is refused with a `TypeError`, as is anything else that is not a `DeprecatedAlias`. With varargs, two entries with the same name reach the function, where a dict literal would have kept the last one silently, so that is now a `ValueError`, and so is a call with no aliases at all. `module` is positional-only, since it is always `__name__` and a keyword could not come before the entries anyway. Aliases passed as keyword arguments (`Decimal="decimal"`) were considered and dropped: they would make `category` and `stacklevel` names that can never be aliased. `_checked_aliases()` keeps only the checks that are about the set as a whole; each entry already checked its own fields. The table can't be changed after the call anymore, a tuple of frozen entries, so the test that it was copied goes. The docstring examples, the README and the test package move to the new form, and the example that existed only to show a custom message goes, since every example has one now. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent bfda0e6 commit cfbbd10

5 files changed

Lines changed: 146 additions & 144 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ object so `isinstance` keeps working through both paths:
194194
```python
195195
from typing import TYPE_CHECKING, TypeAlias
196196

197-
from frequenz.core.warnings import deprecated_aliases
197+
from frequenz.core.warnings import DeprecatedAlias, deprecated_aliases
198198

199199
if TYPE_CHECKING:
200200
# Type checkers can't see the runtime `__getattr__` in the `else` branch.
@@ -209,10 +209,16 @@ else:
209209
# imports included.
210210
__getattr__ = deprecated_aliases(
211211
__name__,
212-
{
213-
"Decimal": "decimal", # decimal.Decimal
214-
"Rational": "fractions:Fraction", # Renamed on the way out
215-
},
212+
DeprecatedAlias(
213+
"Decimal",
214+
target="decimal", # decimal.Decimal
215+
message="{old} is deprecated since v1.2.0. Use {new} instead.",
216+
),
217+
DeprecatedAlias(
218+
"Rational",
219+
target="fractions:Fraction", # Renamed on the way out
220+
message="{old} is deprecated since v1.3.0. Use {new} instead.",
221+
),
216222
)
217223
```
218224

‎src/frequenz/core/warnings/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
having to touch a symbol it deprecated itself.
1111
1212
It also provides [`deprecated_aliases`][.deprecated_aliases], to keep the old import
13-
path of a symbol that moved to another module working, warning whoever uses it, and
13+
path of a symbol that moved to another module working, warning whoever uses it with
14+
the message of its [`DeprecatedAlias`][.DeprecatedAlias] entry, and
1415
[`asserting_no_warnings`][.asserting_no_warnings] with its
1516
[`asserting_no_deprecations`][.asserting_no_deprecations] shortcut, to check in a test
1617
that a piece of code doesn't warn.

‎src/frequenz/core/warnings/_deprecated_aliases.py‎

Lines changed: 60 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import importlib
1010
import warnings
11-
from collections.abc import Callable, Mapping
11+
from collections.abc import Callable
1212
from dataclasses import dataclass
1313
from types import ModuleType
1414
from 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

165172
def 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

‎tests/warnings/documented_aliases/__init__.py‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from typing import TYPE_CHECKING, TypeAlias
1414

15-
from frequenz.core.warnings import deprecated_aliases
15+
from frequenz.core.warnings import DeprecatedAlias, deprecated_aliases
1616

1717
__all__ = ["Decimal", "Rational", "kept"]
1818

@@ -26,20 +26,30 @@
2626
"""A decimal number.
2727
2828
Deprecated:
29-
`tests.warnings.documented_aliases.Decimal` is deprecated. Use
30-
[decimal.Decimal][] instead.
29+
`tests.warnings.documented_aliases.Decimal` is deprecated since v1.2.0.
30+
Use [decimal.Decimal][] instead.
3131
"""
3232

3333
Rational: TypeAlias = _Fraction
3434
"""A rational number.
3535
3636
Deprecated:
37-
`tests.warnings.documented_aliases.Rational` is deprecated. Use
38-
[fractions.Fraction][] instead.
37+
`tests.warnings.documented_aliases.Rational` is deprecated since v1.3.0.
38+
Use [fractions.Fraction][] instead.
3939
"""
4040
else:
4141
__getattr__ = deprecated_aliases(
42-
__name__, {"Decimal": "decimal", "Rational": "fractions:Fraction"}
42+
__name__,
43+
DeprecatedAlias(
44+
"Decimal",
45+
target="decimal",
46+
message="{old} is deprecated since v1.2.0. Use {new} instead.",
47+
),
48+
DeprecatedAlias(
49+
"Rational",
50+
target="fractions:Fraction",
51+
message="{old} is deprecated since v1.3.0. Use {new} instead.",
52+
),
4353
)
4454

4555

0 commit comments

Comments
 (0)