44"""Math tools."""
55
66import math
7+ from dataclasses import dataclass
8+ from typing import Generic , Protocol , Self , TypeVar , cast
79
810
911def 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 } ]"
0 commit comments