Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@

Malformed bounds are now preserved in the returned `MetricSample.bounds_set` as an `InvalidBoundsSet` (validity is encoded in the type), so the previous "bounds for ... is invalid, ignoring these bounds" major issue is no longer produced.

* `float`-typed fields and accessors are now annotated with the new `FloatInt` (`float | int`) type alias (see New Features), to be honest about what PEP 484's numeric tower actually admits. These symbols are affected:

* `frequenz.client.common.metrics.AggregatedMetricValue`: the `avg`, `min`, `max` and `raw` fields.
* `frequenz.client.common.metrics.MetricSample`: the `value` field and the `as_single_value()` return type.
* `frequenz.client.common.metrics.Bounds`: the `lower` and `upper` fields (shared with the new `BaseBounds` / `InvalidBounds` hierarchy).

Runtime behavior is completely unchanged: these fields could always end up storing `int` values (`x: float = 1` is legal even under `mypy --strict`), the annotations just didn't admit it. Reads that assign to `float`-typed destinations or do plain arithmetic keep type-checking as before. However, code that pattern-matches these values with a bare `case float():` arm — a latent runtime crash, since `isinstance(1, float)` is `False` — will now be flagged as non-exhaustive by strict type checkers and should be widened to `case float() | int():`, and calling `float`-only methods (e.g. `hex()`) on them now requires an explicit `float(...)` conversion.

## New Features

* Added 4 new electrical component classes for categories that previously collapsed into `UnrecognizedElectricalComponent`:
Expand Down Expand Up @@ -138,6 +146,11 @@

* Added a new `frequenz.client.common.microgrid.Microgrid` type with a raising `is_active()` method, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function.

* Added `frequenz.client.common.FloatInt`, a type alias for `float | int`.

PEP 484's numeric tower makes `int` assignable wherever `float` is annotated, even under `mypy --strict`, while at runtime `isinstance(1, float)` is `False` — so a plain `float` annotation silently admits values that crash `match … case float():` arms and `float`-only methods like `hex()`. The library now spells such annotations `FloatInt` instead of lying (see Upgrading); the alias docstring documents the trap in detail, including the inherent `bool ⊂ int` leak. New numeric fields (`Location` latitudes/longitudes, `PowerTransformer` voltages, bounds and bounds sets) use it as well. Values loaded from protobuf are unaffected in practice, as the wire always delivers real `float`s.

## Bug Fixes

* 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.
2 changes: 2 additions & 0 deletions src/frequenz/client/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
UnrecognizedEnumValueError,
UnspecifiedEnumValueError,
)
from ._float import FloatInt

__all__ = [
"ClientCommonError",
"FloatInt",
"InvalidAttributeError",
"MissingFieldError",
"UnrecognizedEnumValueError",
Expand Down
50 changes: 50 additions & 0 deletions src/frequenz/client/common/_float.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Honest type alias for floating-point values."""

from typing import TypeAlias

FloatInt: TypeAlias = float | int
"""A `float` that may actually be an `int` at runtime.

[PEP 484's numeric tower](https://peps.python.org/pep-0484/#the-numeric-tower)
makes `int` assignable to any `float`-annotated parameter or field, so a plain
`float` annotation is a lie: type checkers (even `mypy --strict`) happily
accept `int` values, but `isinstance(1, float)` is `False` at runtime. That
breaks `match … case float():` arms (an `int` value falls through to
`assert_never()`), calls to `float`-only methods like `hex()`, and any other
code dispatching on the concrete runtime type.

This library instead annotates such values as `FloatInt`, making the
heterogeneity explicit: type checkers will push code reading these values to
handle both branches, typically by matching with `case float() | int():`. See
[issue #250](https://github.com/frequenz-floss/frequenz-client-common-python/issues/250)
for the full analysis and the alternatives that were rejected.

Danger:
`bool` is a subclass of `int`, so `True` and `False` also satisfy this
alias. This is inherent to Python's type system and not guarded against.

Example:
```python
from typing import assert_never

from frequenz.client.common import FloatInt


def describe(value: FloatInt | None) -> str:
match value:
case float() | int():
return f"number {value}"
case None:
return "nothing"
case unexpected:
assert_never(unexpected)


assert describe(1) == "number 1"
assert describe(1.5) == "number 1.5"
assert describe(None) == "nothing"
```
"""
15 changes: 8 additions & 7 deletions src/frequenz/client/common/metrics/_bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Any, Self

from .._exception import InvalidAttributeError
from .._float import FloatInt


@dataclasses.dataclass(frozen=True, kw_only=True)
Expand All @@ -22,13 +23,13 @@ class BaseBounds:
malformed wire data.
"""

lower: float | int | None = None
lower: FloatInt | None = None
"""The lower bound.

If `None`, there is no lower bound.
"""

upper: float | int | None = None
upper: FloatInt | None = None
"""The upper bound.

If `None`, there is no upper bound.
Expand Down Expand Up @@ -72,7 +73,7 @@ def __str__(self) -> str:
"""Return a string representation of these bounds."""
return f"[{self.lower},{self.upper}]"

def __contains__(self, item: float | None) -> bool:
def __contains__(self, item: FloatInt | None) -> bool:
"""Check whether a value is within these bounds.

The bounds are inclusive on both ends, and a `None` bound means these
Expand Down Expand Up @@ -173,7 +174,7 @@ def __init__(
)


def _end_covers_start(upper: float | None, lower: float | None) -> bool:
def _end_covers_start(upper: FloatInt | None, lower: FloatInt | None) -> bool:
"""Return whether an upper bound reaches a lower bound, treating `None` as ±∞.

Args:
Expand All @@ -190,7 +191,7 @@ def _end_covers_start(upper: float | None, lower: float | None) -> bool:
return not upper < lower


def _max_upper(first: float | None, second: float | None) -> float | None:
def _max_upper(first: FloatInt | None, second: FloatInt | None) -> FloatInt | None:
"""Return the larger of two upper bounds, where `None` means +∞.

Args:
Expand Down Expand Up @@ -226,7 +227,7 @@ def _sort_and_merge_bounds(bounds: Iterable[Bounds]) -> tuple[Bounds, ...]:
return ()

with_none_lower: list[Bounds] = []
with_real_lower: list[tuple[float, Bounds]] = []
with_real_lower: list[tuple[FloatInt, Bounds]] = []
for bound in all_bounds:
if bound.lower is None:
with_none_lower.append(bound)
Expand Down Expand Up @@ -306,7 +307,7 @@ def __post_init__(self) -> None:
"""Normalize the bounds by sorting and merging overlapping ones."""
object.__setattr__(self, "bounds", _sort_and_merge_bounds(self.bounds))

def __contains__(self, item: float | None) -> bool:
def __contains__(self, item: FloatInt | None) -> bool:
"""Check whether a value is within any bounds of this set.

Args:
Expand Down
17 changes: 9 additions & 8 deletions src/frequenz/client/common/metrics/_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing_extensions import deprecated

from .._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError
from .._float import FloatInt
from ._bounds import Bounds, BoundsSet, InvalidBoundsSet, InvalidBoundsSetError
from ._metric import Metric

Expand Down Expand Up @@ -45,16 +46,16 @@ class AggregatedMetricValue:
are available.
"""

avg: float
avg: FloatInt
"""The derived average value of the metric."""

min: float | None
min: FloatInt | None
"""The minimum measured value of the metric."""

max: float | None
max: FloatInt | None
"""The maximum measured value of the metric."""

raw: Sequence[float]
raw: Sequence[FloatInt]
"""All the raw individual values (it might be empty if not provided by the component)."""

def __str__(self) -> str:
Expand Down Expand Up @@ -193,7 +194,7 @@ class MetricSample:
`MetricSample.get_metric()` to obtain a known member or a clear error.
"""

value: float | AggregatedMetricValue | None
value: FloatInt | AggregatedMetricValue | None
"""The value of the sampled metric."""

bounds_set: BoundsSet | InvalidBoundsSet
Expand Down Expand Up @@ -244,7 +245,7 @@ def __init__(
*,
sample_time: datetime,
metric: Metric | int,
value: float | AggregatedMetricValue | None,
value: FloatInt | AggregatedMetricValue | None,
bounds_set: BoundsSet | InvalidBoundsSet | None = None,
bounds: list[Bounds] | None = None,
connection: MetricConnection | None = None,
Expand Down Expand Up @@ -307,10 +308,10 @@ def bounds(self) -> list[Bounds]:

def as_single_value(
self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG
) -> float | None:
) -> FloatInt | None:
"""Return the value of this sample as a single value.

If [`value`][..value] is a `float`, it is returned as is. If `value`
If [`value`][..value] is a number, it is returned as is. If `value`
is an [`AggregatedMetricValue`][...AggregatedMetricValue], the value is
aggregated using the provided `aggregation_method`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import dataclasses

from ..._float import FloatInt
from ._electrical_component import ElectricalComponent


Expand All @@ -22,13 +23,13 @@ class PowerTransformer(ElectricalComponent):
than the input power.
"""

primary_voltage: float
primary_voltage: FloatInt
"""The primary voltage of the transformer, in volts.

This is the input voltage that is stepped up or down.
"""

secondary_voltage: float
secondary_voltage: FloatInt
"""The secondary voltage of the transformer, in volts.

This is the output voltage that is the result of stepping the primary
Expand Down
Loading