Skip to content
Open
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 5 additions & 7 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# Frequenz Core Library Release Notes

## Summary

<!-- Here goes a general summary of what this release is about -->

## 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.
Expand All @@ -18,8 +14,10 @@

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
- 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.

<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
- [`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.
4 changes: 3 additions & 1 deletion src/frequenz/core/math.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion src/frequenz/core/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
30 changes: 28 additions & 2 deletions tests/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down