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
10 changes: 9 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- [`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.

Migration:

- `Interval[int | None]` → `Interval[int]`
- `Interval[LessThanComparable | None]` → `Interval[LessThanComparable]`
- `LessThanComparableOrNoneT` (importable name) → `LessThanComparableT`

Note that explicitly using `Interval[int | None]` still works, as `(int | None) | None` is equivalent to `int | None`, even when it is no longer needed nor recommended. Old code will mostly work, but there is one soft breaking change: `None in some_interval` and `None in some_interval_set` is now a type-check error at call sites, matching the design intent that `None` is a bound marker and never a member value.

## New Features

Expand Down
58 changes: 25 additions & 33 deletions src/frequenz/core/math.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import math
from dataclasses import dataclass
from typing import Generic, Protocol, Self, TypeVar, cast
from typing import Generic, Protocol, Self, TypeVar


def is_close_to_zero(value: float, abs_tol: float = 1e-9) -> bool:
Expand Down Expand Up @@ -34,76 +34,68 @@ def __lt__(self, other: Self, /) -> bool:
"""Return whether self is less than other."""


LessThanComparableT = TypeVar("LessThanComparableT", bound=LessThanComparable)
"""Type variable for a value that is [`LessThanComparable`][..LessThanComparable]."""


LessThanComparableOrNoneT = TypeVar(
"LessThanComparableOrNoneT", bound=LessThanComparable | None
)
"""Type variable for a value that a `LessThanComparable` or `None`."""
"""Type variable for a value that is [`LessThanComparable`][..LessThanComparable] or `None`.

Warning: Deprecated
This type variable is deprecated and it will be removed in a future version. Use
[`LessThanComparableT`][..LessThanComparableT] instead.
"""


@dataclass(frozen=True, repr=False)
class Interval(Generic[LessThanComparableOrNoneT]):
class Interval(Generic[LessThanComparableT]):
"""An interval to test if a value is within its limits.

The [`.start`][.start] and [`.end`][.end] are inclusive, meaning that the
[`.start`][.start] and [`.end`][.end] limits are included in the range when
checking if a value is contained by the interval.

If the [`.start`][.start] or [`.end`][.end] is `None`, it means that the interval
is unbounded in that direction.
is unbounded in that direction. `None` is used purely as a bound marker; it is
never a value in the interval.

If [`.start`][.start] is bigger than [`.end`][.end], a `ValueError` is raised.

The type stored in the interval must be comparable, meaning that it must implement
the `__lt__` method to be able to compare values.
"""

start: LessThanComparableOrNoneT
"""The start of the interval."""
start: LessThanComparableT | None
"""The start of the interval, or `None` to indicate no lower bound (-∞)."""

end: LessThanComparableOrNoneT
"""The end of the interval."""
end: LessThanComparableT | None
"""The end of the interval, or `None` to indicate no upper bound (+∞)."""

def __post_init__(self) -> None:
"""Check if the start is less than or equal to the end."""
if self.start is None or self.end is None:
return
start = cast(LessThanComparable, self.start)
end = cast(LessThanComparable, self.end)
if start > end:
if self.start > self.end:
raise ValueError(
f"The start ({self.start}) can't be bigger than end ({self.end})"
)

def __contains__(self, item: LessThanComparableOrNoneT) -> bool:
"""Check if the value is within the range of the container.
def __contains__(self, item: LessThanComparableT) -> bool:
"""Check if the value is within the range of the interval.

Args:
item: The value to check.

Returns:
True if value is within the range, otherwise False.
"""
if item is None:
if self.start is not None and item < self.start:
return False
if self.end is not None and item > self.end:
return False
casted_item = cast(LessThanComparable, item)

if self.start is None and self.end is None:
return True
if self.start is None:
start = cast(LessThanComparable, self.end)
return not casted_item > start
if self.end is None:
return not self.start > item
# mypy seems to get confused here, not being able to narrow start and end to
# just LessThanComparable, complaining with:
# error: Unsupported left operand type for <= (some union)
# But we know if they are not None, they should be LessThanComparable, and
# actually mypy is being able to figure it out in the lines above, just not in
# this one, so it should be safe to cast.
return not (
casted_item < cast(LessThanComparable, self.start)
or casted_item > cast(LessThanComparable, self.end)
)
return True

def __repr__(self) -> str:
"""Return a string representation of this instance."""
Expand Down
4 changes: 1 addition & 3 deletions tests/math/test_interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,5 @@ def test_contains_no_end(
)
def test_contains_unbound(value: LessThanComparable) -> None:
"""Test if a value is within the interval with no bounds."""
interval_no_bounds: Interval[LessThanComparable | None] = Interval(
start=None, end=None
)
interval_no_bounds: Interval[LessThanComparable] = Interval(start=None, end=None)
assert value in interval_no_bounds # any value within bounds
Loading