From e1a5735320f9c0931a9159b93344e035b18e9df0 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 21 Sep 2026 12:41:34 +0000 Subject: [PATCH 1/8] docs: Add styling for "Deprecated" admonitions Style the `deprecated` admonition class like a warning, but with the `material/grave-stone` icon and its own colour, so deprecation notices read as deprecation notices and not as generic warnings. That class is what a hand-written `Deprecated:` admonition in a docstring produces, and also what the griffe extension added next will emit, so a single rule covers both sources. Signed-off-by: Leandro Lucarella --- docs/_css/mkdocstrings.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/_css/mkdocstrings.css b/docs/_css/mkdocstrings.css index 572abff1..851214f2 100644 --- a/docs/_css/mkdocstrings.css +++ b/docs/_css/mkdocstrings.css @@ -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,'); +} + +.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); +} From 3587d69bf130ca9306c32f3b9edf2209e045f309 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 21 Sep 2026 12:41:34 +0000 Subject: [PATCH 2/8] docs: Generate "Deprecated" admonitions automatically Wire two griffe extensions into the mkdocstrings handler options, so deprecated symbols get a "Deprecated" admonition and a `deprecated` label in the API reference with no docstring edit at all: * `griffe-warnings-deprecated` handles every class and function decorated with `typing_extensions.deprecated`. * `griffe-frequenz-core` handles what `frequenz-core` deprecates through a call instead of a decorator: enum members wrapped in `frequenz.core.enum.deprecated_member()`, and module aliases built with `frequenz.core.warnings.deprecated_aliases()`. It doesn't import `frequenz-core`, so it only goes in the docs dependencies. Both extensions have `kind` set to `deprecated` rather than their default, so they emit `class="deprecated"`, which is exactly what a hand-written `Deprecated:` admonition produces. The CSS rule added in the previous commit then styles all three, and they are visually indistinguishable. That matters because some cases still need the admonition written by hand: a single function argument, a whole module, a property (griffe-warnings-deprecated doesn't look at the decorators of a property), and a message the extensions can't read statically, such as one built by calling a function. Signed-off-by: Leandro Lucarella --- mkdocs.yml | 7 +++++++ pyproject.toml | 2 ++ 2 files changed, 9 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 73a4c8d5..c059a454 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6d94ac2b..afe664ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From 820f619f6f56983a660c196b16321e50c26f43d4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 21 Sep 2026 16:53:05 +0000 Subject: [PATCH 3/8] docs: Make every deprecation notice follow the deprecations guide Rewrite every `@deprecated` message in the form the guide asks for: the fully qualified name of the deprecated symbol, the version it was deprecated in, and the replacement as a bare `[name][]` cross-reference, so the admonition generated from it links to the replacement. The old `Warning: Deprecated` admonitions go away, since the generated ones replace them; whatever they said beyond "use X instead" stays as prose in the docstring body. The `deprecated_member()` messages of the `UNSPECIFIED` members follow the same form, and get their admonition generated too. Where no extension can produce one, a `Deprecated:` admonition is written by hand instead: * The members of `ElectricalComponentCategory`, whose message is built by a helper function, which the extension can't read statically. * `MetricSample.sample_time` and `MetricSample.bounds`, because griffe-warnings-deprecated doesn't look at the decorators of a property. * The `bounds` argument of `MetricSample` and a `None` `code` in `DeliveryArea`, which deprecate an argument and not a symbol. * Constructing an invalid `DeliveryArea`, which is being made stricter. `MetricSample.sample_time` now points at `get_sample_time()`, not at `sample_time2`: the property returns a `datetime` and raises `InvalidDatetimeError` for a malformed timestamp, which is exactly what `get_sample_time()` does, while `sample_time2` has a different type and is itself going away when it is renamed back to `sample_time`. The release notes now say the same. Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 4 +- .../client/common/grid/_delivery_area.py | 25 +-- .../grid/proto/v1alpha8/_delivery_area.py | 17 +- src/frequenz/client/common/metrics/_metric.py | 5 +- src/frequenz/client/common/metrics/_sample.py | 50 +++-- .../common/metrics/proto/v1alpha8/_bounds.py | 29 ++- .../common/metrics/proto/v1alpha8/_sample.py | 34 ++-- .../common/microgrid/components/__init__.py | 6 +- .../electrical_components/_category.py | 186 +++++++++++++++--- .../electrical_components/_diagnostic_code.py | 6 +- .../electrical_components/_state_code.py | 6 +- .../proto/v1alpha8/_category.py | 12 +- .../proto/v1alpha8/_pagination_info.py | 13 +- src/frequenz/client/common/proto/_datetime.py | 18 +- .../client/common/streaming/_event.py | 5 +- tests/metrics/test_sample_metric_sample.py | 7 +- .../proto/v1alpha8/test_pagination_info.py | 6 +- tests/proto/test_datetime.py | 5 +- 18 files changed, 297 insertions(+), 137 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 35ea0f5e..142ac5b7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index c51ba40e..9f1447f2 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -46,8 +46,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.""" @@ -72,11 +73,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 @@ -122,11 +123,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). diff --git a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py index 131e591b..ac23b722 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -45,21 +45,20 @@ def energy_market_code_type_to_proto( @deprecated( - "`delivery_area_from_proto` is deprecated; use " - "`delivery_area_from_proto2` (returns " - "`DeliveryArea | InvalidDeliveryArea`) instead." + "frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto is " + "deprecated since v0.4.1. Use " + "[frequenz.client.common.grid.proto.v1alpha8.delivery_area_from_proto2][] " + "instead." ) def delivery_area_from_proto( # noqa: DOC502 message: delivery_area_pb2.DeliveryArea, ) -> DeliveryArea: """Convert a protobuf message to a [`DeliveryArea`][....DeliveryArea] object. - Warning: Deprecated - Use [`delivery_area_from_proto2`][..delivery_area_from_proto2] - instead. The new converter distinguishes well-formed from - malformed data at the type level - (`DeliveryArea | InvalidDeliveryArea`) rather than silently - constructing a `DeliveryArea` with invalid content. + [`delivery_area_from_proto2`][..delivery_area_from_proto2] distinguishes + well-formed from malformed data at the type level + (`DeliveryArea | InvalidDeliveryArea`) rather than silently constructing a + `DeliveryArea` with invalid content. Args: message: The protobuf message to convert. diff --git a/src/frequenz/client/common/metrics/_metric.py b/src/frequenz/client/common/metrics/_metric.py index 587684cd..e121afa0 100644 --- a/src/frequenz/client/common/metrics/_metric.py +++ b/src/frequenz/client/common/metrics/_metric.py @@ -41,8 +41,9 @@ class Metric(Enum): UNSPECIFIED = deprecated_member( 0, - "Metric.UNSPECIFIED is deprecated; use the `int` value `0` " - "instead if you really need to check for this low-level value.", + "frequenz.client.common.metrics.Metric.UNSPECIFIED is deprecated since " + "v0.4.1. Use the int value 0 instead if you really need to check for " + "this low-level value.", ) """The metric is unspecified (this should not be used).""" diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index ea80c836..1c772065 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -78,8 +78,9 @@ class MetricConnectionCategory(Enum): UNSPECIFIED = deprecated_member( 0, - "MetricConnectionCategory.UNSPECIFIED is deprecated; use the `int` value `0` " - "instead if you really need to check for this low-level value.", + "frequenz.client.common.metrics.MetricConnectionCategory.UNSPECIFIED " + "is deprecated since v0.4.1. Use the int value 0 instead if you really " + "need to check for this low-level value.", ) """The connection category was not specified (do not use).""" @@ -273,6 +274,10 @@ def __init__( # noqa: DOC502 ) -> None: """Initialize this metric sample. + Deprecated: + The `bounds` argument is deprecated since v0.4.1. Use `bounds_set` + instead. + Args: sample_time: The moment when the metric was sampled, as a well-formed [`datetime`][datetime.datetime]. This spelling @@ -381,16 +386,23 @@ def __str__(self) -> str: return sample @property - @deprecated("`MetricSample.sample_time` is deprecated; use `sample_time2` instead.") + @deprecated( + "frequenz.client.common.metrics.MetricSample.sample_time is deprecated " + "since v0.4.1. Use " + "[frequenz.client.common.metrics.MetricSample.get_sample_time][] instead." + ) def sample_time(self) -> datetime: # noqa: DOC502 """The moment when the metric was sampled. - Warning: Deprecated - Use [`sample_time2`][..sample_time2] instead, or - [`get_sample_time()`][..get_sample_time] when a valid - [`datetime`][datetime.datetime] is required. This property keeps - the released `datetime` type, so it cannot express a malformed wire - timestamp and raises for one instead. + Deprecated: + This property is deprecated since v0.4.1. Use + [`get_sample_time()`][..get_sample_time] instead. + + This property keeps the released `datetime` type, so it cannot express + a malformed wire timestamp and raises for one instead, exactly like + [`get_sample_time()`][..get_sample_time]. Read + [`sample_time2`][..sample_time2] to get a malformed timestamp as an + [`InvalidDatetime`][....InvalidDatetime] instead. Returns: The sample time, when it is a valid @@ -404,16 +416,22 @@ def sample_time(self) -> datetime: # noqa: DOC502 return self.get_sample_time() @property - @deprecated("`MetricSample.bounds` is deprecated; use `bounds_set` instead.") + @deprecated( + "frequenz.client.common.metrics.MetricSample.bounds is deprecated " + "since v0.4.1. Use " + "[frequenz.client.common.metrics.MetricSample.bounds_set][] instead." + ) def bounds(self) -> list[Bounds]: """The valid bounds that apply to the metric sample. - Warning: Deprecated - Use `bounds_set` instead. For backward compatibility this returns - only the valid [`Bounds`][...Bounds] from `bounds_set` (dropping any - malformed entries, as the old field did), but it returns the - normalized, merged bounds rather than the raw list received on the - wire. + Deprecated: + This property is deprecated since v0.4.1. Use + [`bounds_set`][..bounds_set] instead. + + For backward compatibility this returns only the valid + [`Bounds`][...Bounds] from `bounds_set` (dropping any malformed entries, + as the old field did), but it returns the normalized, merged bounds + rather than the raw list received on the wire. Returns: The valid bounds in `bounds_set`. diff --git a/src/frequenz/client/common/metrics/proto/v1alpha8/_bounds.py b/src/frequenz/client/common/metrics/proto/v1alpha8/_bounds.py index 33368eb4..2181e782 100644 --- a/src/frequenz/client/common/metrics/proto/v1alpha8/_bounds.py +++ b/src/frequenz/client/common/metrics/proto/v1alpha8/_bounds.py @@ -13,17 +13,17 @@ @deprecated( - "`bounds_from_proto` is deprecated; use " - "`bounds_from_proto2` (returns `Bounds | InvalidBounds`) instead." + "frequenz.client.common.metrics.proto.v1alpha8.bounds_from_proto is " + "deprecated since v0.4.1. Use " + "[frequenz.client.common.metrics.proto.v1alpha8.bounds_from_proto2][] " + "instead." ) def bounds_from_proto(message: bounds_pb2.Bounds) -> Bounds: # noqa: DOC502 """Create a [`Bounds`][....Bounds] object from a protobuf message. - Warning: Deprecated - Use [`bounds_from_proto2`][..bounds_from_proto2] instead. The new - converter distinguishes well-formed from malformed data at the - type level (`Bounds | InvalidBounds`) rather than raising a - `ValueError` when the invariant fires. + [`bounds_from_proto2`][..bounds_from_proto2] distinguishes well-formed from + malformed data at the type level (`Bounds | InvalidBounds`) rather than + raising a `ValueError` when the invariant fires. Args: message: The protobuf message to convert. @@ -101,9 +101,9 @@ def bounds_set_from_proto( @deprecated( - "`bounds_from_proto_with_issues` is deprecated; use " - "`bounds_from_proto2` (returns `Bounds | InvalidBounds`) and inspect " - "the returned type instead." + "frequenz.client.common.metrics.proto.v1alpha8.bounds_from_proto_with_issues " + "is deprecated since v0.4.1. Use " + "[frequenz.client.common.metrics.proto.v1alpha8.bounds_from_proto2][] instead." ) def bounds_from_proto_with_issues( message: bounds_pb2.Bounds, @@ -113,12 +113,9 @@ def bounds_from_proto_with_issues( ) -> Bounds | None: # noqa: DOC502 """Create a [`Bounds`][....Bounds] object from a protobuf message, collecting issues. - Warning: Deprecated - Use [`bounds_from_proto2`][..bounds_from_proto2] instead and - inspect the returned type. The new converter distinguishes - well-formed from malformed data at the type level - (`Bounds | InvalidBounds`) rather than routing invalid data - through a side-channel string list. + [`bounds_from_proto2`][..bounds_from_proto2] distinguishes well-formed from + malformed data at the type level (`Bounds | InvalidBounds`) rather than + routing invalid data through a side-channel string list. Args: message: The protobuf message to convert. diff --git a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py index c2abadad..02e27a51 100644 --- a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py +++ b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py @@ -122,8 +122,10 @@ def metric_sample_from_proto( @deprecated( - "`metric_connection_from_proto_with_issues` is deprecated; use " - "`metric_connection_from_proto` and inspect the returned type instead." + "frequenz.client.common.metrics.proto.v1alpha8." + "metric_connection_from_proto_with_issues is deprecated since v0.4.1. Use " + "[frequenz.client.common.metrics.proto.v1alpha8.metric_connection_from_proto][] " + "instead." ) def metric_connection_from_proto_with_issues( message: metrics_pb2.MetricConnection, @@ -133,12 +135,10 @@ def metric_connection_from_proto_with_issues( ) -> MetricConnection: """Convert a protobuf message to a [`MetricConnection`][....MetricConnection] object. - Warning: Deprecated - Use [`metric_connection_from_proto`][..metric_connection_from_proto] - instead and inspect the returned type. The new converter encodes an - unspecified or unrecognized category in the returned - `MetricConnection.category` field (`MetricConnectionCategory | int`) - rather than routing it through a side-channel string list. + [`metric_connection_from_proto`][..metric_connection_from_proto] encodes an + unspecified or unrecognized category in the returned + `MetricConnection.category` field (`MetricConnectionCategory | int`) rather + than routing it through a side-channel string list. Args: message: The protobuf message to convert. @@ -165,8 +165,10 @@ def metric_connection_from_proto_with_issues( @deprecated( - "`metric_sample_from_proto_with_issues` is deprecated; use " - "`metric_sample_from_proto` and inspect the returned type instead." + "frequenz.client.common.metrics.proto.v1alpha8." + "metric_sample_from_proto_with_issues is deprecated since v0.4.1. Use " + "[frequenz.client.common.metrics.proto.v1alpha8.metric_sample_from_proto][] " + "and inspect the returned type instead." ) def metric_sample_from_proto_with_issues( message: metrics_pb2.MetricSample, @@ -176,13 +178,11 @@ def metric_sample_from_proto_with_issues( ) -> MetricSample: """Convert a protobuf message to a [`MetricSample`][....MetricSample] object. - Warning: Deprecated - Use [`metric_sample_from_proto`][..metric_sample_from_proto] instead - and inspect the returned type. The new converter encodes an - unspecified or unrecognized `metric` (`Metric | int`), malformed - bounds (`InvalidBoundsSet`) and an unrepresentable sample time - (`InvalidDatetime`) in the returned `MetricSample` rather than - routing them through a side-channel string list. + [`metric_sample_from_proto`][..metric_sample_from_proto] encodes an + unspecified or unrecognized `metric` (`Metric | int`), malformed bounds + (`InvalidBoundsSet`) and an unrepresentable sample time (`InvalidDatetime`) + in the returned `MetricSample` rather than routing them through a + side-channel string list. Note: A malformed `sample_time` still raises `ValueError`, as it did when the diff --git a/src/frequenz/client/common/microgrid/components/__init__.py b/src/frequenz/client/common/microgrid/components/__init__.py index 5c1037c3..88bdd4c1 100644 --- a/src/frequenz/client/common/microgrid/components/__init__.py +++ b/src/frequenz/client/common/microgrid/components/__init__.py @@ -10,9 +10,9 @@ @deprecated( - "frequenz.client.common.microgrid.components.ComponentId is deprecated. " - "Use frequenz.client.common.microgrid.electrical_components." - "ElectricalComponentId instead." + "frequenz.client.common.microgrid.components.ComponentId is deprecated " + "since v0.4.1. Use [frequenz.client.common.microgrid." + "electrical_components.ElectricalComponentId][] instead." ) @final class ComponentId(BaseId, str_prefix="CID"): diff --git a/src/frequenz/client/common/microgrid/electrical_components/_category.py b/src/frequenz/client/common/microgrid/electrical_components/_category.py index 1843f99d..9498abd6 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_category.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_category.py @@ -6,10 +6,12 @@ import typing_extensions from frequenz.core.enum import Enum, deprecated_member, unique -_DEPRECATION_MESSAGE = ( - "ElectricalComponentCategory is deprecated; use the ElectricalComponent class " - "hierarchy (isinstance) or electrical_component_class_to_proto()/" - "electrical_component_class_from_proto()." +_QUALNAME = "frequenz.client.common.microgrid.electrical_components" + +_REPLACEMENT = ( + f"Use the [{_QUALNAME}.ElectricalComponent][] class hierarchy with isinstance(), " + f"or [{_QUALNAME}.proto.v1alpha8.electrical_component_class_to_proto][] and " + f"[{_QUALNAME}.proto.v1alpha8.electrical_component_class_from_proto][], instead." ) @@ -23,82 +25,212 @@ def _member_message(name: str) -> str: The full deprecation message for that member. """ return ( - f"ElectricalComponentCategory.{name} is deprecated; use the " - "ElectricalComponent class hierarchy (isinstance) or " - "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + f"{_QUALNAME}.ElectricalComponentCategory.{name} is deprecated since " + f"v0.4.1. {_REPLACEMENT}" ) -@typing_extensions.deprecated(_DEPRECATION_MESSAGE) +# The message is spelled out as a literal rather than built from the constants +# above because griffe-warnings-deprecated only renders the admonition when it +# can read the message statically; a module-level name renders nothing. +@typing_extensions.deprecated( + "frequenz.client.common.microgrid.electrical_components." + "ElectricalComponentCategory is deprecated since v0.4.1. Use the " + "[frequenz.client.common.microgrid.electrical_components." + "ElectricalComponent][] class hierarchy with isinstance(), or " + "[frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "electrical_component_class_to_proto][] and " + "[frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "electrical_component_class_from_proto][], instead." +) @unique class ElectricalComponentCategory(Enum): """Possible types of microgrid electrical component.""" UNSPECIFIED = deprecated_member(0, _member_message("UNSPECIFIED")) - """The component category is unspecified. This should not be used.""" + """The component category is unspecified. This should not be used. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ GRID_CONNECTION_POINT = deprecated_member( 1, _member_message("GRID_CONNECTION_POINT") ) - """The point where the local microgrid is connected to the grid.""" + """The point where the local microgrid is connected to the grid. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ METER = deprecated_member(2, _member_message("METER")) - """A meter, for measuring electrical metrics, e.g., current, voltage, etc.""" + """A meter, for measuring electrical metrics, e.g., current, voltage, etc. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ INVERTER = deprecated_member(3, _member_message("INVERTER")) - """An inverter that converts DC to AC power and vice versa.""" + """An inverter that converts DC to AC power and vice versa. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ CONVERTER = deprecated_member(4, _member_message("CONVERTER")) - """An electricity converter, e.g., a DC-DC converter.""" + """An electricity converter, e.g., a DC-DC converter. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ BATTERY = deprecated_member(5, _member_message("BATTERY")) - """A battery energy storage system.""" + """A battery energy storage system. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ EV_CHARGER = deprecated_member(6, _member_message("EV_CHARGER")) - """A station for charging electrical vehicles.""" + """A station for charging electrical vehicles. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ BREAKER = deprecated_member(7, _member_message("BREAKER")) - """A circuit breaker, providing protection and switching by disconnecting circuits.""" + """A circuit breaker, providing protection and switching by disconnecting circuits. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ PRECHARGER = deprecated_member(8, _member_message("PRECHARGER")) - """A precharger, used for preparing electrical circuits for switching on.""" + """A precharger, used for preparing electrical circuits for switching on. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ CHP = deprecated_member(9, _member_message("CHP")) """A combined heat and power (CHP) plant. + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + It generates electricity and useful heat from a single energy source. """ ELECTROLYZER = deprecated_member(10, _member_message("ELECTROLYZER")) - """A device for splitting water into hydrogen and oxygen using electricity.""" + """A device for splitting water into hydrogen and oxygen using electricity. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ POWER_TRANSFORMER = deprecated_member(11, _member_message("POWER_TRANSFORMER")) - """A transformer, used for changing the voltage of electrical circuits.""" + """A transformer, used for changing the voltage of electrical circuits. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ HVAC = deprecated_member(12, _member_message("HVAC")) - """A heating, ventilation, and air conditioning (HVAC) system.""" + """A heating, ventilation, and air conditioning (HVAC) system. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ PLC = deprecated_member(13, _member_message("PLC")) - """A programmable logic controller (PLC).""" + """A programmable logic controller (PLC). + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ CRYPTO_MINER = deprecated_member(14, _member_message("CRYPTO_MINER")) - """A device for mining cryptocurrencies.""" + """A device for mining cryptocurrencies. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ STATIC_TRANSFER_SWITCH = deprecated_member( 15, _member_message("STATIC_TRANSFER_SWITCH") ) - """A static transfer switch, used for switching between power sources.""" + """A static transfer switch, used for switching between power sources. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ UNINTERRUPTIBLE_POWER_SUPPLY = deprecated_member( 16, _member_message("UNINTERRUPTIBLE_POWER_SUPPLY") ) - """An uninterruptible power supply (UPS), used to provide backup power.""" + """An uninterruptible power supply (UPS), used to provide backup power. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ CAPACITOR_BANK = deprecated_member(17, _member_message("CAPACITOR_BANK")) - """A capacitor bank, used for power factor correction and reactive power compensation.""" + """A capacitor bank, used for power factor correction and reactive power compensation. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ WIND_TURBINE = deprecated_member(18, _member_message("WIND_TURBINE")) - """A wind turbine, used to generate electricity from wind energy.""" + """A wind turbine, used to generate electricity from wind energy. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ STEAM_BOILER = deprecated_member(19, _member_message("STEAM_BOILER")) - """A steam boiler, used to generate steam for heating or industrial processes.""" + """A steam boiler, used to generate steam for heating or industrial processes. + + Deprecated: + This member is deprecated since v0.4.1. See + [`ElectricalComponentCategory`][...ElectricalComponentCategory] for + what to use instead. + """ diff --git a/src/frequenz/client/common/microgrid/electrical_components/_diagnostic_code.py b/src/frequenz/client/common/microgrid/electrical_components/_diagnostic_code.py index 092da57f..33d1ff33 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_diagnostic_code.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_diagnostic_code.py @@ -12,8 +12,10 @@ class ElectricalComponentDiagnosticCode(Enum): UNSPECIFIED = deprecated_member( 0, - "ElectricalComponentDiagnosticCode.UNSPECIFIED is deprecated; use the `int` value `0` " - "instead if you really need to check for this low-level value.", + "frequenz.client.common.microgrid.electrical_components." + "ElectricalComponentDiagnosticCode.UNSPECIFIED is deprecated since " + "v0.4.1. Use the int value 0 instead if you really need to check for " + "this low-level value.", ) """Default value. No specific error is specified.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_state_code.py b/src/frequenz/client/common/microgrid/electrical_components/_state_code.py index 36bd66dd..35264b5f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_state_code.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_state_code.py @@ -12,8 +12,10 @@ class ElectricalComponentStateCode(Enum): UNSPECIFIED = deprecated_member( 0, - "ElectricalComponentStateCode.UNSPECIFIED is deprecated; use the `int` value `0` " - "instead if you really need to check for this low-level value.", + "frequenz.client.common.microgrid.electrical_components." + "ElectricalComponentStateCode.UNSPECIFIED is deprecated since v0.4.1. " + "Use the int value 0 instead if you really need to check for this " + "low-level value.", ) """Default value when the component state is not explicitly set.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py index 1abc3305..1f807e6d 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py @@ -15,8 +15,10 @@ @typing_extensions.deprecated( - "electrical_component_category_from_proto() is deprecated; use " - "electrical_component_class_from_proto() instead." + "frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "electrical_component_category_from_proto is deprecated since v0.4.1. Use " + "[frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "electrical_component_class_from_proto][] instead." ) def electrical_component_category_from_proto( message: electrical_components_pb2.ElectricalComponentCategory.ValueType, @@ -37,8 +39,10 @@ def electrical_component_category_from_proto( @typing_extensions.deprecated( - "electrical_component_category_to_proto() is deprecated; use " - "electrical_component_class_to_proto() instead." + "frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "electrical_component_category_to_proto is deprecated since v0.4.1. Use " + "[frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "electrical_component_class_to_proto][] instead." ) def electrical_component_category_to_proto( category: ElectricalComponentCategory, diff --git a/src/frequenz/client/common/pagination/proto/v1alpha8/_pagination_info.py b/src/frequenz/client/common/pagination/proto/v1alpha8/_pagination_info.py index f510b2ec..de511bbe 100644 --- a/src/frequenz/client/common/pagination/proto/v1alpha8/_pagination_info.py +++ b/src/frequenz/client/common/pagination/proto/v1alpha8/_pagination_info.py @@ -13,8 +13,8 @@ @deprecated( "frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto " - "is deprecated. Use " - "frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto2 " + "is deprecated since v0.4.1. Use " + "[frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto2][] " "instead." ) def pagination_info_from_proto( # noqa: DOC502 @@ -22,11 +22,10 @@ def pagination_info_from_proto( # noqa: DOC502 ) -> PaginationInfo: """Convert a protobuf message to a [`PaginationInfo`][....PaginationInfo] object. - Warning: Deprecated - Use [`pagination_info_from_proto2`][..pagination_info_from_proto2] - instead. The new converter distinguishes well-formed from malformed - data at the type level (`PaginationInfo | InvalidPaginationInfo`) - rather than raising a `ValueError` when the invariant fires. + [`pagination_info_from_proto2`][..pagination_info_from_proto2] distinguishes + well-formed from malformed data at the type level + (`PaginationInfo | InvalidPaginationInfo`) rather than raising a + `ValueError` when the invariant fires. Args: message: The protobuf message to convert. diff --git a/src/frequenz/client/common/proto/_datetime.py b/src/frequenz/client/common/proto/_datetime.py index 80bf1f0c..a59d557d 100644 --- a/src/frequenz/client/common/proto/_datetime.py +++ b/src/frequenz/client/common/proto/_datetime.py @@ -60,22 +60,20 @@ def datetime_to_proto(dt: datetime | None) -> timestamp_pb2.Timestamp | None: @deprecated( - "`datetime_from_proto` is deprecated; use " - "`datetime_from_proto2` (returns `datetime | InvalidDatetime`) instead." + "frequenz.client.common.proto.datetime_from_proto is deprecated since " + "v0.4.1. Use [frequenz.client.common.proto.datetime_from_proto2][] instead." ) def datetime_from_proto( # noqa: DOC502 ts: timestamp_pb2.Timestamp, tz: timezone = timezone.utc ) -> datetime: """Convert a protobuf Timestamp to a datetime. - Warning: Deprecated - Use [`datetime_from_proto2`][..datetime_from_proto2] instead. The new - conversion function keeps a malformed timestamp in its return type - (`datetime | InvalidDatetime`) rather than raising or silently - repairing it, and is exact across the whole protobuf range, where this - function loses sub-second precision far from the epoch. It always - returns UTC; call [`astimezone()`][datetime.datetime.astimezone] on the - result instead of passing `tz`. + [`datetime_from_proto2`][..datetime_from_proto2] keeps a malformed + timestamp in its return type (`datetime | InvalidDatetime`) rather than + raising or silently repairing it, and is exact across the whole protobuf + range, where this function loses sub-second precision far from the epoch. + It always returns UTC; call [`astimezone()`][datetime.datetime.astimezone] + on the result instead of passing `tz`. Args: ts: The Timestamp object to convert. diff --git a/src/frequenz/client/common/streaming/_event.py b/src/frequenz/client/common/streaming/_event.py index 6907b476..e05d19db 100644 --- a/src/frequenz/client/common/streaming/_event.py +++ b/src/frequenz/client/common/streaming/_event.py @@ -12,8 +12,9 @@ class Event(Enum): UNSPECIFIED = deprecated_member( 0, - "Event.UNSPECIFIED is deprecated; use the `int` value `0` " - "instead if you really need to check for this low-level value.", + "frequenz.client.common.streaming.Event.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 event type.""" diff --git a/tests/metrics/test_sample_metric_sample.py b/tests/metrics/test_sample_metric_sample.py index 1b90f58c..e40c4099 100644 --- a/tests/metrics/test_sample_metric_sample.py +++ b/tests/metrics/test_sample_metric_sample.py @@ -413,7 +413,12 @@ def test_deprecated_sample_time_property(now: datetime) -> None: ) assert sample.sample_time2 is now with pytest.deprecated_call( - match="`MetricSample.sample_time` is deprecated; use `sample_time2` instead." + match=( + r"^frequenz\.client\.common\.metrics\.MetricSample\.sample_time is " + r"deprecated since v0\.4\.1\. Use " + r"\[frequenz\.client\.common\.metrics\.MetricSample\.get_sample_time\]" + r"\[\] instead\.$" + ) ): assert sample.sample_time is now diff --git a/tests/pagination/proto/v1alpha8/test_pagination_info.py b/tests/pagination/proto/v1alpha8/test_pagination_info.py index 9230b803..d2aea970 100644 --- a/tests/pagination/proto/v1alpha8/test_pagination_info.py +++ b/tests/pagination/proto/v1alpha8/test_pagination_info.py @@ -39,9 +39,9 @@ def test_from_proto_emits_deprecation_warning() -> None: proto = pagination_info_pb2.PaginationInfo(total_items=1) with pytest.deprecated_call( match=r"^frequenz\.client\.common\.pagination\.proto\.v1alpha8\." - r"pagination_info_from_proto is deprecated\. Use " - r"frequenz\.client\.common\.pagination\.proto\.v1alpha8\." - r"pagination_info_from_proto2 instead\.$" + r"pagination_info_from_proto is deprecated since v0\.4\.1\. Use " + r"\[frequenz\.client\.common\.pagination\.proto\.v1alpha8\." + r"pagination_info_from_proto2\]\[\] instead\.$" ): pagination_info_from_proto(proto) diff --git a/tests/proto/test_datetime.py b/tests/proto/test_datetime.py index eb2b82d8..0c63e51a 100644 --- a/tests/proto/test_datetime.py +++ b/tests/proto/test_datetime.py @@ -83,8 +83,9 @@ def test_no_none_datetime(dt: datetime) -> None: def test_from_proto_is_deprecated() -> None: """`datetime_from_proto` warns and still converts as it always did.""" with pytest.deprecated_call( - match=r"`datetime_from_proto` is deprecated; use `datetime_from_proto2` " - r"\(returns `datetime \| InvalidDatetime`\) instead\." + match=r"^frequenz\.client\.common\.proto\.datetime_from_proto is " + r"deprecated since v0\.4\.1\. Use " + r"\[frequenz\.client\.common\.proto\.datetime_from_proto2\]\[\] instead\.$" ): converted = datetime_from_proto(Timestamp(seconds=1, nanos=500000000)) assert converted == datetime(1970, 1, 1, 0, 0, 1, 500000, tzinfo=timezone.utc) From c2aafe341f8b828aef82ac18aa4a1d71f1f2e3c5 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 21 Sep 2026 17:08:56 +0000 Subject: [PATCH 4/8] docs: Update the wrapping guide to the new deprecation style The previous commits changed every deprecation notice in the repo but left the wrapping guide describing the old message form, `" is deprecated. Use instead."`, with no version and no cross-reference brackets. The guide is what a contributor reads before writing a new deprecation, so leaving it behind would reintroduce the old form. It now describes the form the code uses and why: the version in the sentence because a separate "since" line cannot be expressed through the decorator, the replacement as a bare `[name][]` cross-reference so the generated admonition links to it, no backticks because the same string is printed as a runtime warning, and implicit concatenation of single-line strings because a triple-quoted message carries its indentation into both the cross-reference and the terminal. The worked example is updated to match, and now escapes the brackets and dots in its `pytest.deprecated_call()` regex. That is easy to get wrong silently, because `match` is a search and an unescaped `[...]` is a character class that still matches something. It also explains where the admonition comes from, and so why the message has to be made of string literals: the documentation build reads it without running the code, and renders nothing for a message it can't read. The cases that still need a hand-written `Deprecated:` admonition are listed, since the guide never mentioned them: an argument, construction being made stricter, a property, and a message that isn't a literal. The `deprecated_member` paragraph now says its message gets the same treatment, with an example. Signed-off-by: Leandro Lucarella --- .../deprecation-and-compatibility.md | 71 ++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/docs/wrapping-guide/deprecation-and-compatibility.md b/docs/wrapping-guide/deprecation-and-compatibility.md index 4009b81a..10c56727 100644 --- a/docs/wrapping-guide/deprecation-and-compatibility.md +++ b/docs/wrapping-guide/deprecation-and-compatibility.md @@ -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: `" is deprecated. Use 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: `" is deprecated since v. Use [][] +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 @@ -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 @@ -65,8 +96,32 @@ 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`.""" +``` ## Tighten invariants in stages From 449a80ef9b85e2bca9c1401fd218963e8330943d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 28 Sep 2026 15:45:07 +0000 Subject: [PATCH 5/8] Depend on frequenz-core 1.5.0 for the warnings utilities The next commits use `frequenz.core.warnings`, which first shipped in frequenz-core v1.5.0: the module itself was added by frequenz-floss/frequenz-core-python#199, and the per-alias `DeprecatedAlias` messages used here by #200. Signed-off-by: Leandro Lucarella --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index afe664ff..70f1fe61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From a96d9585693e6ff2b326078468cacfdacbea60b8 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 28 Sep 2026 15:47:05 +0000 Subject: [PATCH 6/8] Silence internal deprecations without resetting the warnings history The library silences the deprecation warnings it raises itself, for example when a converter touches a deprecated `UNSPECIFIED` member, with a `warnings.catch_warnings()` block plus an "ignore" filter. Entering and leaving such a block invalidates the deduplication history of every module (python/cpython#73858), so every warning that was already shown, from this library or from anywhere else, is shown again after each call. Downstream this turned into tens of thousands of repeated warnings in a single test run. Replace all 16 blocks with `frequenz.core.warnings.ignoring_deprecations()`, which adds the filter without touching the history. The blocks keep their current extent, so exactly the same warnings are silenced as before; narrowing some of them is a separate change. The `EnumParityTest` scaffold gets the same treatment: it ships in the package and runs inside downstream test suites. Two regression tests call a deprecated converter and a `str()` that silences a deprecation in a loop, under the "default" action, and check that each warning is shown only once. Both fail with the old blocks. Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 1 + .../client/common/grid/_delivery_area.py | 10 +++----- .../grid/proto/v1alpha8/_delivery_area.py | 5 ++-- src/frequenz/client/common/metrics/_sample.py | 13 ++++------ .../common/metrics/proto/v1alpha8/_sample.py | 6 ++--- .../proto/v1alpha8/_category.py | 9 +++---- .../proto/v1alpha8/_electrical_component.py | 11 +++----- .../client/common/test/enum_parity.py | 8 +++--- .../grid/proto/v1alpha8/test_delivery_area.py | 25 +++++++++++++++++++ .../metrics/test_sample_metric_connection.py | 19 ++++++++++++++ 10 files changed, 68 insertions(+), 39 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 142ac5b7..11c0385f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 `` 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. diff --git a/src/frequenz/client/common/grid/_delivery_area.py b/src/frequenz/client/common/grid/_delivery_area.py index 9f1447f2..820b3619 100644 --- a/src/frequenz/client/common/grid/_delivery_area.py +++ b/src/frequenz/client/common/grid/_delivery_area.py @@ -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, @@ -162,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: @@ -201,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") @@ -229,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=" diff --git a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py index ac23b722..d2d8ec94 100644 --- a/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py +++ b/src/frequenz/client/common/grid/proto/v1alpha8/_delivery_area.py @@ -4,9 +4,9 @@ """Conversion of DeliveryArea and EnergyMarketCodeType to/from protobuf v1alpha8.""" import logging -import warnings from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 +from frequenz.core.warnings import ignoring_deprecations from typing_extensions import deprecated from ....proto import enum_from_proto @@ -94,8 +94,7 @@ def delivery_area_from_proto( # noqa: DOC502 # invalid data. This function is `@deprecated` itself, callers will see the # outer notice pointing to `delivery_area_from_proto2`. Suppress the inner # warning here so we don't double-warn. - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): return DeliveryArea(code=code, code_type=code_type) diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index 1c772065..1ead06df 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -11,6 +11,7 @@ from frequenz.core.enum import Enum, deprecated_member, unique from frequenz.core.typing import FloatInt +from frequenz.core.warnings import ignoring_deprecations from typing_extensions import deprecated from .._datetime import InvalidDatetime, InvalidDatetimeError @@ -130,8 +131,7 @@ class MetricConnection: def __str__(self) -> str: """Return a string representation of this connection.""" - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): match self.category: case 0 | MetricConnectionCategory.UNSPECIFIED: category_name = "cat=" @@ -161,8 +161,7 @@ def get_category(self) -> MetricConnectionCategory: client does not recognize. The raw value is available on the error's `value` attribute. """ - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): match self.category: case 0 | MetricConnectionCategory.UNSPECIFIED: raise UnspecifiedEnumValueError(self, "category") @@ -369,8 +368,7 @@ def _resolve_bounds_set( def __str__(self) -> str: """Return a compact string representation of this sample.""" - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): match self.metric: case 0 | Metric.UNSPECIFIED: metric = "" @@ -517,8 +515,7 @@ def get_metric(self) -> Metric: does not recognize. The raw value is available on the error's `value` attribute. """ - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): match self.metric: case 0 | Metric.UNSPECIFIED: raise UnspecifiedEnumValueError(self, "metric") diff --git a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py index 02e27a51..b4b2cae4 100644 --- a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py +++ b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py @@ -3,9 +3,8 @@ """Loading of MetricSample and AggregatedMetricValue objects from protobuf messages.""" -import warnings - from frequenz.api.common.v1alpha8.metrics import metrics_pb2 +from frequenz.core.warnings import ignoring_deprecations from typing_extensions import deprecated from ...._datetime import InvalidDatetime @@ -228,8 +227,7 @@ def metric_sample_from_proto_with_issues( connection = None if message.HasField("connection"): - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): connection = metric_connection_from_proto_with_issues( message.connection, major_issues=major_issues, minor_issues=minor_issues ) diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py index 1f807e6d..a838a198 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py @@ -3,12 +3,11 @@ """Conversion of electrical component categories to/from protobuf v1alpha8.""" -import warnings - import typing_extensions from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import ignoring_deprecations from .....proto import enum_from_proto from ... import ElectricalComponentCategory @@ -33,8 +32,7 @@ def electrical_component_category_from_proto( [`ElectricalComponentCategory`][....ElectricalComponentCategory] enum member, or the raw [`int`][] if the protobuf value is not recognized. """ - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): return enum_from_proto(message, ElectricalComponentCategory) @@ -57,8 +55,7 @@ def electrical_component_category_to_proto( Returns: The corresponding protobuf `ElectricalComponentCategory` value. """ - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): return electrical_components_pb2.ElectricalComponentCategory.ValueType( category.value ) diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py index 84f06aa1..68d1c33b 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py @@ -3,7 +3,6 @@ """Conversion of electrical components to/from protobuf v1alpha8.""" -import warnings from collections.abc import Mapping, Sequence from typing import Final, NamedTuple, TypeAlias, assert_never, overload @@ -11,6 +10,7 @@ from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import ignoring_deprecations from google.protobuf.json_format import MessageToDict from .....metrics import BoundsSet, InvalidBoundsSet, Metric @@ -938,8 +938,7 @@ def _electrical_component_base_from_proto( Returns: An `_ElectricalComponentBaseData` named tuple containing the extracted data. """ - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): component_id = ElectricalComponentId(message.id) microgrid_id = MicrogridId(message.microgrid_id) @@ -1012,8 +1011,7 @@ def electrical_component_from_proto( Returns: The resulting electrical component instance. """ - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): base_data = _electrical_component_base_from_proto(message) if base_data.category_mismatched: @@ -1242,8 +1240,7 @@ def _metric_config_bounds_from_proto( """ grouped: dict[Metric | int, list[bounds_pb2.Bounds]] = {} for metric_bound in message: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with ignoring_deprecations(): metric = enum_from_proto(metric_bound.metric, Metric) if metric is Metric.UNSPECIFIED: metric = metric.value diff --git a/src/frequenz/client/common/test/enum_parity.py b/src/frequenz/client/common/test/enum_parity.py index ec1fc369..a0e46c5f 100644 --- a/src/frequenz/client/common/test/enum_parity.py +++ b/src/frequenz/client/common/test/enum_parity.py @@ -22,12 +22,12 @@ from __future__ import annotations import contextlib -import warnings from collections.abc import Callable, Iterator from enum import Enum from typing import Any, ClassVar import pytest +from frequenz.core.warnings import ignoring_deprecations class EnumParityTest: @@ -162,8 +162,7 @@ def _maybe_ignore_deprecation(self, name: str) -> Iterator[None]: when ``name`` is in `deprecated_members`. """ if name in self.deprecated_members: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): yield else: yield @@ -182,8 +181,7 @@ def _maybe_silence_converter_deprecation(self) -> Iterator[None]: when `silence_deprecations` is `True`. """ if self.silence_deprecations: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): yield else: yield diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index c11dab4f..6c27e06d 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -154,6 +154,31 @@ def test_from_proto_emits_deprecation_warning() -> None: delivery_area_from_proto(proto) +def test_from_proto_keeps_warnings_deduplicated() -> None: + """Silencing its internal deprecation doesn't make warnings show again. + + A `warnings.catch_warnings()` block resets the deduplication history on + every call (python/cpython#73858), so both warnings would show on every + iteration instead of once. + """ + proto = delivery_area_pb2.DeliveryArea( + code="DE", + code_type=( + delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC + ), + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("default") + for _ in range(3): + warnings.warn("an unrelated warning", UserWarning) + delivery_area_from_proto(proto) + + assert [warning.category for warning in caught] == [ + UserWarning, + DeprecationWarning, + ] + + @dataclass(frozen=True, kw_only=True) class _FromProto2TestCase: """Test case for `delivery_area_from_proto2` conversion.""" diff --git a/tests/metrics/test_sample_metric_connection.py b/tests/metrics/test_sample_metric_connection.py index 1b2cfe1b..474828f0 100644 --- a/tests/metrics/test_sample_metric_connection.py +++ b/tests/metrics/test_sample_metric_connection.py @@ -3,6 +3,8 @@ """Tests for MetricConnection and MetricConnectionCategory classes.""" +import warnings + import pytest from frequenz.client.common import ( @@ -137,6 +139,23 @@ def test_get_category_unspecified_member_raises() -> None: connection.get_category() +def test_str_keeps_warnings_deduplicated() -> None: + """Silencing the internal deprecation doesn't make warnings show again. + + A `warnings.catch_warnings()` block resets the deduplication history on + every call (python/cpython#73858), so the unrelated warning would show on + every iteration instead of once. + """ + connection = MetricConnection(category=0) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("default") + for _ in range(3): + warnings.warn("an unrelated warning", UserWarning) + assert str(connection) == ":cat=" + + assert [warning.category for warning in caught] == [UserWarning] + + def test_get_category_unrecognized_int_raises() -> None: """get_category raises UnrecognizedEnumValueError carrying the raw int value.""" connection = MetricConnection(category=99999) From ad5c8a762531a080f4e7e7a2e956fdf9fd5383bc Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 28 Sep 2026 15:48:00 +0000 Subject: [PATCH 7/8] Use the core warnings helpers in the tests The tests used `warnings.catch_warnings()` blocks for two different things, now done by the `frequenz.core.warnings` helper made for each: * An "ignore" filter, to call deprecated APIs the test needs as setup, becomes `ignoring_deprecations()`, like in the library. * An "error" filter, to check that a replacement API doesn't go through a deprecated one, becomes `asserting_no_deprecations()`. It records the warnings instead of raising them inside the code under test, so a broad `except` there can't swallow the failure, and it reports every offending warning with its location when the block ends. The blocks that record warnings to check what a helper lets through are left alone: that is what `catch_warnings(record=True)` is for. Signed-off-by: Leandro Lucarella --- .../grid/_delivery_area/test_delivery_area.py | 20 +++++++------------ .../test_invalid_delivery_area.py | 5 ++--- .../grid/proto/v1alpha8/test_delivery_area.py | 4 ++-- .../v1alpha8/test_sample_metric_connection.py | 6 ++---- .../v1alpha8/test_sample_metric_sample.py | 5 ++--- .../proto/v1alpha8/conftest.py | 5 ++--- .../test_electrical_component_base.py | 20 +++++++------------ .../test_electrical_component_simple.py | 12 ++++------- .../test_electrical_component_with_type.py | 12 ++++------- .../proto/v1alpha8/test_enum_deprecation.py | 9 +++------ .../proto/v1alpha8/test_raw_storage.py | 11 ++++------ tests/test/test_enum_parity.py | 7 +++---- 12 files changed, 42 insertions(+), 74 deletions(-) diff --git a/tests/grid/_delivery_area/test_delivery_area.py b/tests/grid/_delivery_area/test_delivery_area.py index eb9b5c1b..cc1619f6 100644 --- a/tests/grid/_delivery_area/test_delivery_area.py +++ b/tests/grid/_delivery_area/test_delivery_area.py @@ -3,10 +3,10 @@ """Tests for the DeliveryArea class.""" -import warnings from dataclasses import dataclass import pytest +from frequenz.core.warnings import asserting_no_deprecations from frequenz.client.common import ( UnrecognizedEnumValueError, @@ -62,8 +62,7 @@ class _TestCase: ) def test_creation_valid(case: _TestCase) -> None: """Well-formed DeliveryArea construction succeeds without warnings.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): area = DeliveryArea(code=case.code, code_type=case.code_type) assert area.code == case.code assert area.code_type == case.code_type @@ -161,8 +160,7 @@ def test_creation_with_unspecified_code_type_member_emits_deprecation_warning() ) def test_creation_raises_on_invalid_code(case: _TestCase) -> None: """`_raise_on_invalid=True` raises `ValueError` on empty/`None` code.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): with pytest.raises(ValueError, match="`code` cannot be None or empty"): DeliveryArea( code=case.code, @@ -173,8 +171,7 @@ def test_creation_raises_on_invalid_code(case: _TestCase) -> None: def test_creation_raises_on_unspecified_int_code_type() -> None: """`_raise_on_invalid=True` raises `ValueError` on `code_type=0`.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): with pytest.raises( ValueError, match="`code_type` cannot be 0 \\(UNSPECIFIED\\)" ): @@ -185,8 +182,7 @@ def test_creation_raises_on_unspecified_member_code_type() -> None: """`_raise_on_invalid=True` raises `ValueError` on the UNSPECIFIED member.""" with pytest.deprecated_call(): unspecified = EnergyMarketCodeType.UNSPECIFIED - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): with pytest.raises( ValueError, match="`code_type` cannot be 0 \\(UNSPECIFIED\\)" ): @@ -195,8 +191,7 @@ def test_creation_raises_on_unspecified_member_code_type() -> None: def test_creation_does_not_raise_when_valid() -> None: """`_raise_on_invalid=True` does not raise on well-formed data.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): area = DeliveryArea( code="DE", code_type=EnergyMarketCodeType.EUROPE_EIC, @@ -263,8 +258,7 @@ def test_get_code_type_raises_unspecified_for_value_zero_member() -> None: unspecified = EnergyMarketCodeType.UNSPECIFIED with pytest.deprecated_call(): area = DeliveryArea(code="TEST", code_type=unspecified) - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): with pytest.raises(UnspecifiedEnumValueError): area.get_code_type() diff --git a/tests/grid/_delivery_area/test_invalid_delivery_area.py b/tests/grid/_delivery_area/test_invalid_delivery_area.py index 2c5f6024..8dd1945c 100644 --- a/tests/grid/_delivery_area/test_invalid_delivery_area.py +++ b/tests/grid/_delivery_area/test_invalid_delivery_area.py @@ -3,10 +3,10 @@ """Tests for the InvalidDeliveryArea class.""" -import warnings from dataclasses import dataclass import pytest +from frequenz.core.warnings import asserting_no_deprecations from frequenz.client.common.grid import ( BaseDeliveryArea, @@ -70,8 +70,7 @@ def test_is_base_delivery_area_subclass() -> None: ) def test_creation(case: _TestCase) -> None: """`InvalidDeliveryArea` accepts any data with no invariants and no warnings.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): area = InvalidDeliveryArea(code=case.code, code_type=case.code_type) assert area.code == case.code assert area.code_type == case.code_type diff --git a/tests/grid/proto/v1alpha8/test_delivery_area.py b/tests/grid/proto/v1alpha8/test_delivery_area.py index 6c27e06d..f2b5771d 100644 --- a/tests/grid/proto/v1alpha8/test_delivery_area.py +++ b/tests/grid/proto/v1alpha8/test_delivery_area.py @@ -8,6 +8,7 @@ import pytest from frequenz.api.common.v1alpha8.grid import delivery_area_pb2 +from frequenz.core.warnings import asserting_no_deprecations from frequenz.client.common import UnspecifiedEnumValueError from frequenz.client.common.grid import ( @@ -264,8 +265,7 @@ def test_from_proto2( code=case.code, code_type=case.code_type # type: ignore[arg-type] ) with caplog.at_level("WARNING"): - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): area = delivery_area_from_proto2(proto) assert isinstance(area, case.expected_type) diff --git a/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py b/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py index d78cc42d..f53a8121 100644 --- a/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py +++ b/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py @@ -3,10 +3,9 @@ """Tests for MetricConnection protobuf conversion.""" -import warnings - import pytest from frequenz.api.common.v1alpha8.metrics import metrics_pb2 +from frequenz.core.warnings import asserting_no_deprecations from frequenz.client.common.metrics import MetricConnectionCategory from frequenz.client.common.metrics.proto.v1alpha8 import ( @@ -111,8 +110,7 @@ def test_from_proto_unspecified_category() -> None: name="some_connection", ) - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): connection = metric_connection_from_proto(proto) assert connection.category == 0 diff --git a/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py b/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py index 13f85d37..22d9b5af 100644 --- a/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py +++ b/tests/metrics/proto/v1alpha8/test_sample_metric_sample.py @@ -4,13 +4,13 @@ """Tests for MetricSample protobuf conversion.""" import math -import warnings from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Final import pytest from frequenz.api.common.v1alpha8.metrics import bounds_pb2, metrics_pb2 +from frequenz.core.warnings import asserting_no_deprecations from google.protobuf.timestamp_pb2 import Timestamp from frequenz.client.common import InvalidDatetime, InvalidDatetimeError @@ -373,8 +373,7 @@ def test_from_proto_unspecified_metric() -> None: ), ) - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): sample = metric_sample_from_proto(proto) assert sample.metric == 0 diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py b/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py index 3e445151..53dff2af 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py @@ -3,7 +3,6 @@ """Fixtures and utilities for testing electrical component protobuf conversion.""" -import warnings from datetime import datetime, timezone import pytest @@ -12,6 +11,7 @@ from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import ignoring_deprecations from google.protobuf.timestamp_pb2 import Timestamp from frequenz.client.common.metrics import Bounds, BoundsSet, Metric @@ -53,8 +53,7 @@ def default_component_base_data( component_id: ElectricalComponentId, microgrid_id: MicrogridId ) -> _ElectricalComponentBaseData: """Provide a fixture for common component fields.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.UNSPECIFIED return _ElectricalComponentBaseData( component_id=component_id, diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py index 67d3eed3..edaf9a15 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py @@ -4,7 +4,6 @@ """Tests for protobuf conversion of the base/common part of electrical components.""" import math -import warnings from datetime import timezone import pytest @@ -12,6 +11,7 @@ from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import ignoring_deprecations from google.protobuf.timestamp_pb2 import Timestamp from frequenz.client.common.metrics import ( @@ -85,8 +85,7 @@ def test_operational_mode_to_bools( def test_complete(default_component_base_data: _ElectricalComponentBaseData) -> None: """Test parsing of a complete base component proto.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.CHP # Just to pick a valid category base_data = default_component_base_data._replace(category=category) proto = base_data_as_proto(base_data) @@ -99,8 +98,7 @@ def test_missing_category_specific_info( default_component_base_data: _ElectricalComponentBaseData, ) -> None: """Test parsing with missing optional category specific info.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.UNSPECIFIED base_data = default_component_base_data._replace( name="", @@ -122,8 +120,7 @@ def test_empty_lifetime_is_unbounded( default_component_base_data: _ElectricalComponentBaseData, ) -> None: """A present but empty protobuf lifetime becomes an unbounded `Lifetime`.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.CHP base_data = default_component_base_data._replace( category=category, @@ -141,8 +138,7 @@ def test_category_specific_info_mismatch( default_component_base_data: _ElectricalComponentBaseData, ) -> None: """Test category and category specific info mismatch.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.GRID_CONNECTION_POINT base_data = default_component_base_data._replace( category=category, @@ -165,8 +161,7 @@ def test_invalid_lifetime( default_component_base_data: _ElectricalComponentBaseData, ) -> None: """Test invalid lifetime (start after end).""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.CHP base_data = default_component_base_data._replace( category=category, @@ -204,8 +199,7 @@ def _metric_bound( def test_metric_config_bounds_stores_unspecified_as_int() -> None: """Test UNSPECIFIED metric bounds load as plain int key 0.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): unspecified = Metric.UNSPECIFIED message = [ _metric_bound(int(unspecified.value), 0.0, 1.0), diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py index c40974d3..56e15cb6 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py @@ -3,12 +3,11 @@ """Tests for protobuf conversion of simple electrical components.""" -import warnings - import pytest from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import ignoring_deprecations from frequenz.client.common.microgrid.electrical_components import ( Breaker, @@ -100,8 +99,7 @@ def test_category_mismatch( assert electrical_component_class_to_proto(component) == (1, None) -with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) +with ignoring_deprecations(): _TRIVIAL_CASES = [ pytest.param(ElectricalComponentCategory.BREAKER, Breaker, id="Breaker"), pytest.param( @@ -167,8 +165,7 @@ def test_power_transformer( secondary: float | None, ) -> None: """Test PowerTransformer component.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.POWER_TRANSFORMER base_data = default_component_base_data._replace(category=category) @@ -196,8 +193,7 @@ def test_grid( rated_fuse_current: int | None, ) -> None: """Test GridConnectionPoint component with default values.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.GRID_CONNECTION_POINT base_data = default_component_base_data._replace(category=category) diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_with_type.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_with_type.py index 6597878c..b1c0ccfa 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_with_type.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_with_type.py @@ -3,12 +3,11 @@ """Tests for protobuf conversion of components with a type.""" -import warnings - import pytest from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import ignoring_deprecations from frequenz.client.common.microgrid.electrical_components import ( AcEvCharger, @@ -72,8 +71,7 @@ def test_battery( pb_battery_type: int, ) -> None: """Test battery component.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.BATTERY base_data = default_component_base_data._replace(category=category) proto = base_data_as_proto(base_data) @@ -126,8 +124,7 @@ def test_ev_charger( pb_ev_charger_type: int, ) -> None: """Test EV Charger component.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.EV_CHARGER base_data = default_component_base_data._replace(category=category) proto = base_data_as_proto(base_data) @@ -180,8 +177,7 @@ def test_inverter( pb_inverter_type: int, ) -> None: """Test inverter component.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.INVERTER base_data = default_component_base_data._replace(category=category) proto = base_data_as_proto(base_data) diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py index c8b1454a..f3bff74e 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py @@ -3,12 +3,11 @@ """Tests for deprecation of the category enum and its proto converters.""" -import warnings - import pytest from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import asserting_no_deprecations, ignoring_deprecations from frequenz.client.common.microgrid.electrical_components import ( ElectricalComponentCategory, @@ -29,8 +28,7 @@ def test_electrical_component_category_member_warns() -> None: def test_electrical_component_category_to_proto_warns() -> None: """Calling `electrical_component_category_to_proto` must warn.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): member = ElectricalComponentCategory.BATTERY with pytest.deprecated_call(): _ = electrical_component_category_to_proto(member) @@ -46,8 +44,7 @@ def test_electrical_component_category_from_proto_warns() -> None: def test_class_to_proto_does_not_warn() -> None: """The non-deprecated `electrical_component_class_to_proto` must NOT warn.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): result = electrical_component_class_to_proto(LiIonBattery) assert result == ( electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py index 68eb4960..331648ec 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py @@ -4,11 +4,11 @@ """Tests for the raw category/type values carried by problematic components.""" import dataclasses -import warnings from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) +from frequenz.core.warnings import asserting_no_deprecations, ignoring_deprecations from frequenz.client.common.microgrid.electrical_components import ( CategorySpecificInfo, @@ -32,8 +32,7 @@ def _li_ion_battery( default_component_base_data: _ElectricalComponentBaseData, ) -> LiIonBattery: """Build a `LiIonBattery` through the protobuf converter.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.BATTERY base_data = default_component_base_data._replace(category=category) proto = base_data_as_proto(base_data) @@ -203,15 +202,13 @@ def test_from_proto_emits_no_deprecation_warning( default_component_base_data: _ElectricalComponentBaseData, ) -> None: """Converting a protobuf message must not emit a `DeprecationWarning`.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): category = ElectricalComponentCategory.BATTERY base_data = default_component_base_data._replace(category=category) proto = base_data_as_proto(base_data) proto.category_specific_info.battery.type = ( electrical_components_pb2.BATTERY_TYPE_LI_ION ) - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) + with asserting_no_deprecations(): component = electrical_component_from_proto(proto) assert isinstance(component, LiIonBattery) diff --git a/tests/test/test_enum_parity.py b/tests/test/test_enum_parity.py index 9076e8b9..c237329f 100644 --- a/tests/test/test_enum_parity.py +++ b/tests/test/test_enum_parity.py @@ -27,6 +27,7 @@ import typing_extensions from frequenz.core.enum import Enum as DeprecatingEnum from frequenz.core.enum import deprecated_member, unique +from frequenz.core.warnings import ignoring_deprecations from pytest_mock import MockerFixture from frequenz.client.common.proto import enum_from_proto @@ -222,8 +223,7 @@ def _gone_from_proto(value: int) -> _GoneColor | int: Returns: The `_GoneColor` member, or the raw `int` for unknown values. """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): return enum_from_proto(value, _GoneColor) @@ -237,8 +237,7 @@ def _gone_to_proto(member: _GoneColor) -> int: Returns: The member's numeric value. """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with ignoring_deprecations(): return int(member.value) From 0947f4c27d1eb5a02bb9cad6a70d5f12edccd5c8 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 28 Sep 2026 15:53:47 +0000 Subject: [PATCH 8/8] docs: Explain the frequenz.core.warnings helpers in the wrapping guide The previous commits moved the library and its tests to the `frequenz.core.warnings` helpers, but the guide still told contributors to silence inner deprecations with `warnings.catch_warnings()`, which is the exact bug they fix. Anyone following it would bring the warning amplification back. The deprecation guide gets two new sections and an updated one: * Keeping a moved symbol importable with `deprecated_aliases()`, including the `TYPE_CHECKING`/`else` structure it needs, a message carrying the version, and when a deprecated class is the better fit, as with `ComponentId`. * Silencing only the deprecations the library raises itself, with `ignoring_deprecations()` around the one statement, and why `catch_warnings()` must not be used for it. * Testing that a replacement doesn't warn with `asserting_no_deprecations()` instead of an "error" filter. The testing guide points at both helpers. The helpers are mentioned as plain code rather than cross-references because the frequenz-core inventory will only have them once v1.5.0 is released. Signed-off-by: Leandro Lucarella --- .../deprecation-and-compatibility.md | 108 +++++++++++++++++- docs/wrapping-guide/index.md | 4 +- docs/wrapping-guide/testing.md | 10 +- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/wrapping-guide/deprecation-and-compatibility.md b/docs/wrapping-guide/deprecation-and-compatibility.md index 10c56727..af39c8f5 100644 --- a/docs/wrapping-guide/deprecation-and-compatibility.md +++ b/docs/wrapping-guide/deprecation-and-compatibility.md @@ -123,6 +123,89 @@ class Mode(Enum): """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 When you tighten a rule, do not always reject old input immediately. First, @@ -140,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 diff --git a/docs/wrapping-guide/index.md b/docs/wrapping-guide/index.md index a196faeb..4ad4aa8b 100644 --- a/docs/wrapping-guide/index.md +++ b/docs/wrapping-guide/index.md @@ -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. diff --git a/docs/wrapping-guide/testing.md b/docs/wrapping-guide/testing.md index d186c291..df7658b0 100644 --- a/docs/wrapping-guide/testing.md +++ b/docs/wrapping-guide/testing.md @@ -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.