From 084c16425a5ba2e70af9096e776405b6bb15a2a4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 16 Jun 2026 10:11:26 +0000 Subject: [PATCH 1/3] Validate `Location` coordinates in `__post_init__` Reject out-of-range and NaN latitude or longitude values on direct construction using the same inclusive bounds as the proto converter, while keeping converter sanitization unchanged. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/types/_location.py | 11 +++ tests/types/test_location.py | 83 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/frequenz/client/common/types/_location.py b/src/frequenz/client/common/types/_location.py index 9d14a918..307d16a0 100644 --- a/src/frequenz/client/common/types/_location.py +++ b/src/frequenz/client/common/types/_location.py @@ -19,6 +19,17 @@ class Location: country_code: str | None """The country code in ISO 3166-1 Alpha 2 format.""" + def __post_init__(self) -> None: + """Validate latitude and longitude are within their respective ranges.""" + if self.latitude is not None and not -90.0 <= self.latitude <= 90.0: + raise ValueError( + f"latitude must be in the range [-90, 90], got {self.latitude!r}" + ) + if self.longitude is not None and not -180.0 <= self.longitude <= 180.0: + raise ValueError( + f"longitude must be in the range [-180, 180], got {self.longitude!r}" + ) + def __str__(self) -> str: """Return the short string representation of this instance.""" country = self.country_code or "" diff --git a/tests/types/test_location.py b/tests/types/test_location.py index 2c82c7ac..8949a446 100644 --- a/tests/types/test_location.py +++ b/tests/types/test_location.py @@ -3,6 +3,8 @@ """Tests for the microgrid metadata types.""" +import math + import pytest from frequenz.client.common.types import Location @@ -48,3 +50,84 @@ def test_location_str( latitude=latitude, longitude=longitude, country_code=country_code ) assert str(location) == expected + + +@pytest.mark.parametrize( + "latitude, longitude", + [ + (-90.0, 0.0), + (90.0, 0.0), + (0.0, -180.0), + (0.0, 180.0), + (-90.0, -180.0), + (90.0, 180.0), + ], + ids=[ + "lat_min_boundary", + "lat_max_boundary", + "lon_min_boundary", + "lon_max_boundary", + "both_min_boundary", + "both_max_boundary", + ], +) +def test_location_boundary_values_accepted(latitude: float, longitude: float) -> None: + """Test that boundary latitude/longitude values are accepted.""" + location = Location(latitude=latitude, longitude=longitude, country_code=None) + assert location.latitude == latitude + assert location.longitude == longitude + + +@pytest.mark.parametrize( + "latitude, longitude, match", + [ + (-90.001, 0.0, "latitude"), + (90.001, 0.0, "latitude"), + (0.0, -180.001, "longitude"), + (0.0, 180.001, "longitude"), + ], + ids=[ + "lat_below_min", + "lat_above_max", + "lon_below_min", + "lon_above_max", + ], +) +def test_location_out_of_range_raises( + latitude: float, longitude: float, match: str +) -> None: + """Test that out-of-range latitude/longitude raises ValueError.""" + with pytest.raises(ValueError, match=match): + Location(latitude=latitude, longitude=longitude, country_code=None) + + +@pytest.mark.parametrize( + "latitude, longitude", + [ + (None, 13.405), + (52.52, None), + (None, None), + ], + ids=["lat_none", "lon_none", "both_none"], +) +def test_location_partial_none_accepted( + latitude: float | None, longitude: float | None +) -> None: + """Test that partial None coordinates are valid.""" + location = Location(latitude=latitude, longitude=longitude, country_code=None) + assert location.latitude == latitude + assert location.longitude == longitude + + +@pytest.mark.parametrize( + "latitude, longitude, match", + [ + (math.nan, 0.0, "latitude"), + (0.0, math.nan, "longitude"), + ], + ids=["nan_lat", "nan_lon"], +) +def test_location_nan_raises(latitude: float, longitude: float, match: str) -> None: + """Test that NaN latitude/longitude raises ValueError.""" + with pytest.raises(ValueError, match=match): + Location(latitude=latitude, longitude=longitude, country_code=None) From 22a9bedcfcd8b91a06fd39f9e8e86e92a679b5ed Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 16 Jun 2026 10:11:48 +0000 Subject: [PATCH 2/3] Validate `PaginationInfo.total_items` is non-negative Reject negative `total_items` values with the exact value in the error message while keeping zero and positive values valid. Signed-off-by: Leandro Lucarella --- .../common/pagination/_pagination_info.py | 7 +++++ tests/pagination/test_pagination_info.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/pagination/test_pagination_info.py diff --git a/src/frequenz/client/common/pagination/_pagination_info.py b/src/frequenz/client/common/pagination/_pagination_info.py index fe762760..77978695 100644 --- a/src/frequenz/client/common/pagination/_pagination_info.py +++ b/src/frequenz/client/common/pagination/_pagination_info.py @@ -15,3 +15,10 @@ class PaginationInfo: next_page_token: str | None = None """The token identifying the next page of results.""" + + def __post_init__(self) -> None: + """Validate pagination information.""" + if self.total_items < 0: + raise ValueError( + f"total_items must be non-negative, not {self.total_items}" + ) diff --git a/tests/pagination/test_pagination_info.py b/tests/pagination/test_pagination_info.py new file mode 100644 index 00000000..f2d580b7 --- /dev/null +++ b/tests/pagination/test_pagination_info.py @@ -0,0 +1,29 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for pagination info.""" + +import pytest + +from frequenz.client.common.pagination import PaginationInfo + + +def test_pagination_info_accepts_zero_total_items() -> None: + """Zero total items should be accepted.""" + info = PaginationInfo(total_items=0) + assert info.total_items == 0 + + +def test_pagination_info_accepts_positive_total_items() -> None: + """Positive total items should be accepted.""" + info = PaginationInfo(total_items=1) + assert info.total_items == 1 + + +def test_pagination_info_rejects_negative_total_items() -> None: + """Negative total items should be rejected with the exact message.""" + with pytest.raises( + ValueError, + match=r"^total_items must be non-negative, not -1$", + ): + PaginationInfo(total_items=-1) From 07291264bfbd75c71e68966887aed0505fb221dc Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 16 Jun 2026 10:11:52 +0000 Subject: [PATCH 3/3] Fix `rated_fuse_current` validation error message Keep accepting zero by preserving the `< 0` guard and correct the message to say non-negative instead of positive. Signed-off-by: Leandro Lucarella --- .../microgrid/electrical_components/_grid_connection_point.py | 2 +- .../electrical_components/test_grid_connection_point.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py b/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py index d94b412c..ef6ee413 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py @@ -57,5 +57,5 @@ def __post_init__(self) -> None: """Validate the fuse's rated current.""" if self.rated_fuse_current < 0: raise ValueError( - f"rated_fuse_current must be a positive integer, not {self.rated_fuse_current}" + f"rated_fuse_current must be a non-negative integer, not {self.rated_fuse_current}" ) diff --git a/tests/microgrid/electrical_components/test_grid_connection_point.py b/tests/microgrid/electrical_components/test_grid_connection_point.py index e5b02aad..c4f3e39f 100644 --- a/tests/microgrid/electrical_components/test_grid_connection_point.py +++ b/tests/microgrid/electrical_components/test_grid_connection_point.py @@ -51,7 +51,7 @@ def test_creation_invalid_rated_fuse_current( ) -> None: """Test Fuse component initialization with invalid rated current.""" with pytest.raises( - ValueError, match="rated_fuse_current must be a positive integer, not -1" + ValueError, match="rated_fuse_current must be a non-negative integer, not -1" ): GridConnectionPoint( id=component_id,