Skip to content

Commit c5c1527

Browse files
committed
Add pagination_info_from_proto2 and deprecate the old converter
`pagination_info_from_proto()` calls the `PaginationInfo` constructor directly, so it raises on a count the constructor rejects. A conversion function must never raise on wire content: the caller loses the whole message over one bad field. Add the replacement that returns `PaginationInfo | InvalidPaginationInfo` and deprecate the old one, following the path `delivery_area_from_proto` and `bounds_from_proto` already took. The warning text uses the form the deprecation guide prescribes, with both fully qualified names written out. The six deprecations already in the library use a shorter backticked form instead, so this one does not match them; the guide is the normative source, and a caller who sees only the warning can act on it without guessing the import path. The new converter also reads `next_page_token` through `HasField()` instead of testing it for truth. The field has presence, so the old truthiness test collapses "the server sent an empty token" into "the server sent no token"; only the second means there are no more pages. Changing this in place would be a silent behavior change, which is another reason it belongs in the new name rather than the old one. Note that `total_items` is a `uint32`, so a decoded message cannot in fact carry a negative count today, and the invalid branch is unreachable through the generated bindings. It is there because the converter's contract is not to raise regardless of what it is handed, and because the wire type is not the wrapper's to assume; the test exercises it with a message-like stand-in. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent f407b4f commit c5c1527

4 files changed

Lines changed: 150 additions & 11 deletions

File tree

‎docs/client-developer-guide/shipped-converters.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Import the package that matches the messages your code receives.
1717
| [`metrics`][frequenz.client.common.metrics.proto.v1alpha8] | Metric samples ([`metrics_pb2.MetricSample`][frequenz.api.common.v1alpha8.metrics.metrics_pb2.MetricSample]), connections, aggregate values, bounds and bounds sets, plus metric and connection-category enum conversion. |
1818
| [`microgrid`][frequenz.client.common.microgrid.proto.v1alpha8] | Microgrids ([`microgrid_pb2.Microgrid`][frequenz.api.common.v1alpha8.microgrid.microgrid_pb2.Microgrid]) and lifetimes through [`microgrid_from_proto`][frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto] and [`lifetime_from_proto`][frequenz.client.common.microgrid.proto.v1alpha8.lifetime_from_proto]. |
1919
| [`microgrid.electrical_components`][frequenz.client.common.microgrid.electrical_components.proto.v1alpha8] | Electrical-component classes ([`electrical_components_pb2.ElectricalComponent`][frequenz.api.common.v1alpha8.microgrid.electrical_components.electrical_components_pb2.ElectricalComponent]), component instances and connections, plus category, diagnostic-code, and state-code enums. |
20-
| [`pagination`][frequenz.client.common.pagination.proto.v1alpha8] | Pagination information ([`pagination_info_pb2.PaginationInfo`][frequenz.api.common.v1alpha8.pagination.pagination_info_pb2.PaginationInfo]) in both directions through [`pagination_info_from_proto`][frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto] and [`pagination_info_to_proto`][frequenz.client.common.pagination.proto.v1alpha8.pagination_info_to_proto]. |
20+
| [`pagination`][frequenz.client.common.pagination.proto.v1alpha8] | Pagination information ([`pagination_info_pb2.PaginationInfo`][frequenz.api.common.v1alpha8.pagination.pagination_info_pb2.PaginationInfo]) in both directions through [`pagination_info_from_proto2`][frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto2] and [`pagination_info_to_proto`][frequenz.client.common.pagination.proto.v1alpha8.pagination_info_to_proto]. |
2121
| [`streaming`][frequenz.client.common.streaming.proto.v1alpha8] | Streaming events ([`event_pb2.Event`][frequenz.api.common.v1alpha8.streaming.event_pb2.Event]) in both directions through [`event_from_proto`][frequenz.client.common.streaming.proto.v1alpha8.event_from_proto] and [`event_to_proto`][frequenz.client.common.streaming.proto.v1alpha8.event_to_proto]. |
2222
| [`types`][frequenz.client.common.types.proto.v1alpha8] | Locations ([`location_pb2.Location`][frequenz.api.common.v1alpha8.types.location_pb2.Location]) through [`location_from_proto`][frequenz.client.common.types.proto.v1alpha8.location_from_proto]. |
2323

‎src/frequenz/client/common/pagination/proto/v1alpha8/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
from ._pagination_info import (
77
pagination_info_from_proto,
8+
pagination_info_from_proto2,
89
pagination_info_to_proto,
910
)
1011

1112
__all__ = [
1213
"pagination_info_from_proto",
14+
"pagination_info_from_proto2",
1315
"pagination_info_to_proto",
1416
]

‎src/frequenz/client/common/pagination/proto/v1alpha8/_pagination_info.py‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,77 @@
66
from frequenz.api.common.v1alpha8.pagination.pagination_info_pb2 import (
77
PaginationInfo as PaginationInfoPb,
88
)
9+
from typing_extensions import deprecated
910

10-
from ....pagination import PaginationInfo
11+
from ....pagination import InvalidPaginationInfo, PaginationInfo
1112

1213

13-
def pagination_info_from_proto(message: PaginationInfoPb) -> PaginationInfo:
14+
@deprecated(
15+
"frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto "
16+
"is deprecated. Use "
17+
"frequenz.client.common.pagination.proto.v1alpha8.pagination_info_from_proto2 "
18+
"instead."
19+
)
20+
def pagination_info_from_proto( # noqa: DOC502
21+
message: PaginationInfoPb,
22+
) -> PaginationInfo:
1423
"""Convert a protobuf message to a [`PaginationInfo`][....PaginationInfo] object.
1524
25+
Warning: Deprecated
26+
Use [`pagination_info_from_proto2`][..pagination_info_from_proto2]
27+
instead. The new converter distinguishes well-formed from malformed
28+
data at the type level (`PaginationInfo | InvalidPaginationInfo`)
29+
rather than raising a `ValueError` when the invariant fires.
30+
1631
Args:
1732
message: The protobuf message to convert.
1833
1934
Returns:
2035
The corresponding [`PaginationInfo`][....PaginationInfo] object.
36+
37+
Raises:
38+
ValueError: If the message carries a negative `total_items`.
2139
"""
2240
return PaginationInfo(
2341
total_items=message.total_items,
2442
next_page_token=message.next_page_token if message.next_page_token else None,
2543
)
2644

2745

46+
def pagination_info_from_proto2(
47+
message: PaginationInfoPb,
48+
) -> PaginationInfo | InvalidPaginationInfo:
49+
"""Convert a protobuf message to pagination information, preserving malformed data.
50+
51+
Unlike [`pagination_info_from_proto`][..pagination_info_from_proto], the
52+
token is read through
53+
[`HasField()`][google.protobuf.message.Message.HasField], so a token the
54+
server actually sent is preserved even when it is empty; only an unset
55+
token becomes `None`.
56+
57+
Args:
58+
message: The protobuf message to convert.
59+
60+
Returns:
61+
A [`PaginationInfo`][....PaginationInfo] when the wire data is
62+
well-formed, or an
63+
[`InvalidPaginationInfo`][....InvalidPaginationInfo] preserving a
64+
`total_items` count that violates `total_items >= 0`.
65+
"""
66+
next_page_token = (
67+
message.next_page_token if message.HasField("next_page_token") else None
68+
)
69+
try:
70+
return PaginationInfo(
71+
total_items=message.total_items, next_page_token=next_page_token
72+
)
73+
except ValueError:
74+
pass
75+
return InvalidPaginationInfo(
76+
total_items=message.total_items, next_page_token=next_page_token
77+
)
78+
79+
2880
def pagination_info_to_proto(info: PaginationInfo) -> PaginationInfoPb:
2981
"""Convert a [`PaginationInfo`][....PaginationInfo] object to a protobuf message.
3082

‎tests/pagination/proto/v1alpha8/test_pagination_info.py‎

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,127 @@
33

44
"""Tests for pagination info protobuf v1alpha8 conversions."""
55

6+
from typing import cast
7+
8+
import pytest
69
from frequenz.api.common.v1alpha8.pagination import pagination_info_pb2
710

8-
from frequenz.client.common.pagination import PaginationInfo
11+
from frequenz.client.common.pagination import InvalidPaginationInfo, PaginationInfo
912
from frequenz.client.common.pagination.proto.v1alpha8 import (
1013
pagination_info_from_proto,
14+
pagination_info_from_proto2,
1115
pagination_info_to_proto,
1216
)
1317

1418

15-
def test_pagination_info_from_proto_with_token() -> None:
19+
def test_from_proto_with_token() -> None:
1620
"""Test converting a protobuf PaginationInfo with a token."""
1721
proto = pagination_info_pb2.PaginationInfo(total_items=100, next_page_token="token")
18-
info = pagination_info_from_proto(proto)
22+
with pytest.deprecated_call(match="pagination_info_from_proto2"):
23+
info = pagination_info_from_proto(proto)
1924
assert info.total_items == 100
2025
assert info.next_page_token == "token"
2126

2227

23-
def test_pagination_info_from_proto_empty_token() -> None:
28+
def test_from_proto_empty_token() -> None:
2429
"""Test converting an empty protobuf token to None."""
2530
proto = pagination_info_pb2.PaginationInfo(total_items=100, next_page_token="")
26-
info = pagination_info_from_proto(proto)
31+
with pytest.deprecated_call(match="pagination_info_from_proto2"):
32+
info = pagination_info_from_proto(proto)
2733
assert info.total_items == 100
2834
assert info.next_page_token is None
2935

3036

31-
def test_pagination_info_to_proto_with_token() -> None:
37+
def test_from_proto_emits_deprecation_warning() -> None:
38+
"""`pagination_info_from_proto` itself is deprecated and warns on call."""
39+
proto = pagination_info_pb2.PaginationInfo(total_items=1)
40+
with pytest.deprecated_call(
41+
match=r"^frequenz\.client\.common\.pagination\.proto\.v1alpha8\."
42+
r"pagination_info_from_proto is deprecated\. Use "
43+
r"frequenz\.client\.common\.pagination\.proto\.v1alpha8\."
44+
r"pagination_info_from_proto2 instead\.$"
45+
):
46+
pagination_info_from_proto(proto)
47+
48+
49+
def test_to_proto_with_token() -> None:
3250
"""Test converting a PaginationInfo with a token to protobuf."""
3351
info = PaginationInfo(total_items=100, next_page_token="token")
3452
proto = pagination_info_to_proto(info)
3553
assert proto.total_items == 100
3654
assert proto.next_page_token == "token"
3755

3856

39-
def test_pagination_info_roundtrip() -> None:
57+
def test_roundtrip() -> None:
4058
"""Test round-tripping PaginationInfo to protobuf and back."""
4159
info = PaginationInfo(total_items=100, next_page_token="token")
4260
proto = pagination_info_to_proto(info)
43-
roundtripped_info = pagination_info_from_proto(proto)
61+
roundtripped_info = pagination_info_from_proto2(proto)
4462
assert roundtripped_info == info
63+
64+
65+
def test_from_proto2_with_token() -> None:
66+
"""A well-formed message with a token becomes a `PaginationInfo`."""
67+
proto = pagination_info_pb2.PaginationInfo(total_items=100, next_page_token="token")
68+
info = pagination_info_from_proto2(proto)
69+
assert isinstance(info, PaginationInfo)
70+
assert info.total_items == 100
71+
assert info.next_page_token == "token"
72+
73+
74+
def test_from_proto2_unset_token() -> None:
75+
"""An unset token becomes `None`."""
76+
proto = pagination_info_pb2.PaginationInfo(total_items=100)
77+
info = pagination_info_from_proto2(proto)
78+
assert isinstance(info, PaginationInfo)
79+
assert info.total_items == 100
80+
assert info.next_page_token is None
81+
82+
83+
def test_from_proto2_keeps_explicitly_empty_token() -> None:
84+
"""A token the server sent is kept even when it is empty."""
85+
proto = pagination_info_pb2.PaginationInfo(total_items=100, next_page_token="")
86+
info = pagination_info_from_proto2(proto)
87+
assert isinstance(info, PaginationInfo)
88+
assert info.next_page_token == ""
89+
90+
91+
def test_from_proto2_zero_total_items() -> None:
92+
"""A zero count is well-formed."""
93+
info = pagination_info_from_proto2(pagination_info_pb2.PaginationInfo())
94+
assert isinstance(info, PaginationInfo)
95+
assert info.total_items == 0
96+
97+
98+
class _NegativeTotalItemsPb:
99+
"""A message-like object whose `total_items` is negative.
100+
101+
The `total_items` field is a protobuf `uint32`, so a decoded message can
102+
never carry a negative count. This stand-in exercises the invalid branch
103+
of the converter, which must not raise regardless of what it is handed.
104+
"""
105+
106+
total_items = -1
107+
next_page_token = ""
108+
109+
# pylint: disable-next=invalid-name,unused-argument
110+
def HasField(self, field_name: str) -> bool:
111+
"""Report every field as unset.
112+
113+
Args:
114+
field_name: The name of the field to check.
115+
116+
Returns:
117+
Always `False`.
118+
"""
119+
return False
120+
121+
122+
def test_from_proto2_negative_total_items() -> None:
123+
"""A negative count becomes an `InvalidPaginationInfo` instead of raising."""
124+
proto = cast(pagination_info_pb2.PaginationInfo, _NegativeTotalItemsPb())
125+
info = pagination_info_from_proto2(proto)
126+
assert isinstance(info, InvalidPaginationInfo)
127+
assert info.total_items == -1
128+
assert info.next_page_token is None
129+
assert str(info) == "items=<invalid:-1>,next=None"

0 commit comments

Comments
 (0)