This is an automated email from the ASF dual-hosted git repository.

tvalentyn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new b15e5f47d40 Add equal_to_approx matcher for approximate numeric 
assertions (#39443)
b15e5f47d40 is described below

commit b15e5f47d405170c8496b6073938174e2b4cef1e
Author: SreeramaYeshwanthGowd <[email protected]>
AuthorDate: Fri Jul 24 00:36:20 2026 +0530

    Add equal_to_approx matcher for approximate numeric assertions (#39443)
    
    * Add equal_to_approx matcher for approximate numeric assertions
    
    equal_to only supports exact equality, so tests over floating point pipeline
    output had to supply a custom equals_fn. Add equal_to_approx, which compares
    real number elements (including numbers nested in tuples or lists) with
    math.isclose using configurable rel_tol and abs_tol, reusing the existing
    equal_to matching. Includes unit tests and a CHANGES.md entry.
    
    Fixes #18028
    
    * Address review feedback: show int inputs and expand equal_to_approx 
docstring
    
    Apply reviewer suggestions on #39443: mix an int into the equal_to_approx
    tests to show integer inputs are accepted, and expand the equal_to_approx
    docstring with an Args section and an example.
---
 CHANGES.md                                   |  1 +
 sdks/python/apache_beam/testing/util.py      | 43 +++++++++++++++++++++++++++
 sdks/python/apache_beam/testing/util_test.py | 44 ++++++++++++++++++++++++++++
 3 files changed, 88 insertions(+)

diff --git a/CHANGES.md b/CHANGES.md
index 59c7ac7b24b..7ea2cccde29 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -70,6 +70,7 @@
 
 * (Python) Removed the `envoy-data-plane` (and transitive `betterproto`) 
dependency; `EnvoyRateLimiter` now uses a small vendored protobuf definition 
instead, resolving dependency conflicts for downstream projects 
([#37854](https://github.com/apache/beam/issues/37854)).
 * (Java) Supported acknowledge mode for JmsIO 
([#39253](https://github.com/apache/beam/issues/39253)).
+* (Python) Added `equal_to_approx`, an `assert_that` matcher that compares 
numeric pipeline outputs with a configurable tolerance 
([#18028](https://github.com/apache/beam/issues/18028)).
 * X feature added (Java/Python) 
([#X](https://github.com/apache/beam/issues/X)).
 
 ## Breaking Changes
diff --git a/sdks/python/apache_beam/testing/util.py 
b/sdks/python/apache_beam/testing/util.py
index cfe8221b4aa..bf969d5aa00 100644
--- a/sdks/python/apache_beam/testing/util.py
+++ b/sdks/python/apache_beam/testing/util.py
@@ -22,6 +22,8 @@
 import collections
 import glob
 import io
+import math
+import numbers
 import tempfile
 from typing import Any
 from typing import Iterable
@@ -44,6 +46,7 @@ from apache_beam.utils.windowed_value import PaneInfo
 __all__ = [
     'assert_that',
     'equal_to',
+    'equal_to_approx',
     'equal_to_per_window',
     'has_at_least_one',
     'is_empty',
@@ -231,6 +234,46 @@ def row_namedtuple_equals_fn(expected, actual, 
fallback_equals_fn=None):
   return True
 
 
+def equal_to_approx(expected, rel_tol=1e-09, abs_tol=0.0):
+  """Matcher used by assert_that that compares numeric elements approximately.
+
+  Behaves similarly to `equal_to` for sequence ordering and membership, but any
+  real number elements (integers and floats) are compared using `math.isclose`
+  instead of exact equality.
+
+  Approximate comparisons are also applied to real numbers nested within lists
+  and tuples. Note that `equal_to`'s advanced handling for Beam `Row` and
+  `NamedTuple` is not supported here. All other elements are compared using
+  standard `==` equality.
+
+  Args:
+    expected: The expected output or sequence to compare against.
+    rel_tol: The relative tolerance used by `math.isclose` (default: 1e-09).
+    abs_tol: The absolute tolerance used by `math.isclose` (default: 0.0).
+
+  Example:
+    assert_that(
+        pipeline | beam.Create([1.000000001, 2.0]),
+        equal_to_approx([1.0, 2.0]))
+  """
+  def _approx_equals(expected_element, actual_element):
+    return _elements_approx_equal(
+        expected_element, actual_element, rel_tol, abs_tol)
+
+  return equal_to(expected, equals_fn=_approx_equals)
+
+
+def _elements_approx_equal(expected, actual, rel_tol, abs_tol):
+  if all(isinstance(x, numbers.Real) for x in (expected, actual)):
+    return math.isclose(expected, actual, rel_tol=rel_tol, abs_tol=abs_tol)
+  if (isinstance(expected, (list, tuple)) and type(actual) is type(expected) 
and
+      len(expected) == len(actual)):
+    return all(
+        _elements_approx_equal(e, a, rel_tol, abs_tol)
+        for e, a in zip(expected, actual))
+  return expected == actual
+
+
 def matches_all(expected):
   """Matcher used by assert_that to check a set of matchers.
 
diff --git a/sdks/python/apache_beam/testing/util_test.py 
b/sdks/python/apache_beam/testing/util_test.py
index 12314f4653a..87f6162ea31 100644
--- a/sdks/python/apache_beam/testing/util_test.py
+++ b/sdks/python/apache_beam/testing/util_test.py
@@ -29,6 +29,7 @@ from apache_beam.testing.test_pipeline import TestPipeline
 from apache_beam.testing.util import TestWindowedValue
 from apache_beam.testing.util import assert_that
 from apache_beam.testing.util import equal_to
+from apache_beam.testing.util import equal_to_approx
 from apache_beam.testing.util import equal_to_per_window
 from apache_beam.testing.util import is_empty
 from apache_beam.testing.util import is_not_empty
@@ -93,6 +94,49 @@ class UtilTest(unittest.TestCase):
           p | Create([1, 2, 3]),
           equal_to(['1', '2', '3'], equals_fn=lambda e, a: int(e) == int(a)))
 
+  def test_equal_to_approx(self):
+    with TestPipeline() as p:
+      assert_that(
+          p | Create([1.0, 2, 3.0]), equal_to_approx([3.0000000001, 2.0, 1.0]))
+
+  def test_equal_to_approx_nested(self):
+    with TestPipeline() as p:
+      assert_that(
+          p | Create([('a', 1.0), ('b', 2.0)]),
+          equal_to_approx([('b', 2.0000000001), ('a', 1)]))
+
+  def test_equal_to_approx_with_abs_tol(self):
+    with TestPipeline() as p:
+      assert_that(p | Create([0.0]), equal_to_approx([1e-10], abs_tol=1e-9))
+
+  def test_equal_to_approx_fails_outside_tolerance(self):
+    with self.assertRaises(Exception):
+      with TestPipeline() as p:
+        assert_that(p | Create([1.0]), equal_to_approx([1.1]))
+
+  def test_equal_to_approx_nested_list(self):
+    with TestPipeline() as p:
+      assert_that(
+          p | Create([[1.0, 2.0]]), equal_to_approx([[1.0000000001, 2.0]]))
+
+  def test_equal_to_approx_non_numeric(self):
+    with TestPipeline() as p:
+      assert_that(p | Create(['a', 'b']), equal_to_approx(['b', 'a']))
+
+  def test_equal_to_approx_empty(self):
+    with TestPipeline() as p:
+      assert_that(p | Create([]), equal_to_approx([]))
+
+  def test_equal_to_approx_with_rel_tol(self):
+    with TestPipeline() as p:
+      assert_that(
+          p | Create([100.0]), equal_to_approx([100.00001], rel_tol=1e-6))
+
+  def test_equal_to_approx_nested_fails_outside_tolerance(self):
+    with self.assertRaises(Exception):
+      with TestPipeline() as p:
+        assert_that(p | Create([('a', 1.0)]), equal_to_approx([('a', 1.2)]))
+
   def test_reified_value_passes(self):
     expected = [
         TestWindowedValue(v, MIN_TIMESTAMP, [GlobalWindow()])

Reply via email to