Skip to content

Commit 1e40791

Browse files
authored
Move Interval to math (#25)
An `Interval` is not really a *collection*, as it doesn't store any objects, it is more of a mathematical concept, so we move the class to the `math` module and remove the `collections` module.
2 parents ddd13cc + a464978 commit 1e40791

File tree

6 files changed

+101
-107
lines changed

6 files changed

+101
-107
lines changed

.github/keylabeler.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ caseSensitive: true
1313
# Explicit keyword mappings to labels. Form of match:label. Required.
1414
labelMappings:
1515
"part:asyncio": "part:asyncio"
16-
"part:collections": "part:collections"
1716
"part:datetime": "part:datetime"
1817
"part:docs": "part:docs"
1918
"part:math": "part:math"

.github/labeler.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,6 @@
3939
- "tests/test_asyncio.py"
4040
- "tests/asyncio/**"
4141

42-
"part:collections":
43-
- changed-files:
44-
- any-glob-to-any-file:
45-
- "src/frequenz/core/collections.py"
46-
- "src/frequenz/core/collections/**"
47-
- "tests/test_collections.py"
48-
- "tests/collections/**"
49-
5042
"part:datetime":
5143
- changed-files:
5244
- any-glob-to-any-file:

src/frequenz/core/collections.py

Lines changed: 0 additions & 86 deletions
This file was deleted.

src/frequenz/core/math.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"""Math tools."""
55

66
import math
7+
from dataclasses import dataclass
8+
from typing import Generic, Protocol, Self, TypeVar, cast
79

810

911
def is_close_to_zero(value: float, abs_tol: float = 1e-9) -> bool:
@@ -23,3 +25,92 @@ def is_close_to_zero(value: float, abs_tol: float = 1e-9) -> bool:
2325
"""
2426
zero: float = 0.0
2527
return math.isclose(a=value, b=zero, abs_tol=abs_tol)
28+
29+
30+
class LessThanComparable(Protocol):
31+
"""A protocol that requires the `__lt__` method to compare values."""
32+
33+
def __lt__(self, other: Self, /) -> bool:
34+
"""Return whether self is less than other."""
35+
36+
37+
LessThanComparableOrNoneT = TypeVar(
38+
"LessThanComparableOrNoneT", bound=LessThanComparable | None
39+
)
40+
"""Type variable for a value that a `LessThanComparable` or `None`."""
41+
42+
43+
@dataclass(frozen=True, repr=False)
44+
class Interval(Generic[LessThanComparableOrNoneT]):
45+
"""An interval to test if a value is within its limits.
46+
47+
The `start` and `end` are inclusive, meaning that the `start` and `end` limites are
48+
included in the range when checking if a value is contained by the interval.
49+
50+
If the `start` or `end` is `None`, it means that the interval is unbounded in that
51+
direction.
52+
53+
If `start` is bigger than `end`, a `ValueError` is raised.
54+
55+
The type stored in the interval must be comparable, meaning that it must implement
56+
the `__lt__` method to be able to compare values.
57+
"""
58+
59+
start: LessThanComparableOrNoneT
60+
"""The start of the interval."""
61+
62+
end: LessThanComparableOrNoneT
63+
"""The end of the interval."""
64+
65+
def __post_init__(self) -> None:
66+
"""Check if the start is less than or equal to the end."""
67+
if self.start is None or self.end is None:
68+
return
69+
start = cast(LessThanComparable, self.start)
70+
end = cast(LessThanComparable, self.end)
71+
if start > end:
72+
raise ValueError(
73+
f"The start ({self.start}) can't be bigger than end ({self.end})"
74+
)
75+
76+
def __contains__(self, item: LessThanComparableOrNoneT) -> bool:
77+
"""
78+
Check if the value is within the range of the container.
79+
80+
Args:
81+
item: The value to check.
82+
83+
Returns:
84+
bool: True if value is within the range, otherwise False.
85+
"""
86+
if item is None:
87+
return False
88+
casted_item = cast(LessThanComparable, item)
89+
90+
if self.start is None and self.end is None:
91+
return True
92+
if self.start is None:
93+
start = cast(LessThanComparable, self.end)
94+
return not casted_item > start
95+
if self.end is None:
96+
return not self.start > item
97+
# mypy seems to get confused here, not being able to narrow start and end to
98+
# just LessThanComparable, complaining with:
99+
# error: Unsupported left operand type for <= (some union)
100+
# But we know if they are not None, they should be LessThanComparable, and
101+
# actually mypy is being able to figure it out in the lines above, just not in
102+
# this one, so it should be safe to cast.
103+
return not (
104+
casted_item < cast(LessThanComparable, self.start)
105+
or casted_item > cast(LessThanComparable, self.end)
106+
)
107+
108+
def __repr__(self) -> str:
109+
"""Return a string representation of this instance."""
110+
return f"Interval({self.start!r}, {self.end!r})"
111+
112+
def __str__(self) -> str:
113+
"""Return a string representation of this instance."""
114+
start = "∞" if self.start is None else str(self.start)
115+
end = "∞" if self.end is None else str(self.end)
116+
return f"[{start}, {end}]"
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import pytest
1010

11-
from frequenz.core.collections import Interval, LessThanComparable
11+
from frequenz.core.math import Interval, LessThanComparable
1212

1313

1414
class CustomComparable:
@@ -64,7 +64,7 @@ def test_invalid_range(start: LessThanComparable, end: LessThanComparable) -> No
6464
),
6565
],
6666
)
67-
def test_interval_contains( # pylint: disable=too-many-arguments
67+
def test_contains( # pylint: disable=too-many-arguments
6868
start: LessThanComparable,
6969
end: LessThanComparable,
7070
within: LessThanComparable,
@@ -94,7 +94,7 @@ def test_interval_contains( # pylint: disable=too-many-arguments
9494
),
9595
],
9696
)
97-
def test_interval_contains_no_start(
97+
def test_contains_no_start(
9898
end: LessThanComparable,
9999
within: LessThanComparable,
100100
at_end: LessThanComparable,
@@ -119,7 +119,7 @@ def test_interval_contains_no_start(
119119
),
120120
],
121121
)
122-
def test_interval_contains_no_end(
122+
def test_contains_no_end(
123123
start: LessThanComparable,
124124
within: LessThanComparable,
125125
at_start: LessThanComparable,
@@ -143,7 +143,7 @@ def test_interval_contains_no_end(
143143
CustomComparable(-10),
144144
],
145145
)
146-
def test_interval_contains_unbound(value: LessThanComparable) -> None:
146+
def test_contains_unbound(value: LessThanComparable) -> None:
147147
"""Test if a value is within the interval with no bounds."""
148148
interval_no_bounds: Interval[LessThanComparable | None] = Interval(
149149
start=None, end=None
Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,28 @@
1212
# basic cases not working.
1313

1414

15-
def test_is_close_to_zero_default_tolerance() -> None:
15+
def test_default_tolerance() -> None:
1616
"""Test the is_close_to_zero function with the default tolerance."""
1717
assert is_close_to_zero(0.0)
1818
assert is_close_to_zero(1e-10)
1919
assert not is_close_to_zero(1e-8)
2020

2121

22-
def test_is_close_to_zero_custom_tolerance() -> None:
22+
def test_custom_tolerance() -> None:
2323
"""Test the is_close_to_zero function with a custom tolerance."""
2424
assert is_close_to_zero(0.0, abs_tol=1e-8)
2525
assert is_close_to_zero(1e-8, abs_tol=1e-8)
2626
assert not is_close_to_zero(1e-7, abs_tol=1e-8)
2727

2828

29-
def test_is_close_to_zero_negative_values() -> None:
29+
def test_negative_values() -> None:
3030
"""Test the is_close_to_zero function with negative values."""
3131
assert is_close_to_zero(-1e-10)
3232
assert not is_close_to_zero(-1e-8)
3333

3434

3535
@given(st.floats(allow_nan=False, allow_infinity=False))
36-
def test_is_close_to_zero_default_tolerance_hypothesis(value: float) -> None:
36+
def test_default_tolerance_hypothesis(value: float) -> None:
3737
"""Test the is_close_to_zero function with the default tolerance for many values."""
3838
if -1e-9 <= value <= 1e-9:
3939
assert is_close_to_zero(value)
@@ -45,9 +45,7 @@ def test_is_close_to_zero_default_tolerance_hypothesis(value: float) -> None:
4545
st.floats(allow_nan=False, allow_infinity=False),
4646
st.floats(allow_nan=False, allow_infinity=False, min_value=0.0, max_value=2.0),
4747
)
48-
def test_is_close_to_zero_custom_tolerance_hypothesis(
49-
value: float, abs_tol: float
50-
) -> None:
48+
def test_custom_tolerance_hypothesis(value: float, abs_tol: float) -> None:
5149
"""Test the is_close_to_zero function with a custom tolerance with many values/tolerance."""
5250
if -abs_tol <= value <= abs_tol:
5351
assert is_close_to_zero(value, abs_tol=abs_tol)

0 commit comments

Comments
 (0)