[post_process] Add PropertyMatcher checkers

Change-Id: I0c2067680362d382b23029e2db9a72e4e0dd3864
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/recipes-py/+/5523933
Commit-Queue: Chan Li <chanli@chromium.org>
Auto-Submit: Rob Mohr <mohrr@google.com>
Reviewed-by: Chan Li <chanli@chromium.org>
diff --git a/recipe_engine/post_process.py b/recipe_engine/post_process.py
index 4def751..8bdd388 100644
--- a/recipe_engine/post_process.py
+++ b/recipe_engine/post_process.py
@@ -6,11 +6,17 @@
 RecipeTestApi.post_process method in GenTests.
 """
 
-from past.builtins import basestring
-
-import re
+from __future__ import annotations
 
 from collections import defaultdict, OrderedDict, namedtuple
+import re
+from typing import Callable, Mapping, TYPE_CHECKING
+
+from past.builtins import basestring
+
+if TYPE_CHECKING:
+  from recipe_engine import post_process_inputs
+  from recipe_engine.internal.test import magic_check_fn
 
 
 _filterRegexEntry = namedtuple('_filterRegexEntry', 'at_most at_least fields')
@@ -569,6 +575,56 @@
   if check(key in build_properties):
     check(build_properties[key] == value)
 
+
+def PropertyMatchesRE(
+    check: magic_check_fn.Checker,
+    step_odict: Mapping[str, post_process_inputs.Step],
+    key: str,
+    pattern: str | re.Pattern,
+):
+  """Assert that a recipe's output property `key` value matches `pattern`.
+
+  Args:
+    key - The key to look for in output properties.
+    pattern - The pattern for comparison.
+
+  Usage:
+    yield api.test(
+        ...,
+        api.post_process(PropertyMatchesRE, 'key', r'.*value.*'),
+    )
+  """
+  build_properties = GetBuildProperties(step_odict)
+
+  if check(key in build_properties):
+    if check(isinstance(build_properties[key], str)):
+      check(re.search(pattern, build_properties[key]))
+
+
+def PropertyMatchesCallable(
+    check: magic_check_fn.Checker,
+    step_odict: Mapping[str, post_process_inputs.Step],
+    key: str,
+    matcher: Callable[[Any], bool],
+):
+  """Assert that a recipe's output property `key` meets conditions of `matcher`.
+
+  Args:
+    key - The key to look for in output properties.
+    matcher - A callable that evaluates the property.
+
+  Usage:
+    yield api.test(
+        ...,
+        api.post_process(PropertyMatchesCallable, 'key', lamdda x: 'foo' in x),
+    )
+  """
+  build_properties = GetBuildProperties(step_odict)
+
+  if check(key in build_properties):
+    check(matcher(build_properties[key]))
+
+
 def PropertiesContain(check, step_odict, key):
   """Assert that a recipe's output properties contain `key`.
 
diff --git a/unittests/post_process_test.py b/unittests/post_process_test.py
index 204e475..9cf1a5b 100755
--- a/unittests/post_process_test.py
+++ b/unittests/post_process_test.py
@@ -612,24 +612,44 @@
 
   def test_property_equals_fail(self):
     failures = self.expect_fails(1, post_process.PropertyEquals, 'x', 'foobar')
+    self.assertHas(failures[0], 'check((build_properties[key] == value))')
+
+  def test_property_matches_regex_pass(self):
+    self.expect_pass(post_process.PropertyMatchesRE, 'x', r'^fo+$')
+
+  def test_property_matches_regex_not_str(self):
+    failures = self.expect_fails(1, post_process.PropertyMatchesRE, 'y',
+                                 r'^fo+$')
+    self.assertHas(failures[0], 'check(isinstance(build_properties[key], str))')
+
+  def test_property_matches_regex_fail(self):
+    failures = self.expect_fails(1, post_process.PropertyMatchesRE, 'x',
+                                 r'^fooo+$')
     self.assertHas(failures[0],
-                   'check((build_properties[key] == value))')
+                   'check(re.search(pattern, build_properties[key]))')
+
+  def test_property_matches_callable_pass(self):
+    self.expect_pass(post_process.PropertyMatchesCallable, 'y',
+                     lambda i: ''.join(i) == 'bar')
+
+  def test_property_matches_callable_fail(self):
+    failures = self.expect_fails(1, post_process.PropertyMatchesCallable, 'y',
+                                 lambda i: ''.join(i) == 'foo')
+    self.assertHas(failures[0], 'check(matcher(build_properties[key]))')
 
   def test_properties_contain_pass(self):
     self.expect_pass(post_process.PropertiesContain, 'x')
 
   def test_properties_contain_fail(self):
     failures = self.expect_fails(1, post_process.PropertiesContain, 'q')
-    self.assertHas(failures[0],
-                   'check((key in build_properties))')
+    self.assertHas(failures[0], 'check((key in build_properties))')
 
   def test_properties_do_not_contain_pass(self):
     self.expect_pass(post_process.PropertiesDoNotContain, 'q')
 
   def test_properties_do_not_contain_fail(self):
     failures = self.expect_fails(1, post_process.PropertiesDoNotContain, 'x')
-    self.assertHas(failures[0],
-                   'check((key not in build_properties))')
+    self.assertHas(failures[0], 'check((key not in build_properties))')
 
 
 if __name__ == '__main__':