Skip to content
Draft
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
5 changes: 3 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ There are a few intentional hard breaks too, all listed in the Upgrading section

* `frequenz.client.common.proto.datetime_from_proto` is now deprecated; use `datetime_from_proto2` instead.

* `frequenz.client.common.metrics.MetricSample.sample_time` is now a deprecated read-only property; use the new `sample_time2` field instead.
* `frequenz.client.common.metrics.MetricSample.sample_time` is now a deprecated read-only property; use the new `get_sample_time()` method instead, or the new `sample_time2` field to also see malformed timestamps.

The field became `datetime | InvalidDatetime`, which the released `datetime` annotation cannot express, so it was renamed. Reading `sample_time` still returns a `datetime` and now emits a `DeprecationWarning`; for a malformed wire timestamp it raises `InvalidDatetimeError` (a `ValueError`) rather than returning a repaired value. `get_sample_time()` does the same without the warning.
The field became `datetime | InvalidDatetime`, which the released `datetime` annotation cannot express, so it was renamed. Reading `sample_time` still returns a `datetime` and now emits a `DeprecationWarning`; for a malformed wire timestamp it raises `InvalidDatetimeError` (a `ValueError`) rather than returning a repaired value. `get_sample_time()` does the same without the warning, which is why the warning recommends it.

Constructing with `sample_time=` is **not** deprecated and keeps working: it accepts a well-formed `datetime` today and will accept the wider type once `sample_time2` is renamed back to `sample_time`. Use `sample_time2=` to build a sample from a malformed wire timestamp.

Expand Down Expand Up @@ -248,3 +248,4 @@ There are a few intentional hard breaks too, all listed in the Upgrading section
* Fixed `EnumParityTest` so protobuf values whose Python member name exists with a different number fail parity checks instead of being treated as unmirrored protobuf values.
* Fixed potential unexpected exceptions due to type-checking accepting `int` for code annotated to only accept `float`. Fixes #250.
* Exception messages reporting an invalid value now use its `str()` instead of its `repr()`, so they show the compact `<invalid:...>` rendering instead of a verbose dataclass dump.
* Fixed warnings being shown over and over instead of once. The library silenced its own internal deprecation warnings with `warnings.catch_warnings()`, which resets the warnings deduplication history of the whole program every time it is used ([python/cpython#73858](https://github.com/python/cpython/issues/73858)), so every warning already shown, from this library or any other code, was shown again on each call to a converter, accessor or `str()`. It now uses `frequenz.core.warnings.ignoring_deprecations()`, which leaves that history alone.
22 changes: 22 additions & 0 deletions docs/_css/mkdocstrings.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,25 @@ a.autorefs-external::after {
a.autorefs-external:hover::after {
background-color: var(--md-accent-fg-color);
}

/* A "Deprecated" admonition, styled like a warning but with its own icon. */
:root {
--md-admonition-icon--deprecated: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10 2h4c3.31 0 5 2.69 5 6v10.66C16.88 17.63 15.07 17 12 17s-4.88.63-7 1.66V8c0-3.31 1.69-6 5-6M8 8v1.5h8V8zm1 4v1.5h6V12zM3 22v-.69c2.66-1.69 10.23-5.47 18-.06V22z"/></svg>');
}

.md-typeset .admonition.deprecated,
.md-typeset details.deprecated {
border-color: #cc9900;
}

.md-typeset .deprecated > .admonition-title,
.md-typeset .deprecated > summary {
background-color: #cc99001a;
}

.md-typeset .deprecated > .admonition-title::before,
.md-typeset .deprecated > summary::before {
background-color: #cc9900;
-webkit-mask-image: var(--md-admonition-icon--deprecated);
mask-image: var(--md-admonition-icon--deprecated);
}
179 changes: 166 additions & 13 deletions docs/wrapping-guide/deprecation-and-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@ define the wider 0.x versioning process.

Mark the old public symbol with
[`typing_extensions.deprecated`][typing_extensions.deprecated]. Use this exact
message form: `"<old FQCN> is deprecated. Use <new FQCN> instead."`. Write both
fully qualified names exactly. In the old API documentation, explain any change
to the return type or behavior. A caller should know what to use from the
message form: `"<old FQCN> is deprecated since v<X.Y.Z>. Use [<new FQCN>][]
instead."`. Write both fully qualified names exactly, the old one plain and the
replacement as a bare cross-reference so the rendered `Deprecated:` admonition
links to it. The version belongs in the sentence: a separate "since" line
cannot be expressed through the decorator, so the two would drift apart.

The same string is printed as a runtime warning, so keep it to a sentence or
two and build it by concatenating single-line strings. A triple-quoted message
keeps its indentation, which stops the cross-reference from resolving and
prints an indented warning in the terminal. Do not put the names in backticks
either: they buy code font in the documentation at the cost of noise in the
console, where the reader cannot skip over them. Anything beyond "use X
instead", such as a change to the return type or behavior, goes in the
docstring body as prose. A caller should still know what to use from the
warning alone.

This example gives the replacement conversion function a numeric-suffixed name
Expand All @@ -27,19 +38,39 @@ def thing_from_proto2(value: int) -> str:


@deprecated(
"example.thing_from_proto is deprecated. Use example.thing_from_proto2 instead."
"example.thing_from_proto is deprecated since v0.4.1. "
"Use [example.thing_from_proto2][] instead."
)
def thing_from_proto(value: int) -> str:
return thing_from_proto2(value)


with deprecated_call(
match="example.thing_from_proto is deprecated. "
"Use example.thing_from_proto2 instead."
match=r"^example\.thing_from_proto is deprecated since v0\.4\.1\. "
r"Use \[example\.thing_from_proto2\]\[\] instead\.$"
):
assert thing_from_proto(3) == "3"
```

`match` is a regular expression, so the brackets of the cross-reference have to
be escaped there, as do the dots of the qualified names.

The documentation build turns the decorator's message into a `Deprecated:`
admonition at the top of the symbol's docstring, and adds a `deprecated` label
next to its name. It reads the message from the source without running it, so
write it as string literals in the decorator call: a message held in a
constant, built by an f-string or returned by a helper function renders no
admonition at all.

Where no admonition can be generated, write the notice as a `Deprecated:`
admonition in the docstring instead. That is the case for a single argument,
construction that is being made stricter, a property (the decorator works at
runtime, but the documentation build ignores it there), and a message that is
not a literal. Put it immediately after the summary line and give it no custom
title (a title replaces the word "Deprecated" in the rendered output), and
again state the version in the text. Never hand-write one for a symbol that
already gets a generated one, or the page shows the same notice twice.

## Check downstream adoption before removal

When possible, check downstream client releases to see if they import or expose
Expand All @@ -65,8 +96,115 @@ unsuffixed name while retaining a deprecated alias for the suffixed name.

For an enum-member change, use
[`deprecated_member`][frequenz.core.enum.deprecated_member]. It keeps the old
member temporarily and warns when code uses it. Document the representation new
code should use.
member temporarily and warns when code uses it. Its message follows the same
form as the decorator's and gets the same generated admonition and label, as
long as it is written as string literals in the call. Document the
representation new code should use.

A renamed member keeps its old name as a deprecated alias of the same value,
which [`unique`][frequenz.core.enum.unique] allows:

```python
from frequenz.core.enum import Enum, deprecated_member, unique


@unique
class Mode(Enum):
"""Modes a thing can run in."""

NEW_NAME = 1
"""The thing runs normally."""

OLD_NAME = deprecated_member(
1,
"example.Mode.OLD_NAME is deprecated since v0.5.0. "
"Use example.Mode.NEW_NAME instead.",
)
"""Old name of `NEW_NAME`."""
```

## Keep a moved symbol importable

When a public symbol moves to another module, keep its old import path working
with `frequenz.core.warnings.deprecated_aliases()` instead of writing a module
`__getattr__` by hand. The alias is the very same object, so
[`isinstance()`][isinstance] keeps working through both paths, and the
documentation build generates the admonition and label for every alias, as
long as it is a literal in the call.

```python
from typing import TYPE_CHECKING, TypeAlias

from frequenz.core.warnings import DeprecatedAlias, deprecated_aliases

if TYPE_CHECKING:
from example.new import Thing as _Thing

Thing: TypeAlias = _Thing
"""A thing, now living in `example.new`."""
else:
__getattr__ = deprecated_aliases(
__name__,
DeprecatedAlias("Thing", new_module="example.new", since="v0.5.0"),
)
```

Keep that structure exactly: without the `else:`, type checkers see the
`__getattr__` and treat every name in the module as `Any`. When the symbol was
renamed too, give its new name as `new_name`; without `new_module`, the alias
points at a renamed symbol in its own module. Each alias gives
its own `since`, the version it is deprecated in, so aliases deprecated in
different releases can each say theirs; the warning and the generated
admonition both read `{old} is deprecated since {since}. Use {new} instead.`
When that standard wording is not enough, give `message` instead of `since`,
a full template taking only `{old}` and `{new}`, the two fully qualified
names; the documentation turns `{new}` into a link there too, so leave out
the cross-reference brackets.

An alias only fits when the old name can be the same object as the new one.
When the old type has to stay distinct, as
[`ComponentId`][frequenz.client.common.microgrid.components.ComponentId] does
next to
[`ElectricalComponentId`][frequenz.client.common.microgrid.electrical_components.ElectricalComponentId],
keep a deprecated class instead.

## Silence only the deprecations you raise yourself

Sometimes library code has to touch a symbol it deprecated itself, such as a
deprecated converter that still has to build the deprecated type it returns.
The caller already gets the converter's own warning, so a second one from
inside it is noise. Silence it with
`frequenz.core.warnings.ignoring_deprecations()`, around the statement that
raises it and nothing more, so deprecations from anywhere else still get
through:

```python
from frequenz.core.warnings import ignoring_deprecations
from typing_extensions import deprecated

from example import OldThing, ThingProto


@deprecated(
"example.old_thing_from_proto is deprecated since v0.5.0. "
"Use example.thing_from_proto instead."
)
def old_thing_from_proto(message: ThingProto) -> OldThing:
"""Convert a protobuf message to the deprecated `OldThing`."""
with ignoring_deprecations():
return OldThing(value=message.value)
```

The same applies to code that is not deprecated itself but still has to accept
or build a deprecated symbol for compatibility: the user is warned where they
use the deprecated symbol, not by the library's internals.

Do not use [`warnings.catch_warnings()`][warnings.catch_warnings] for this.
Entering and leaving it resets the warnings deduplication history of the whole
program ([python/cpython#73858](https://github.com/python/cpython/issues/73858)),
so every warning that was already shown, by this library or any other code, is
shown again after each call. In an application converting data in a loop, that
turns a handful of warnings into tens of thousands.

## Tighten invariants in stages

Expand All @@ -85,11 +223,26 @@ can test the stricter behavior before it becomes required and migrate on purpose

Test every public deprecation with
[`pytest.deprecated_call()`][pytest.deprecated_call]. Check the exact message
and the replacement behavior. If deprecated code correctly calls another
deprecated symbol, suppress only that expected inner
[`DeprecationWarning`][]
in a small [`warnings.catch_warnings()`][warnings.catch_warnings] block. The
outer API must still emit its one public warning.
and the replacement behavior. The outer API must still emit its one public
warning, even when it silences inner ones as described above.

Check that the replacement doesn't go through anything deprecated with
`frequenz.core.warnings.asserting_no_deprecations()`, rather than with an
`"error"` filter. The filter turns the warning into an exception inside the
code under test, where a broad `except` can swallow it; the helper records the
warnings instead and fails when the block ends, listing each one and where it
came from:

```python
from frequenz.core.warnings import asserting_no_deprecations

from example import thing_from_proto2


def test_thing_from_proto2_does_not_warn() -> None:
with asserting_no_deprecations():
assert thing_from_proto2(3) == "3"
```

Add `RELEASE_NOTES.md` migration bullets that state the old behavior, the
replacement, what changes, and the planned removal version. Remove the
Expand Down
4 changes: 2 additions & 2 deletions docs/wrapping-guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ wrappers.
`*_from_proto` and `*_to_proto` functions that translate protobuf messages
to wrapper types.
- [Deprecation and compatibility](deprecation-and-compatibility.md) — Describes
how to replace public functions and tighten validation without surprising
callers.
how to replace or move public symbols and tighten validation without
surprising callers, and how to keep the library's own deprecations quiet.
- [Testing](testing.md) — Shows how to place tests, check enum parity, and make
documentation examples and warnings part of the test suite.
10 changes: 7 additions & 3 deletions docs/wrapping-guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Assert each expected deprecation with
[`pytest.deprecated_call()`][pytest.deprecated_call]. This records the public
warning and stops an unrelated warning from being hidden. When deprecated code
correctly calls another deprecated symbol, suppress only that inner
`DeprecationWarning` in a small warning block to avoid duplicate messages. In a
test, use the same small suppression only for a warning that a dedicated
assertion already checks. Never suppress warnings globally.
`DeprecationWarning` in a small `frequenz.core.warnings.ignoring_deprecations()`
block to avoid duplicate messages. In a test, use the same small suppression
only for a warning that a dedicated assertion already checks, and check that a
replacement doesn't warn with `frequenz.core.warnings.asserting_no_deprecations()`.
Never suppress warnings globally, and never with
[`warnings.catch_warnings()`][warnings.catch_warnings]; [Deprecation and
compatibility](deprecation-and-compatibility.md) explains both helpers and why.
7 changes: 7 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ plugins:
python:
paths: ["src"]
options:
extensions:
- griffe_warnings_deprecated:
kind: deprecated
title: Deprecated
- griffe_frequenz_core.deprecations:
kind: deprecated
title: Deprecated
docstring_section_style: spacy
inherited_members: true
merge_init_into_class: false
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ requires-python = ">= 3.11, < 4"
dependencies = [
"typing-extensions >= 4.13.0, < 5",
"frequenz-api-common >= 0.8.4, < 1",
"frequenz-core >= 1.4.0, < 2",
"frequenz-core >= 1.5.0, < 2",
"protobuf >= 6.33.6, < 8",
]
dynamic = ["version"]
Expand All @@ -50,6 +50,8 @@ dev-formatting = ["black == 26.5.1", "isort == 9.0.1"]
dev-mkdocs = [
"Markdown == 3.11",
"black == 26.5.1",
"griffe-frequenz-core == 1.0.0",
"griffe-warnings-deprecated == 1.1.1",
"mike == 2.2.0",
"mkdocs-gen-files == 0.6.1",
"mkdocs-literate-nav == 0.6.3",
Expand Down
35 changes: 17 additions & 18 deletions src/frequenz/client/common/grid/_delivery_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Any, Self, assert_never

from frequenz.core.enum import Enum, deprecated_member, unique
from frequenz.core.warnings import ignoring_deprecations

from .._exception import (
InvalidAttributeError,
Expand Down Expand Up @@ -46,8 +47,9 @@ class EnergyMarketCodeType(Enum):

UNSPECIFIED = deprecated_member(
0,
"EnergyMarketCodeType.UNSPECIFIED is deprecated; use the `int` value `0` "
"instead if you really need to check for this low-level value.",
"frequenz.client.common.grid.EnergyMarketCodeType.UNSPECIFIED is "
"deprecated since v0.4.1. Use the int value 0 instead if you really "
"need to check for this low-level value.",
)
"""Unspecified type. This value is a placeholder and should not be used."""

Expand All @@ -72,11 +74,11 @@ class BaseDeliveryArea:
code: str | None
"""The code representing the unique identifier for the delivery area.

Warning: Using `None` is deprecated
This field is required for a well-formed `DeliveryArea`, so we are
making this more explicit by deprecating the use of `None` here. In the
future, `| None` will be removed so passing `None` will fail type
checking.
Deprecated:
Passing `None` is deprecated since v0.4.1. This field is required for a
well-formed `DeliveryArea`, so we are making this more explicit by
deprecating the use of `None` here. In the future, `| None` will be
removed so passing `None` will fail type checking.
"""

code_type: EnergyMarketCodeType | int
Expand Down Expand Up @@ -122,11 +124,11 @@ class DeliveryArea(BaseDeliveryArea):
location. Delivery areas can have different codes based on the jurisdiction in
which they operate.

Warning: Construction of invalid instances is deprecated
A well-formed `DeliveryArea` carries a non-empty [`code`][.code] and a
specified [`code_type`][.code_type]. Constructing one with data that
violates this invariant is **deprecated**, and will raise a
[`ValueError`][] in a future release.
Deprecated:
Constructing a `DeliveryArea` with invalid data is deprecated since
v0.4.1, and will raise a [`ValueError`][] in a future release. The type
itself is not deprecated. A well-formed `DeliveryArea` carries a
non-empty [`code`][.code] and a specified [`code_type`][.code_type].

You can temporarily use the `_raise_on_invalid` keyword argument to get
the upcoming behavior now (raising instead of deprecation warning).
Expand Down Expand Up @@ -161,8 +163,7 @@ def __post_init__(self, _raise_on_invalid: bool) -> None:
DeprecationWarning,
stacklevel=3,
)
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
with ignoring_deprecations():
unspecified_code_type = EnergyMarketCodeType.UNSPECIFIED
if self.code_type in (0, unspecified_code_type):
if _raise_on_invalid:
Expand Down Expand Up @@ -200,8 +201,7 @@ def get_code_type(self) -> EnergyMarketCodeType:
available on the exception's `value` attribute.
"""
# Suppressing the deprecation warning can be removed when UNSPECIFIED is removed
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
with ignoring_deprecations():
match self.code_type:
case 0 | EnergyMarketCodeType.UNSPECIFIED:
raise UnspecifiedEnumValueError(self, "code_type")
Expand All @@ -228,8 +228,7 @@ def __str__(self) -> str:
"""Return a human-readable string representation of this instance."""
# Suppressing the deprecation warning can be removed when UNSPECIFIED
# is removed
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
with ignoring_deprecations():
match self.code_type:
case 0 | EnergyMarketCodeType.UNSPECIFIED:
code_type = "type=<invalid:0>"
Expand Down
Loading