diff --git a/.github/keylabeler.yml b/.github/keylabeler.yml index 90bd07b..26c1aee 100644 --- a/.github/keylabeler.yml +++ b/.github/keylabeler.yml @@ -13,7 +13,6 @@ caseSensitive: true # Explicit keyword mappings to labels. Form of match:label. Required. labelMappings: "part:asyncio": "part:asyncio" - "part:collections": "part:collections" "part:datetime": "part:datetime" "part:docs": "part:docs" "part:math": "part:math" diff --git a/.github/labeler.yml b/.github/labeler.yml index d40bfa1..b9807ed 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -39,14 +39,6 @@ - "tests/test_asyncio.py" - "tests/asyncio/**" -"part:collections": - - changed-files: - - any-glob-to-any-file: - - "src/frequenz/core/collections.py" - - "src/frequenz/core/collections/**" - - "tests/test_collections.py" - - "tests/collections/**" - "part:datetime": - changed-files: - any-glob-to-any-file: diff --git a/src/frequenz/core/collections.py b/src/frequenz/core/collections.py deleted file mode 100644 index 4d13f26..0000000 --- a/src/frequenz/core/collections.py +++ /dev/null @@ -1,86 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Data structures that contain collections of values or objects.""" - -from dataclasses import dataclass -from typing import Generic, Protocol, Self, TypeVar, cast - - -class LessThanComparable(Protocol): - """A protocol that requires the `__lt__` method to compare values.""" - - def __lt__(self, other: Self, /) -> bool: - """Return whether self is less than other.""" - - -LessThanComparableOrNoneT = TypeVar( - "LessThanComparableOrNoneT", bound=LessThanComparable | None -) -"""Type variable for a value that a `LessThanComparable` or `None`.""" - - -@dataclass(frozen=True) -class Interval(Generic[LessThanComparableOrNoneT]): - """An interval to test if a value is within its limits. - - The `start` and `end` are inclusive, meaning that the `start` and `end` limites are - included in the range when checking if a value is contained by the interval. - - If the `start` or `end` is `None`, it means that the interval is unbounded in that - direction. - - If `start` is bigger than `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.""" - - end: LessThanComparableOrNoneT - """The end of the interval.""" - - 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: - 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. - - Args: - item: The value to check. - - Returns: - bool: True if value is within the range, otherwise False. - """ - if item is None: - 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) - ) diff --git a/src/frequenz/core/math.py b/src/frequenz/core/math.py index e1b51a5..288d408 100644 --- a/src/frequenz/core/math.py +++ b/src/frequenz/core/math.py @@ -4,6 +4,8 @@ """Internal math tools.""" import math +from dataclasses import dataclass +from typing import Generic, Protocol, Self, TypeVar, cast 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: """ zero: float = 0.0 return math.isclose(a=value, b=zero, abs_tol=abs_tol) + + +class LessThanComparable(Protocol): + """A protocol that requires the `__lt__` method to compare values.""" + + def __lt__(self, other: Self, /) -> bool: + """Return whether self is less than other.""" + + +LessThanComparableOrNoneT = TypeVar( + "LessThanComparableOrNoneT", bound=LessThanComparable | None +) +"""Type variable for a value that a `LessThanComparable` or `None`.""" + + +@dataclass(frozen=True, repr=False) +class Interval(Generic[LessThanComparableOrNoneT]): + """An interval to test if a value is within its limits. + + The `start` and `end` are inclusive, meaning that the `start` and `end` limites are + included in the range when checking if a value is contained by the interval. + + If the `start` or `end` is `None`, it means that the interval is unbounded in that + direction. + + If `start` is bigger than `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.""" + + end: LessThanComparableOrNoneT + """The end of the interval.""" + + 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: + 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. + + Args: + item: The value to check. + + Returns: + bool: True if value is within the range, otherwise False. + """ + if item is None: + 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) + ) + + def __repr__(self) -> str: + """Return a string representation of this instance.""" + return f"Interval({self.start!r}, {self.end!r})" + + def __str__(self) -> str: + """Return a string representation of this instance.""" + start = "∞" if self.start is None else str(self.start) + end = "∞" if self.end is None else str(self.end) + return f"[{start}, {end}]" diff --git a/tests/test_collections.py b/tests/math/test_interval.py similarity index 93% rename from tests/test_collections.py rename to tests/math/test_interval.py index cf740f5..f81cf5a 100644 --- a/tests/test_collections.py +++ b/tests/math/test_interval.py @@ -8,7 +8,7 @@ import pytest -from frequenz.core.collections import Interval, LessThanComparable +from frequenz.core.math import Interval, LessThanComparable class CustomComparable: @@ -64,7 +64,7 @@ def test_invalid_range(start: LessThanComparable, end: LessThanComparable) -> No ), ], ) -def test_interval_contains( # pylint: disable=too-many-arguments +def test_contains( # pylint: disable=too-many-arguments start: LessThanComparable, end: LessThanComparable, within: LessThanComparable, @@ -94,7 +94,7 @@ def test_interval_contains( # pylint: disable=too-many-arguments ), ], ) -def test_interval_contains_no_start( +def test_contains_no_start( end: LessThanComparable, within: LessThanComparable, at_end: LessThanComparable, @@ -119,7 +119,7 @@ def test_interval_contains_no_start( ), ], ) -def test_interval_contains_no_end( +def test_contains_no_end( start: LessThanComparable, within: LessThanComparable, at_start: LessThanComparable, @@ -143,7 +143,7 @@ def test_interval_contains_no_end( CustomComparable(-10), ], ) -def test_interval_contains_unbound(value: LessThanComparable) -> None: +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 diff --git a/tests/test_math.py b/tests/math/test_is_close_to_zero.py similarity index 82% rename from tests/test_math.py rename to tests/math/test_is_close_to_zero.py index ba7d934..3764388 100644 --- a/tests/test_math.py +++ b/tests/math/test_is_close_to_zero.py @@ -12,28 +12,28 @@ # basic cases not working. -def test_is_close_to_zero_default_tolerance() -> None: +def test_default_tolerance() -> None: """Test the is_close_to_zero function with the default tolerance.""" assert is_close_to_zero(0.0) assert is_close_to_zero(1e-10) assert not is_close_to_zero(1e-8) -def test_is_close_to_zero_custom_tolerance() -> None: +def test_custom_tolerance() -> None: """Test the is_close_to_zero function with a custom tolerance.""" assert is_close_to_zero(0.0, abs_tol=1e-8) assert is_close_to_zero(1e-8, abs_tol=1e-8) assert not is_close_to_zero(1e-7, abs_tol=1e-8) -def test_is_close_to_zero_negative_values() -> None: +def test_negative_values() -> None: """Test the is_close_to_zero function with negative values.""" assert is_close_to_zero(-1e-10) assert not is_close_to_zero(-1e-8) @given(st.floats(allow_nan=False, allow_infinity=False)) -def test_is_close_to_zero_default_tolerance_hypothesis(value: float) -> None: +def test_default_tolerance_hypothesis(value: float) -> None: """Test the is_close_to_zero function with the default tolerance for many values.""" if -1e-9 <= value <= 1e-9: assert is_close_to_zero(value) @@ -45,9 +45,7 @@ def test_is_close_to_zero_default_tolerance_hypothesis(value: float) -> None: st.floats(allow_nan=False, allow_infinity=False), st.floats(allow_nan=False, allow_infinity=False, min_value=0.0, max_value=2.0), ) -def test_is_close_to_zero_custom_tolerance_hypothesis( - value: float, abs_tol: float -) -> None: +def test_custom_tolerance_hypothesis(value: float, abs_tol: float) -> None: """Test the is_close_to_zero function with a custom tolerance with many values/tolerance.""" if -abs_tol <= value <= abs_tol: assert is_close_to_zero(value, abs_tol=abs_tol)