Skip to content

Commit

Permalink
Add a new predicate to skip messages that are equal to the previous one
Browse files Browse the repository at this point in the history
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 <luca-frequenz@llucax.com>
  • Loading branch information
llucax committed Nov 28, 2024
1 parent 0a48f9e commit cea113b
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 1 deletion.
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
### Experimental

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

## Bug Fixes

Expand Down
3 changes: 2 additions & 1 deletion src/frequenz/channels/experimental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
"""

from ._pipe import Pipe
from ._predicates import OnlyIfPrevious
from ._predicates import ChangedOnly, OnlyIfPrevious
from ._relay_sender import RelaySender

__all__ = [
"ChangedOnly",
"OnlyIfPrevious",
"Pipe",
"RelaySender",
Expand Down
55 changes: 55 additions & 0 deletions src/frequenz/channels/experimental/_predicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,58 @@ def __str__(self) -> str:
def __repr__(self) -> str:
"""Return a string representation of this instance."""
return f"<{type(self).__name__}: {self._predicate!r} first_is_true={self._first_is_true!r}>"


class ChangedOnly(OnlyIfPrevious[object]):
"""A predicate to check if a message is different from the previous one.
This predicate can be used to filter out messages that are the same as the previous
one. This can be useful in cases where you want to avoid processing duplicate
messages.
Warning:
This predicate uses the `!=` operator to compare messages, which includes all
the weirdnesses of Python's equality comparison (e.g., `1 == 1.0`, `True == 1`,
`True == 1.0`, `False == 0` are all `True`).
If you need to use a different comparison, you can create a custom predicate
using [`OnlyIfPrevious`][frequenz.channels.experimental.OnlyIfPrevious].
Example:
```python
from frequenz.channels import Broadcast
from frequenz.channels.experimental import ChangedOnly
channel = Broadcast[int](name="skip_duplicates_test")
receiver = channel.new_receiver().filter(ChangedOnly())
sender = channel.new_sender()
# This message will be received as it is the first message.
await sender.send(1)
assert await receiver.receive() == 1
# This message will be skipped as it is the same as the previous one.
await sender.send(1)
# This message will be received as it is different from the previous one.
await sender.send(2)
assert await receiver.receive() == 2
```
"""

def __init__(self, *, first_is_true: bool = True) -> None:
"""Initialize this instance.
Args:
first_is_true: Whether the first message should be considered as different
from the previous one. Defaults to `True`.
"""
super().__init__(lambda old, new: old != new, first_is_true=first_is_true)

def __str__(self) -> str:
"""Return a string representation of this instance."""
return f"{type(self).__name__}"

def __repr__(self) -> str:
"""Return a string representation of this instance."""
return f"{type(self).__name__}(first_is_true={self._first_is_true!r})"
121 changes: 121 additions & 0 deletions tests/experimental/test_predicates_changed_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# License: MIT
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH

"""Tests for the ChangedOnly implementation.
Most testing is done in the OnlyIfPrevious tests, these tests are limited to the
specifics of the ChangedOnly implementation.
"""

from dataclasses import dataclass
from unittest.mock import MagicMock

import pytest

from frequenz.channels.experimental import ChangedOnly, OnlyIfPrevious


@dataclass(frozen=True, kw_only=True)
class EqualityTestCase:
"""Test case for testing ChangedOnly behavior with tricky equality cases."""

title: str
first_value: object
second_value: object
expected_second_result: bool


EQUALITY_TEST_CASES = [
# Python's equality weirdness cases
EqualityTestCase(
title="Integer equals float",
first_value=1,
second_value=1.0,
expected_second_result=False,
),
EqualityTestCase(
title="Boolean equals integer",
first_value=True,
second_value=1,
expected_second_result=False,
),
EqualityTestCase(
title="Boolean equals float",
first_value=True,
second_value=1.0,
expected_second_result=False,
),
EqualityTestCase(
title="False equals zero",
first_value=False,
second_value=0,
expected_second_result=False,
),
EqualityTestCase(
title="Zero equals False",
first_value=0,
second_value=False,
expected_second_result=False,
),
# Edge cases that should be different
EqualityTestCase(
title="NaN is never equal to NaN",
first_value=float("nan"),
second_value=float("nan"),
expected_second_result=True,
),
EqualityTestCase(
title="Different list instances with same content",
first_value=[1],
second_value=[1],
expected_second_result=False,
),
]


def test_changed_only_inheritance() -> None:
"""Test that ChangedOnly is properly inheriting from OnlyIfPrevious."""
changed_only = ChangedOnly()
assert isinstance(changed_only, OnlyIfPrevious)


def test_changed_only_predicate_implementation() -> None:
"""Test that ChangedOnly properly implements the inequality predicate."""
# Create mock objects that we can control the equality comparison for
old = MagicMock()
new = MagicMock()

# Set up the inequality comparison
# mypy doesn't understand mocking __ne__ very well
old.__ne__.return_value = True # type: ignore[attr-defined]

changed_only = ChangedOnly()
# Skip the first message as it's handled by first_is_true
changed_only(old)
changed_only(new)

# Verify that __ne__ was called with the correct argument
old.__ne__.assert_called_once_with(new) # type: ignore[attr-defined]


@pytest.mark.parametrize(
"test_case",
EQUALITY_TEST_CASES,
ids=lambda test_case: test_case.title,
)
def test_changed_only_equality_cases(test_case: EqualityTestCase) -> None:
"""Test ChangedOnly behavior with Python's tricky equality cases.
Args:
test_case: The test case containing the input values and expected result.
"""
changed_only = ChangedOnly()
assert changed_only(test_case.first_value) is True # First is always True
assert changed_only(test_case.second_value) is test_case.expected_second_result


def test_changed_only_representation() -> None:
"""Test the string representation of ChangedOnly."""
changed_only = ChangedOnly()
assert str(changed_only) == "ChangedOnly"
assert repr(changed_only) == "ChangedOnly(first_is_true=True)"

0 comments on commit cea113b

Please # to comment.