Skip to content

Commit 2b9541c

Browse files
committed
Add a new predicate to skip messages that are equal to the previous one
The new `ChangedOnly` predicate is is just a special case of `OnlyIfPrevious` that skips messages that are equal to the previous one. This is such a common use case that it deserves its own predicate. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 2a3dd31 commit 2b9541c

File tree

4 files changed

+179
-1
lines changed

4 files changed

+179
-1
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
### Experimental
1414

1515
- A new predicate, `OnlyIfPrevious`, to `filter()` messages based on the previous message.
16+
- A new special case of `OnlyIfPrevious`, `ChangedOnly`, to skip messages if they are equal to the previous message.
1617

1718
## Bug Fixes
1819

src/frequenz/channels/experimental/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
"""
1111

1212
from ._pipe import Pipe
13-
from ._predicates import OnlyIfPrevious
13+
from ._predicates import ChangedOnly, OnlyIfPrevious
1414
from ._relay_sender import RelaySender
1515

1616
__all__ = [
17+
"ChangedOnly",
1718
"OnlyIfPrevious",
1819
"Pipe",
1920
"RelaySender",

src/frequenz/channels/experimental/_predicates.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,58 @@ def __str__(self) -> str:
127127
def __repr__(self) -> str:
128128
"""Return a string representation of this instance."""
129129
return f"<{type(self).__name__}: {self._predicate!r} first_is_true={self._first_is_true!r}>"
130+
131+
132+
class ChangedOnly(OnlyIfPrevious[object]):
133+
"""A predicate to check if a message is different from the previous one.
134+
135+
This predicate can be used to filter out messages that are the same as the previous
136+
one. This can be useful in cases where you want to avoid processing duplicate
137+
messages.
138+
139+
Warning:
140+
This predicate uses the `!=` operator to compare messages, which includes all
141+
the weirdnesses of Python's equality comparison (e.g., `1 == 1.0`, `True == 1`,
142+
`True == 1.0`, `False == 0` are all `True`).
143+
144+
If you need to use a different comparison, you can create a custom predicate
145+
using [`OnlyIfPrevious`][frequenz.channels.experimental.OnlyIfPrevious].
146+
147+
Example:
148+
```python
149+
from frequenz.channels import Broadcast
150+
from frequenz.channels.experimental import ChangedOnly
151+
152+
channel = Broadcast[int](name="skip_duplicates_test")
153+
receiver = channel.new_receiver().filter(ChangedOnly())
154+
sender = channel.new_sender()
155+
156+
# This message will be received as it is the first message.
157+
await sender.send(1)
158+
assert await receiver.receive() == 1
159+
160+
# This message will be skipped as it is the same as the previous one.
161+
await sender.send(1)
162+
163+
# This message will be received as it is different from the previous one.
164+
await sender.send(2)
165+
assert await receiver.receive() == 2
166+
```
167+
"""
168+
169+
def __init__(self, *, first_is_true: bool = True) -> None:
170+
"""Initialize this instance.
171+
172+
Args:
173+
first_is_true: Whether the first message should be considered as different
174+
from the previous one. Defaults to `True`.
175+
"""
176+
super().__init__(lambda old, new: old != new, first_is_true=first_is_true)
177+
178+
def __str__(self) -> str:
179+
"""Return a string representation of this instance."""
180+
return f"{type(self).__name__}"
181+
182+
def __repr__(self) -> str:
183+
"""Return a string representation of this instance."""
184+
return f"{type(self).__name__}(first_is_true={self._first_is_true!r})"
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the ChangedOnly implementation.
5+
6+
Most testing is done in the OnlyIfPrevious tests, these tests are limited to the
7+
specifics of the ChangedOnly implementation.
8+
"""
9+
10+
from dataclasses import dataclass
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
15+
from frequenz.channels.experimental import ChangedOnly, OnlyIfPrevious
16+
17+
18+
@dataclass(frozen=True, kw_only=True)
19+
class EqualityTestCase:
20+
"""Test case for testing ChangedOnly behavior with tricky equality cases."""
21+
22+
title: str
23+
first_value: object
24+
second_value: object
25+
expected_second_result: bool
26+
27+
28+
EQUALITY_TEST_CASES = [
29+
# Python's equality weirdness cases
30+
EqualityTestCase(
31+
title="Integer equals float",
32+
first_value=1,
33+
second_value=1.0,
34+
expected_second_result=False,
35+
),
36+
EqualityTestCase(
37+
title="Boolean equals integer",
38+
first_value=True,
39+
second_value=1,
40+
expected_second_result=False,
41+
),
42+
EqualityTestCase(
43+
title="Boolean equals float",
44+
first_value=True,
45+
second_value=1.0,
46+
expected_second_result=False,
47+
),
48+
EqualityTestCase(
49+
title="False equals zero",
50+
first_value=False,
51+
second_value=0,
52+
expected_second_result=False,
53+
),
54+
EqualityTestCase(
55+
title="Zero equals False",
56+
first_value=0,
57+
second_value=False,
58+
expected_second_result=False,
59+
),
60+
# Edge cases that should be different
61+
EqualityTestCase(
62+
title="NaN is never equal to NaN",
63+
first_value=float("nan"),
64+
second_value=float("nan"),
65+
expected_second_result=True,
66+
),
67+
EqualityTestCase(
68+
title="Different list instances with same content",
69+
first_value=[1],
70+
second_value=[1],
71+
expected_second_result=False,
72+
),
73+
]
74+
75+
76+
def test_changed_only_inheritance() -> None:
77+
"""Test that ChangedOnly is properly inheriting from OnlyIfPrevious."""
78+
changed_only = ChangedOnly()
79+
assert isinstance(changed_only, OnlyIfPrevious)
80+
81+
82+
def test_changed_only_predicate_implementation() -> None:
83+
"""Test that ChangedOnly properly implements the inequality predicate."""
84+
# Create mock objects that we can control the equality comparison for
85+
old = MagicMock()
86+
new = MagicMock()
87+
88+
# Set up the inequality comparison
89+
# mypy doesn't understand mocking __ne__ very well
90+
old.__ne__.return_value = True # type: ignore[attr-defined]
91+
92+
changed_only = ChangedOnly()
93+
# Skip the first message as it's handled by first_is_true
94+
changed_only(old)
95+
changed_only(new)
96+
97+
# Verify that __ne__ was called with the correct argument
98+
old.__ne__.assert_called_once_with(new) # type: ignore[attr-defined]
99+
100+
101+
@pytest.mark.parametrize(
102+
"test_case",
103+
EQUALITY_TEST_CASES,
104+
ids=lambda test_case: test_case.title,
105+
)
106+
def test_changed_only_equality_cases(test_case: EqualityTestCase) -> None:
107+
"""Test ChangedOnly behavior with Python's tricky equality cases.
108+
109+
Args:
110+
test_case: The test case containing the input values and expected result.
111+
"""
112+
changed_only = ChangedOnly()
113+
assert changed_only(test_case.first_value) is True # First is always True
114+
assert changed_only(test_case.second_value) is test_case.expected_second_result
115+
116+
117+
def test_changed_only_representation() -> None:
118+
"""Test the string representation of ChangedOnly."""
119+
changed_only = ChangedOnly()
120+
assert str(changed_only) == "ChangedOnly"
121+
assert repr(changed_only) == "ChangedOnly(first_is_true=True)"

0 commit comments

Comments
 (0)