From 5e0fba4c513d43b41b1e69ea5c26546e211f58d9 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Aug 2026 10:56:03 +0000 Subject: [PATCH 1/3] Add a `FloatInt` type alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PEP 484's numeric tower makes `int` assignable to any `float`-annotated parameter, attribute or variable, even under `mypy --strict`, while at runtime `isinstance(1, float)` is `False`. A plain `float` annotation is therefore a lie: it silently admits values that fall through an apparently exhaustive `match … case float():` into `assert_never()`, and that lack `float`-only methods like `hex()`. There is no clean fix in Python, and the alternatives were all measured or analyzed and rejected: coercing at ingress costs ~2.3x on the construction of hot-path types, structural `Protocol` tricks don't close the widened variable and `Sequence` covariance holes, and widening `match` arms one by one leaves the annotation lying. So stop lying instead and spell out what PEP 484 actually admits, at zero runtime cost. The docstring example is mirrored as a regular test because the example linter only extracts module, class and function docstrings, so an example documenting a module attribute is never checked. Signed-off-by: Leandro Lucarella --- README.md | 22 ++++++++++++++++ src/frequenz/core/typing.py | 52 ++++++++++++++++++++++++++++++++++++- tests/test_typing.py | 30 +++++++++++++++++++-- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a922c38..249b9f9 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,28 @@ class ApiClient: client = ApiClient.create("my-api-key") # ✅ Works ``` +Annotate floating point values honestly, as Python's numeric tower lets `int` +values through any `float` annotation: + +```python +from typing import assert_never + +from frequenz.core.typing 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" # ✅ `case float():` alone would crash here +assert describe(1.5) == "number 1.5" +assert describe(None) == "nothing" +``` + ### Strongly-Typed IDs Create type-safe identifiers for different entities: diff --git a/src/frequenz/core/typing.py b/src/frequenz/core/typing.py index fabc68a..917e915 100644 --- a/src/frequenz/core/typing.py +++ b/src/frequenz/core/typing.py @@ -11,10 +11,60 @@ [`NoInitConstructibleMeta`][frequenz.core.typing.NoInitConstructibleMeta]. This is useful mostly for disabling `__init__` while having to use another metaclass too (like [`abc.ABCMeta`][abc.ABCMeta]). + +Finally, it provides [`FloatInt`][frequenz.core.typing.FloatInt], an honest type alias +for `float | int`, to annotate floating point values that can also be an `int` at +runtime. """ from collections.abc import Callable -from typing import Any, NoReturn, TypeVar, cast, overload +from typing import Any, NoReturn, TypeAlias, TypeVar, cast, overload + +FloatInt: TypeAlias = float | int +"""A floating point value that can also 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, attribute or variable, 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. This breaks `match … case +float():` arms (an `int` value falls through to +[`assert_never()`][typing.assert_never]), calls to `float`-only methods like +[`hex()`][float.hex], and any other code dispatching on the concrete runtime type. + +Annotating with this alias instead makes the heterogeneity explicit, so type checkers +push the code reading these values to handle both branches, typically by matching with +`case float() | int():`. + +The full analysis, including the alternatives that were rejected, is recorded in +[issue #250](https://github.com/frequenz-floss/frequenz-client-common-python/issues/250). + +Danger: + `bool` is a subclass of `int`, so `True` and `False` also satisfy this alias. This + is inherent to Python's type system and is not guarded against. + +Example: + ```python + from typing import assert_never + + from frequenz.core.typing 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" + ``` +""" + TypeT = TypeVar("TypeT", bound=type) """A type variable that is bound to a type.""" diff --git a/tests/test_typing.py b/tests/test_typing.py index 1a2a89a..91512f0 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -3,11 +3,37 @@ """Test cases for the typing module.""" -from typing import Self +from typing import Self, assert_never import pytest -from frequenz.core.typing import disable_init +from frequenz.core.typing import FloatInt, disable_init + + +def test_float_int_matches_floats_and_ints() -> None: + """Test that both floats and ints are instances of FloatInt.""" + assert isinstance(1.5, FloatInt) + assert isinstance(1, FloatInt) + # bool is a subclass of int, so it leaks in too, as documented. + assert isinstance(True, FloatInt) + assert not isinstance("1", FloatInt) + + +def test_float_int_is_exhausted_by_float_and_int_cases() -> None: + """Test that matching float and int exhausts FloatInt for ints and floats.""" + + 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.5) == "number 1.5" + assert describe(1) == "number 1" + assert describe(None) == "nothing" def test_disable_init_declaration_with_custom_error() -> None: From f358654dd4d28fcaae29abaaeee628ea5be785b7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Aug 2026 10:56:10 +0000 Subject: [PATCH 2/3] Use `FloatInt` for `is_close_to_zero()` arguments The change is a pure widening with no effect on callers: `int` arguments were already accepted by type checkers via the numeric tower and already handled correctly by `math.isclose()`, the annotation just didn't admit it. Making it explicit means readers no longer have to guess whether integers are supported. Signed-off-by: Leandro Lucarella --- src/frequenz/core/math.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frequenz/core/math.py b/src/frequenz/core/math.py index 2d535f3..2d1bb88 100644 --- a/src/frequenz/core/math.py +++ b/src/frequenz/core/math.py @@ -7,8 +7,10 @@ from dataclasses import dataclass from typing import Generic, Protocol, Self, TypeVar +from .typing import FloatInt -def is_close_to_zero(value: float, abs_tol: float = 1e-9) -> bool: + +def is_close_to_zero(value: FloatInt, abs_tol: FloatInt = 1e-9) -> bool: """Check if a floating point value is close to zero. A value of 1e-9 is a commonly used absolute tolerance to balance precision From 2576343d25f6b66ff811c91e5e0f90bdcee21262 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Aug 2026 10:56:15 +0000 Subject: [PATCH 3/3] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5261a49..1c3085b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,9 +1,5 @@ # Frequenz Core Library Release Notes -## Summary - - - ## Upgrading - [`Interval`][frequenz.core.math.Interval]'s type parameter no longer includes `None`. The exported type variable `LessThanComparableOrNoneT` (bound to `LessThanComparable | None`) was deprecated and replaced with `LessThanComparableT` (bound to `LessThanComparable`). `None` is still accepted as a value for `start` / `end` to indicate an unbounded side, but it is treated purely as bound metadata rather than a value in the interval's comparable space. @@ -18,8 +14,10 @@ ## New Features - +- Added [`FloatInt`][frequenz.core.typing.FloatInt], a type alias for `float | int`. + + [PEP 484](https://peps.python.org/pep-0484/)'s [numeric tower](https://peps.python.org/pep-0484/#the-numeric-tower) makes `int` assignable wherever `float` is annotated, while at runtime `isinstance(1, float)` is `False`. A plain `float` annotation therefore silently admits values that break `match … case float():` arms and `float`-only methods like `hex()`. -## Bug Fixes + Annotate with `FloatInt` instead of a plain `float`: the alias docstring documents the trap in detail, including the inherent `bool ⊂ int` leak. - +- [`is_close_to_zero()`][frequenz.core.math.is_close_to_zero] now annotates its `value` and `abs_tol` parameters as [`FloatInt`][frequenz.core.typing.FloatInt]. This is a pure widening, `int` arguments were always accepted by type checkers, the annotation just didn't admit it.