diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bac25102..f37b2067 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -70,6 +70,14 @@ Malformed bounds are now preserved in the returned `MetricSample.bounds_set` as an `InvalidBoundsSet` (validity is encoded in the type), so the previous "bounds for ... is invalid, ignoring these bounds" major issue is no longer produced. +* `float`-typed fields and accessors are now annotated with the new `FloatInt` (`float | int`) type alias (see New Features), to be honest about what PEP 484's numeric tower actually admits. These symbols are affected: + + * `frequenz.client.common.metrics.AggregatedMetricValue`: the `avg`, `min`, `max` and `raw` fields. + * `frequenz.client.common.metrics.MetricSample`: the `value` field and the `as_single_value()` return type. + * `frequenz.client.common.metrics.Bounds`: the `lower` and `upper` fields (shared with the new `BaseBounds` / `InvalidBounds` hierarchy). + + Runtime behavior is completely unchanged: these fields could always end up storing `int` values (`x: float = 1` is legal even under `mypy --strict`), the annotations just didn't admit it. Reads that assign to `float`-typed destinations or do plain arithmetic keep type-checking as before. However, code that pattern-matches these values with a bare `case float():` arm — a latent runtime crash, since `isinstance(1, float)` is `False` — will now be flagged as non-exhaustive by strict type checkers and should be widened to `case float() | int():`, and calling `float`-only methods (e.g. `hex()`) on them now requires an explicit `float(...)` conversion. + ## New Features * Added 4 new electrical component classes for categories that previously collapsed into `UnrecognizedElectricalComponent`: @@ -138,6 +146,11 @@ * Added a new `frequenz.client.common.microgrid.Microgrid` type with a raising `is_active()` method, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function. +* Added `frequenz.client.common.FloatInt`, a type alias for `float | int`. + + PEP 484's numeric tower makes `int` assignable wherever `float` is annotated, even under `mypy --strict`, while at runtime `isinstance(1, float)` is `False` — so a plain `float` annotation silently admits values that crash `match … case float():` arms and `float`-only methods like `hex()`. The library now spells such annotations `FloatInt` instead of lying (see Upgrading); the alias docstring documents the trap in detail, including the inherent `bool ⊂ int` leak. New numeric fields (`Location` latitudes/longitudes, `PowerTransformer` voltages, bounds and bounds sets) use it as well. Values loaded from protobuf are unaffected in practice, as the wire always delivers real `float`s. + ## Bug Fixes * Fixed `EnumParityTest` so protobuf values whose Python member name exists with a different number fail parity checks instead of being treated as unmirrored protobuf values. +* Fixed potential unexpected exceptions due to type-checking accepting `int` for code annotated to only accept `float`. Fixes #250. diff --git a/src/frequenz/client/common/__init__.py b/src/frequenz/client/common/__init__.py index 0580c58d..9dd94a7c 100644 --- a/src/frequenz/client/common/__init__.py +++ b/src/frequenz/client/common/__init__.py @@ -10,9 +10,11 @@ UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) +from ._float import FloatInt __all__ = [ "ClientCommonError", + "FloatInt", "InvalidAttributeError", "MissingFieldError", "UnrecognizedEnumValueError", diff --git a/src/frequenz/client/common/_float.py b/src/frequenz/client/common/_float.py new file mode 100644 index 00000000..fcdce535 --- /dev/null +++ b/src/frequenz/client/common/_float.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Honest type alias for floating-point values.""" + +from typing import TypeAlias + +FloatInt: TypeAlias = float | int +"""A `float` that may actually 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 or field, 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. That +breaks `match … case float():` arms (an `int` value falls through to +`assert_never()`), calls to `float`-only methods like `hex()`, and any other +code dispatching on the concrete runtime type. + +This library instead annotates such values as `FloatInt`, making the +heterogeneity explicit: type checkers will push code reading these values to +handle both branches, typically by matching with `case float() | int():`. See +[issue #250](https://github.com/frequenz-floss/frequenz-client-common-python/issues/250) +for the full analysis and the alternatives that were rejected. + +Danger: + `bool` is a subclass of `int`, so `True` and `False` also satisfy this + alias. This is inherent to Python's type system and not guarded against. + +Example: + ```python + from typing import assert_never + + from frequenz.client.common 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" + ``` +""" diff --git a/src/frequenz/client/common/metrics/_bounds.py b/src/frequenz/client/common/metrics/_bounds.py index 3cd812e9..5b051b1d 100644 --- a/src/frequenz/client/common/metrics/_bounds.py +++ b/src/frequenz/client/common/metrics/_bounds.py @@ -11,6 +11,7 @@ from typing import Any, Self from .._exception import InvalidAttributeError +from .._float import FloatInt @dataclasses.dataclass(frozen=True, kw_only=True) @@ -22,13 +23,13 @@ class BaseBounds: malformed wire data. """ - lower: float | int | None = None + lower: FloatInt | None = None """The lower bound. If `None`, there is no lower bound. """ - upper: float | int | None = None + upper: FloatInt | None = None """The upper bound. If `None`, there is no upper bound. @@ -72,7 +73,7 @@ def __str__(self) -> str: """Return a string representation of these bounds.""" return f"[{self.lower},{self.upper}]" - def __contains__(self, item: float | None) -> bool: + def __contains__(self, item: FloatInt | None) -> bool: """Check whether a value is within these bounds. The bounds are inclusive on both ends, and a `None` bound means these @@ -173,7 +174,7 @@ def __init__( ) -def _end_covers_start(upper: float | None, lower: float | None) -> bool: +def _end_covers_start(upper: FloatInt | None, lower: FloatInt | None) -> bool: """Return whether an upper bound reaches a lower bound, treating `None` as ±∞. Args: @@ -190,7 +191,7 @@ def _end_covers_start(upper: float | None, lower: float | None) -> bool: return not upper < lower -def _max_upper(first: float | None, second: float | None) -> float | None: +def _max_upper(first: FloatInt | None, second: FloatInt | None) -> FloatInt | None: """Return the larger of two upper bounds, where `None` means +∞. Args: @@ -226,7 +227,7 @@ def _sort_and_merge_bounds(bounds: Iterable[Bounds]) -> tuple[Bounds, ...]: return () with_none_lower: list[Bounds] = [] - with_real_lower: list[tuple[float, Bounds]] = [] + with_real_lower: list[tuple[FloatInt, Bounds]] = [] for bound in all_bounds: if bound.lower is None: with_none_lower.append(bound) @@ -306,7 +307,7 @@ def __post_init__(self) -> None: """Normalize the bounds by sorting and merging overlapping ones.""" object.__setattr__(self, "bounds", _sort_and_merge_bounds(self.bounds)) - def __contains__(self, item: float | None) -> bool: + def __contains__(self, item: FloatInt | None) -> bool: """Check whether a value is within any bounds of this set. Args: diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index fecebf67..d00de53c 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -13,6 +13,7 @@ from typing_extensions import deprecated from .._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError +from .._float import FloatInt from ._bounds import Bounds, BoundsSet, InvalidBoundsSet, InvalidBoundsSetError from ._metric import Metric @@ -45,16 +46,16 @@ class AggregatedMetricValue: are available. """ - avg: float + avg: FloatInt """The derived average value of the metric.""" - min: float | None + min: FloatInt | None """The minimum measured value of the metric.""" - max: float | None + max: FloatInt | None """The maximum measured value of the metric.""" - raw: Sequence[float] + raw: Sequence[FloatInt] """All the raw individual values (it might be empty if not provided by the component).""" def __str__(self) -> str: @@ -193,7 +194,7 @@ class MetricSample: `MetricSample.get_metric()` to obtain a known member or a clear error. """ - value: float | AggregatedMetricValue | None + value: FloatInt | AggregatedMetricValue | None """The value of the sampled metric.""" bounds_set: BoundsSet | InvalidBoundsSet @@ -244,7 +245,7 @@ def __init__( *, sample_time: datetime, metric: Metric | int, - value: float | AggregatedMetricValue | None, + value: FloatInt | AggregatedMetricValue | None, bounds_set: BoundsSet | InvalidBoundsSet | None = None, bounds: list[Bounds] | None = None, connection: MetricConnection | None = None, @@ -307,10 +308,10 @@ def bounds(self) -> list[Bounds]: def as_single_value( self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG - ) -> float | None: + ) -> FloatInt | None: """Return the value of this sample as a single value. - If [`value`][..value] is a `float`, it is returned as is. If `value` + If [`value`][..value] is a number, it is returned as is. If `value` is an [`AggregatedMetricValue`][...AggregatedMetricValue], the value is aggregated using the provided `aggregation_method`. diff --git a/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py b/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py index d86f90e9..51f41bb2 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py @@ -5,6 +5,7 @@ import dataclasses +from ..._float import FloatInt from ._electrical_component import ElectricalComponent @@ -22,13 +23,13 @@ class PowerTransformer(ElectricalComponent): than the input power. """ - primary_voltage: float + primary_voltage: FloatInt """The primary voltage of the transformer, in volts. This is the input voltage that is stepped up or down. """ - secondary_voltage: float + secondary_voltage: FloatInt """The secondary voltage of the transformer, in volts. This is the output voltage that is the result of stepping the primary diff --git a/src/frequenz/client/common/types/_location.py b/src/frequenz/client/common/types/_location.py index 90e12d03..bd946ed8 100644 --- a/src/frequenz/client/common/types/_location.py +++ b/src/frequenz/client/common/types/_location.py @@ -7,13 +7,14 @@ from typing import assert_never from .._exception import InvalidAttributeError, MissingFieldError +from .._float import FloatInt class InvalidLatitudeError(InvalidAttributeError): """Raised when a semantic accessor sees a latitude outside `[-90, 90]`. A well-formed latitude lies in the closed interval `[-90, 90]`. The raw - out-of-range float is available as `value`. + out-of-range number is available as `value`. This is also a [`ValueError`][] for convenience. """ @@ -22,7 +23,7 @@ def __init__( self, instance: object, attr_name: str, - value: float, + value: FloatInt, message: str | None = None, ) -> None: """Initialize this error. @@ -34,7 +35,7 @@ def __init__( message: A custom error message. If `None`, a default message mentioning the invalid value is used. """ - self.value: float = value + self.value: FloatInt = value """The out-of-range latitude value.""" super().__init__( @@ -53,7 +54,7 @@ class InvalidLongitudeError(InvalidAttributeError): """Raised when a semantic accessor sees a longitude outside `[-180, 180]`. A well-formed longitude lies in the closed interval `[-180, 180]`. The raw - out-of-range float is available as `value`. + out-of-range number is available as `value`. This is also a [`ValueError`][] for convenience. """ @@ -62,7 +63,7 @@ def __init__( self, instance: object, attr_name: str, - value: float, + value: FloatInt, message: str | None = None, ) -> None: """Initialize this error. @@ -74,7 +75,7 @@ def __init__( message: A custom error message. If `None`, a default message mentioning the invalid value is used. """ - self.value: float = value + self.value: FloatInt = value """The out-of-range longitude value.""" super().__init__( @@ -136,7 +137,7 @@ class InvalidLatitude: Wraps a raw wire latitude that fell outside the well-formed range. """ - value: float + value: FloatInt """The raw out-of-range latitude value.""" def __str__(self) -> str: @@ -151,7 +152,7 @@ class InvalidLongitude: Wraps a raw wire longitude that fell outside the well-formed range. """ - value: float + value: FloatInt """The raw out-of-range longitude value.""" def __str__(self) -> str: @@ -194,33 +195,33 @@ class Location: validated value or a clear [`InvalidAttributeError`][...InvalidAttributeError] subclass. - Constructing a `Location` with a plain `float` or `str` that violates + Constructing a `Location` with a plain number or `str` that violates its invariant raises `ValueError`; use the corresponding `Invalid*` type to represent an out-of-invariant wire value. """ - latitude: float | InvalidLatitude + latitude: FloatInt | InvalidLatitude """The latitude. - A plain `float` when well-formed (in `[-90, 90]`); an + A plain number when well-formed (in `[-90, 90]`); an [`InvalidLatitude`][...InvalidLatitude] wrapper when the wire delivered an out-of-range value. Tip: Use [`Location.get_latitude()`][...Location.get_latitude] to obtain - a validated `float` or a clear error. + a validated number or a clear error. """ - longitude: float | InvalidLongitude + longitude: FloatInt | InvalidLongitude """The longitude. - A plain `float` when well-formed (in `[-180, 180]`); an + A plain number when well-formed (in `[-180, 180]`); an [`InvalidLongitude`][...InvalidLongitude] wrapper when the wire delivered an out-of-range value. Tip: Use [`Location.get_longitude()`][...Location.get_longitude] to obtain a - validated `float` or a clear error. + validated number or a clear error. """ country_code: str | InvalidCountryCode | None @@ -241,8 +242,8 @@ def __post_init__(self) -> None: """Enforce that plain (unwrapped) fields respect their invariants. Raises: - ValueError: If `latitude` is a plain `float` outside `[-90, 90]`; - if `longitude` is a plain `float` outside `[-180, 180]`; or + ValueError: If `latitude` is a plain number outside `[-90, 90]`; + if `longitude` is a plain number outside `[-180, 180]`; or if `country_code` is a plain `str` not exactly 2 characters long. To represent an invalid wire value, wrap it in the corresponding `Invalid*` type. @@ -272,11 +273,11 @@ def __post_init__(self) -> None: "invalid wire value" ) - def get_latitude(self) -> float: - """Return the latitude as a well-formed `float` in `[-90, 90]`. + def get_latitude(self) -> FloatInt: + """Return the latitude as a well-formed number in `[-90, 90]`. Returns: - The latitude, when it is a well-formed `float`. + The latitude, when it is a well-formed number. Raises: InvalidLatitudeError: If [`latitude`][..latitude] is an @@ -291,11 +292,11 @@ def get_latitude(self) -> float: case unknown: assert_never(unknown) - def get_longitude(self) -> float: - """Return the longitude as a well-formed `float` in `[-180, 180]`. + def get_longitude(self) -> FloatInt: + """Return the longitude as a well-formed number in `[-180, 180]`. Returns: - The longitude, when it is a well-formed `float`. + The longitude, when it is a well-formed number. Raises: InvalidLongitudeError: If [`longitude`][..longitude] is an diff --git a/tests/metrics/_bounds/test_bounds.py b/tests/metrics/_bounds/test_bounds.py index 6d38cdb0..e448cabb 100644 --- a/tests/metrics/_bounds/test_bounds.py +++ b/tests/metrics/_bounds/test_bounds.py @@ -8,6 +8,7 @@ import pytest +from frequenz.client.common import FloatInt from frequenz.client.common.metrics import BaseBounds, Bounds @@ -27,10 +28,11 @@ def test_is_base_bounds_subclass() -> None: (-10.0, -10.0), (0.0, 10.0), (-10, 0.0), + (-10, 10), # the numeric tower lets plain `int` bounds in (0.0, 0.0), ], ) -def test_creation(lower: float | int | None, upper: float | int | None) -> None: +def test_creation(lower: FloatInt | None, upper: FloatInt | None) -> None: """Test creation of Bounds with valid values.""" bounds = Bounds(lower=lower, upper=upper) assert bounds.lower == lower @@ -52,6 +54,8 @@ def test_str_representation() -> None: """Test string representation of Bounds.""" bounds = Bounds(lower=-10.0, upper=10.0) assert str(bounds) == "[-10.0,10.0]" + # `int` bounds keep their `int` repr; values are stored untouched. + assert str(Bounds(lower=-10, upper=10)) == "[-10,10]" def test_equality() -> None: @@ -65,6 +69,11 @@ def test_equality() -> None: assert bounds2 != bounds3 +def test_equality_int_float() -> None: + """`int` and `float` bounds with the same value compare equal (`1 == 1.0`).""" + assert Bounds(lower=-10, upper=10) == Bounds(lower=-10.0, upper=10.0) + + def test_hash() -> None: """Test that Bounds objects can be used in sets and as dictionary keys.""" bounds1 = Bounds(lower=-10.0, upper=10.0) @@ -94,10 +103,15 @@ def test_hash() -> None: (-10.0, None, 1e9, True), # unbounded above (-10.0, None, -10.0, True), (-10.0, None, -10.1, False), + (-10, 10, 5, True), # `int` bounds and `int` items work the same + (-10, 10, 10, True), + (-10, 10, 11, False), + (-10.0, 10.0, 10, True), # `int` item against `float` bounds + (-10, 10, 10.1, False), # `float` item against `int` bounds ], ) def test_contains( - lower: float | None, upper: float | None, item: float, expected: bool + lower: FloatInt | None, upper: FloatInt | None, item: FloatInt, expected: bool ) -> None: """Test membership with `in`, inclusive on both ends.""" assert (item in Bounds(lower=lower, upper=upper)) is expected diff --git a/tests/metrics/_bounds/test_bounds_set.py b/tests/metrics/_bounds/test_bounds_set.py index c44c6670..2209b5d9 100644 --- a/tests/metrics/_bounds/test_bounds_set.py +++ b/tests/metrics/_bounds/test_bounds_set.py @@ -7,6 +7,7 @@ import pytest +from frequenz.client.common import FloatInt from frequenz.client.common.metrics import Bounds, BoundsSet @@ -126,9 +127,11 @@ def test_all_covering_halves_collapse_to_empty() -> None: (15.0, True), (20.0, True), (21.0, False), + (3, True), # `int` items work the same + (6, False), ], ) -def test_contains(item: float, expected: bool) -> None: +def test_contains(item: FloatInt, expected: bool) -> None: """Membership tests the union of all bounds, inclusive on both ends.""" bounds_set = BoundsSet( bounds=(Bounds(lower=1.0, upper=5.0), Bounds(lower=15.0, upper=20.0)) @@ -148,6 +151,14 @@ def test_contains_nan() -> None: assert math.nan not in BoundsSet(bounds=(Bounds(lower=1.0, upper=5.0),)) +def test_int_bounds_normalize_with_float_bounds() -> None: + """`int` bounds sort, merge and membership-test seamlessly with `float` ones.""" + result = BoundsSet(bounds=(Bounds(lower=1, upper=5), Bounds(lower=5.0, upper=10.0))) + assert result.bounds == (Bounds(lower=1, upper=10.0),) + assert 7 in result + assert 0 not in result + + def test_str() -> None: """The string form joins members with a union symbol.""" bounds_set = BoundsSet( diff --git a/tests/metrics/test_sample_aggregated_value.py b/tests/metrics/test_sample_aggregated_value.py index e672b4d9..43719d33 100644 --- a/tests/metrics/test_sample_aggregated_value.py +++ b/tests/metrics/test_sample_aggregated_value.py @@ -5,6 +5,7 @@ import pytest +from frequenz.client.common import FloatInt from frequenz.client.common.metrics import AggregatedMetricValue @@ -27,13 +28,21 @@ "avg:5.0", id="minimal_data", ), + pytest.param( + 5, + 1, + 10, + [1, 5.0, 10], + "avg:5", + id="int_data", + ), ], ) def test_creation_and_str( - avg: float, - min_val: float | None, - max_val: float | None, - raw: list[float], + avg: FloatInt, + min_val: FloatInt | None, + max_val: FloatInt | None, + raw: list[FloatInt], expected_str: str, ) -> None: """Test AggregatedMetricValue creation and string representation.""" diff --git a/tests/metrics/test_sample_metric_sample.py b/tests/metrics/test_sample_metric_sample.py index e9f42bdb..4d255b73 100644 --- a/tests/metrics/test_sample_metric_sample.py +++ b/tests/metrics/test_sample_metric_sample.py @@ -8,6 +8,7 @@ import pytest from frequenz.client.common import ( + FloatInt, UnrecognizedEnumValueError, UnspecifiedEnumValueError, ) @@ -58,7 +59,7 @@ def now() -> datetime: ) def test_creation( now: datetime, - value: float | AggregatedMetricValue | None, + value: FloatInt | AggregatedMetricValue | None, connection: MetricConnection | None, ) -> None: """Test MetricSample creation with different value types.""" @@ -112,12 +113,30 @@ def test_creation( }, id="none_value", ), + pytest.param( + 5, + { + AggregationMethod.AVG: 5, + AggregationMethod.MIN: 5, + AggregationMethod.MAX: 5, + }, + id="simple_int_value", + ), + pytest.param( + AggregatedMetricValue(avg=5, min=1, max=10, raw=[1, 5, 10]), + { + AggregationMethod.AVG: 5, + AggregationMethod.MIN: 1, + AggregationMethod.MAX: 10, + }, + id="aggregated_int_value", + ), ], ) def test_as_single_value( now: datetime, - value: float | AggregatedMetricValue | None, - method_results: dict[AggregationMethod, float | None], + value: FloatInt | AggregatedMetricValue | None, + method_results: dict[AggregationMethod, FloatInt | None], ) -> None: """Test MetricSample.as_single_value with different value types and methods.""" bounds_set = BoundsSet(bounds=(Bounds(lower=-10.0, upper=10.0),)) @@ -133,6 +152,19 @@ def test_as_single_value( assert sample.as_single_value(aggregation_method=method) == expected +def test_as_single_value_returns_int_untouched(now: datetime) -> None: + """An `int` value is returned as is, without coercion to `float`.""" + sample = MetricSample( + sample_time=now, + metric=Metric.AC_POWER_ACTIVE, + value=5, + bounds_set=BoundsSet(), + ) + result = sample.as_single_value() + assert result == 5 + assert type(result) is int # pylint: disable=unidiomatic-typecheck + + def test_multiple_bounds(now: datetime) -> None: """Test MetricSample creation with multiple bounds.""" bounds_set = BoundsSet( diff --git a/tests/microgrid/electrical_components/test_power_transformer.py b/tests/microgrid/electrical_components/test_power_transformer.py index 9f8eb7f0..4a127f90 100644 --- a/tests/microgrid/electrical_components/test_power_transformer.py +++ b/tests/microgrid/electrical_components/test_power_transformer.py @@ -5,6 +5,7 @@ import pytest +from frequenz.client.common import FloatInt from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ( ElectricalComponentId, @@ -25,13 +26,14 @@ def microgrid_id() -> MicrogridId: @pytest.mark.parametrize( - "primary, secondary", [(400.0, 230.0), (0.0, 0.0), (230.0, 400.0), (-230.0, -400.0)] + "primary, secondary", + [(400.0, 230.0), (0.0, 0.0), (230.0, 400.0), (-230.0, -400.0), (400, 230)], ) def test_creation_ok( component_id: ElectricalComponentId, microgrid_id: MicrogridId, - primary: float, - secondary: float, + primary: FloatInt, + secondary: FloatInt, ) -> None: """Test PowerTransformer component initialization with different voltages.""" power_transformer = PowerTransformer( diff --git a/tests/test_float.py b/tests/test_float.py new file mode 100644 index 00000000..ba1b6454 --- /dev/null +++ b/tests/test_float.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the `FloatInt` type alias.""" + +from frequenz.client.common import FloatInt + + +def test_alias_covers_float_and_int() -> None: + """The alias is exactly the `float | int` union.""" + assert FloatInt == float | int + + +def test_alias_admits_the_numeric_tower() -> None: + """`float`, `int` and (inherently) `bool` values all satisfy the alias.""" + assert isinstance(1.5, FloatInt) + assert isinstance(1, FloatInt) + # `bool` is a subclass of `int`, this is documented as not guarded against. + assert isinstance(True, FloatInt) + assert not isinstance("1.5", FloatInt) + assert not isinstance(None, FloatInt) diff --git a/tests/types/_location/test_invalid_latitude.py b/tests/types/_location/test_invalid_latitude.py index 5302e298..15d892ed 100644 --- a/tests/types/_location/test_invalid_latitude.py +++ b/tests/types/_location/test_invalid_latitude.py @@ -24,3 +24,10 @@ def test_equality() -> None: def test_str() -> None: """`InvalidLatitude.__str__` renders with a compact invalid marker.""" assert str(InvalidLatitude(value=91.0)) == "" + + +def test_int_value() -> None: + """An `int` value is stored untouched and renders like a `float`.""" + invalid = InvalidLatitude(value=91) + assert type(invalid.value) is int # pylint: disable=unidiomatic-typecheck + assert str(invalid) == "" diff --git a/tests/types/_location/test_invalid_longitude.py b/tests/types/_location/test_invalid_longitude.py index 1596f030..5c6f0e47 100644 --- a/tests/types/_location/test_invalid_longitude.py +++ b/tests/types/_location/test_invalid_longitude.py @@ -24,3 +24,10 @@ def test_equality() -> None: def test_str() -> None: """`InvalidLongitude.__str__` renders with a compact invalid marker.""" assert str(InvalidLongitude(value=181.0)) == "" + + +def test_int_value() -> None: + """An `int` value is stored untouched and renders like a `float`.""" + invalid = InvalidLongitude(value=181) + assert type(invalid.value) is int # pylint: disable=unidiomatic-typecheck + assert str(invalid) == "" diff --git a/tests/types/_location/test_location.py b/tests/types/_location/test_location.py index 4908215b..3ad2b3a7 100644 --- a/tests/types/_location/test_location.py +++ b/tests/types/_location/test_location.py @@ -8,6 +8,7 @@ import pytest +from frequenz.client.common import FloatInt from frequenz.client.common._exception import MissingFieldError from frequenz.client.common.types import ( InvalidCountryCode, @@ -61,22 +62,22 @@ def test_construction_wrapped_invalid_country_code() -> None: @pytest.mark.parametrize( "latitude", - [-90.001, 90.001, math.nan, float("inf"), float("-inf")], - ids=["below_min", "above_max", "nan", "inf", "neg_inf"], + [-90.001, 90.001, math.nan, float("inf"), float("-inf"), -91, 91], + ids=["below_min", "above_max", "nan", "inf", "neg_inf", "int_below", "int_above"], ) -def test_construction_rejects_plain_invalid_latitude(latitude: float) -> None: - """A plain `float` latitude outside `[-90, 90]` is rejected at construction.""" +def test_construction_rejects_plain_invalid_latitude(latitude: FloatInt) -> None: + """A plain latitude number outside `[-90, 90]` is rejected at construction.""" with pytest.raises(ValueError, match=r"latitude .* is outside \[-90, 90\]"): Location(latitude=latitude, longitude=13.405, country_code="DE") @pytest.mark.parametrize( "longitude", - [-180.001, 180.001, math.nan, float("inf"), float("-inf")], - ids=["below_min", "above_max", "nan", "inf", "neg_inf"], + [-180.001, 180.001, math.nan, float("inf"), float("-inf"), -181, 181], + ids=["below_min", "above_max", "nan", "inf", "neg_inf", "int_below", "int_above"], ) -def test_construction_rejects_plain_invalid_longitude(longitude: float) -> None: - """A plain `float` longitude outside `[-180, 180]` is rejected at construction.""" +def test_construction_rejects_plain_invalid_longitude(longitude: FloatInt) -> None: + """A plain longitude number outside `[-180, 180]` is rejected at construction.""" with pytest.raises(ValueError, match=r"longitude .* is outside \[-180, 180\]"): Location(latitude=52.52, longitude=longitude, country_code="DE") @@ -117,15 +118,23 @@ def test_dataclasses_replace_enforces_invariant() -> None: @pytest.mark.parametrize( "latitude", - [-90.0, 0.0, 90.0], - ids=["min_boundary", "middle", "max_boundary"], + [-90.0, 0.0, 90.0, -90, 45, 90], + ids=["min_boundary", "middle", "max_boundary", "int_min", "int_middle", "int_max"], ) -def test_get_latitude_returns_valid(latitude: float) -> None: - """`get_latitude()` returns the stored `float` when well-formed.""" +def test_get_latitude_returns_valid(latitude: FloatInt) -> None: + """`get_latitude()` returns the stored number when well-formed.""" location = Location(latitude=latitude, longitude=13.405, country_code="DE") assert location.get_latitude() == pytest.approx(latitude) +def test_get_latitude_returns_int_untouched() -> None: + """An `int` latitude is returned as is, without coercion to `float`.""" + location = Location(latitude=45, longitude=13.405, country_code="DE") + result = location.get_latitude() + assert result == 45 + assert type(result) is int # pylint: disable=unidiomatic-typecheck + + def test_get_latitude_raises_for_wrapper() -> None: """`get_latitude()` raises `InvalidLatitudeError` when latitude is wrapped.""" location = Location( @@ -146,15 +155,23 @@ def test_get_latitude_raises_for_wrapper() -> None: @pytest.mark.parametrize( "longitude", - [-180.0, 0.0, 180.0], - ids=["min_boundary", "middle", "max_boundary"], + [-180.0, 0.0, 180.0, -180, 90, 180], + ids=["min_boundary", "middle", "max_boundary", "int_min", "int_middle", "int_max"], ) -def test_get_longitude_returns_valid(longitude: float) -> None: - """`get_longitude()` returns the stored `float` when well-formed.""" +def test_get_longitude_returns_valid(longitude: FloatInt) -> None: + """`get_longitude()` returns the stored number when well-formed.""" location = Location(latitude=52.52, longitude=longitude, country_code="DE") assert location.get_longitude() == pytest.approx(longitude) +def test_get_longitude_returns_int_untouched() -> None: + """An `int` longitude is returned as is, without coercion to `float`.""" + location = Location(latitude=52.52, longitude=90, country_code="DE") + result = location.get_longitude() + assert result == 90 + assert type(result) is int # pylint: disable=unidiomatic-typecheck + + def test_get_longitude_raises_for_wrapper() -> None: """`get_longitude()` raises `InvalidLongitudeError` when longitude is wrapped.""" location = Location( @@ -259,6 +276,7 @@ def test_get_country_code_or_none_raises_for_wrapper() -> None: InvalidCountryCode(value="DEU"), "(,)", ), + (45, 90, "DE", "DE(45.00,90.00)"), ], ids=[ "valid", @@ -267,11 +285,12 @@ def test_get_country_code_or_none_raises_for_wrapper() -> None: "invalid_lat", "invalid_lon", "all_invalid", + "int_lat_lon", ], ) def test_str( - latitude: float | InvalidLatitude, - longitude: float | InvalidLongitude, + latitude: FloatInt | InvalidLatitude, + longitude: FloatInt | InvalidLongitude, country_code: str | InvalidCountryCode | None, expected: str, ) -> None: