Skip to content

Commit 5e56d9b

Browse files
ssbrcopybara-github
authored andcommitted
Support **$... for keyword arguments.
This pretty much just works out of the box, albeit it's ordering-dependent. For instance, to match any call to `gfile.Stat` which does _not_ include `stat_proto=True`, you can use: ```python AllOf( ExprPattern("gfile.Stat(*$..., **$...)"), Unless(ExprPattern("$_(*$..., **$..., stat_proto=True, **$...)")) ) ``` Or similar. A future change to refex (which I do want to make eventually...) could eventually mean keyword parameter and dict ordering is ignored by default, so that we can remove one of the `**$...`. PiperOrigin-RevId: 568317030
1 parent 5bbce5e commit 5e56d9b

File tree

10 files changed

+402
-164
lines changed

10 files changed

+402
-164
lines changed

refex/python/matcher.py

Lines changed: 110 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -203,74 +203,6 @@ def register_constant(name: str, constant: Any):
203203
registered_constants[name] = constant
204204

205205

206-
def coerce(value): # Nobody uses coerce. pylint: disable=redefined-builtin
207-
"""Returns the 'intended' matcher given by `value`.
208-
209-
If `value` is already a matcher, then this is what is returned.
210-
211-
If `value` is anything else, then coerce returns `ImplicitEquals(value)`.
212-
213-
Args:
214-
value: Either a Matcher, or a value to compare for equality.
215-
"""
216-
if isinstance(value, Matcher):
217-
return value
218-
else:
219-
return ImplicitEquals(value)
220-
221-
222-
def _coerce_list(values):
223-
return [coerce(v) for v in values]
224-
225-
226-
# TODO(b/199577701): drop the **kwargs: Any in the *_attrib functions.
227-
228-
_IS_SUBMATCHER_ATTRIB = __name__ + '._IS_SUBMATCHER_ATTRIB'
229-
_IS_SUBMATCHER_LIST_ATTRIB = __name__ + '._IS_SUBMATCHER_LIST_ATTRIB'
230-
231-
232-
def submatcher_attrib(*args, walk: bool = True, **kwargs: Any):
233-
"""Creates an attr.ib that is marked as a submatcher.
234-
235-
This will cause the matcher to be automatically walked as part of the
236-
computation of .bind_variables. Any submatcher that can introduce a binding
237-
must be listed as a submatcher_attrib or submatcher_list_attrib.
238-
239-
Args:
240-
*args: Forwarded to attr.ib.
241-
walk: Whether or not to walk to accumulate .bind_variables.
242-
**kwargs: Forwarded to attr.ib.
243-
244-
Returns:
245-
An attr.ib()
246-
"""
247-
if walk:
248-
kwargs.setdefault('metadata', {})[_IS_SUBMATCHER_ATTRIB] = True
249-
kwargs.setdefault('converter', coerce)
250-
return attr.ib(*args, **kwargs)
251-
252-
253-
def submatcher_list_attrib(*args, walk: bool = True, **kwargs: Any):
254-
"""Creates an attr.ib that is marked as an iterable of submatchers.
255-
256-
This will cause the matcher to be automatically walked as part of the
257-
computation of .bind_variables. Any submatcher that can introduce a binding
258-
must be listed as a submatcher_attrib or submatcher_list_attrib.
259-
260-
Args:
261-
*args: Forwarded to attr.ib.
262-
walk: Whether or not to walk to accumulate .bind_variables.
263-
**kwargs: Forwarded to attr.ib.
264-
265-
Returns:
266-
An attr.ib()
267-
"""
268-
if walk:
269-
kwargs.setdefault('metadata', {})[_IS_SUBMATCHER_LIST_ATTRIB] = True
270-
kwargs.setdefault('converter', _coerce_list)
271-
return attr.ib(*args, **kwargs)
272-
273-
274206
# TODO: make MatchObject, MatchInfo, and Matcher generic, parameterized
275207
# by match type. Since pytype doesn't support generics yet, that's not an
276208
# option, but it'd greatly clarify the API by allowing us to classify matchers
@@ -921,6 +853,16 @@ def bind_variables(self):
921853
type_filter = None
922854

923855

856+
class ContextualMatcher(Matcher):
857+
"""A matcher which requires special understanding in context.
858+
859+
By default, contextual matchers are not allowed inside of a submatcher
860+
attribute.
861+
To allow one, specify, for instance,
862+
``submatcher_attrib(contextual=MyContextualMatcher)``.
863+
"""
864+
865+
pass
924866

925867

926868
def accumulating_matcher(f):
@@ -1026,6 +968,106 @@ class ParseError(Exception):
1026968
"""
1027969

1028970

971+
def coerce(value): # Nobody uses coerce. pylint: disable=redefined-builtin
972+
"""Returns the 'intended' matcher given by `value`.
973+
974+
If `value` is already a matcher, then this is what is returned.
975+
976+
If `value` is anything else, then coerce returns `ImplicitEquals(value)`.
977+
978+
Args:
979+
value: Either a Matcher, or a value to compare for equality.
980+
"""
981+
if isinstance(value, Matcher):
982+
return value
983+
else:
984+
return ImplicitEquals(value)
985+
986+
987+
def _coerce_list(values):
988+
return [coerce(v) for v in values]
989+
990+
991+
# TODO(b/199577701): drop the **kwargs: Any in the *_attrib functions.
992+
993+
_IS_SUBMATCHER_ATTRIB = __name__ + '._IS_SUBMATCHER_ATTRIB'
994+
_IS_SUBMATCHER_LIST_ATTRIB = __name__ + '._IS_SUBMATCHER_LIST_ATTRIB'
995+
996+
997+
def _submatcher_validator(old_validator, contextual):
998+
def validator(o: object, attribute: attr.Attribute, m: Matcher):
999+
if isinstance(m, ContextualMatcher) and not isinstance(m, contextual):
1000+
raise TypeError(
1001+
f'Cannot use a `{m}` in `{type(o).__name__}.{attribute.name}`.'
1002+
)
1003+
if old_validator is not None:
1004+
old_validator(o, attribute, m)
1005+
1006+
return validator
1007+
1008+
1009+
def submatcher_attrib(
1010+
*args,
1011+
walk: bool = True,
1012+
contextual: type[ContextualMatcher]
1013+
| tuple[type[ContextualMatcher], ...] = (),
1014+
**kwargs: Any,
1015+
):
1016+
"""Creates an attr.ib that is marked as a submatcher.
1017+
1018+
This will cause the matcher to be automatically walked as part of the
1019+
computation of .bind_variables. Any submatcher that can introduce a binding
1020+
must be listed as a submatcher_attrib or submatcher_list_attrib.
1021+
1022+
Args:
1023+
*args: Forwarded to attr.ib.
1024+
walk: Whether or not to walk to accumulate .bind_variables.
1025+
contextual: The contextual matcher classes to allow, if any.
1026+
**kwargs: Forwarded to attr.ib.
1027+
1028+
Returns:
1029+
An attr.ib()
1030+
"""
1031+
if walk:
1032+
kwargs.setdefault('metadata', {})[_IS_SUBMATCHER_ATTRIB] = True
1033+
kwargs.setdefault('converter', coerce)
1034+
kwargs['validator'] = _submatcher_validator(
1035+
kwargs.get('validator'), contextual
1036+
)
1037+
return attr.ib(*args, **kwargs)
1038+
1039+
1040+
def submatcher_list_attrib(
1041+
*args,
1042+
walk: bool = True,
1043+
contextual: type[ContextualMatcher]
1044+
| tuple[type[ContextualMatcher], ...] = (),
1045+
**kwargs: Any,
1046+
):
1047+
"""Creates an attr.ib that is marked as an iterable of submatchers.
1048+
1049+
This will cause the matcher to be automatically walked as part of the
1050+
computation of .bind_variables. Any submatcher that can introduce a binding
1051+
must be listed as a submatcher_attrib or submatcher_list_attrib.
1052+
1053+
Args:
1054+
*args: Forwarded to attr.ib.
1055+
walk: Whether or not to walk to accumulate .bind_variables.
1056+
contextual: The contextual matcher classes to allow, if any.
1057+
**kwargs: Forwarded to attr.ib.
1058+
1059+
Returns:
1060+
An attr.ib()
1061+
"""
1062+
if walk:
1063+
kwargs.setdefault('metadata', {})[_IS_SUBMATCHER_LIST_ATTRIB] = True
1064+
kwargs.setdefault('converter', _coerce_list)
1065+
kwargs['validator'] = attr.validators.deep_iterable(
1066+
_submatcher_validator(kwargs.get('validator'), contextual)
1067+
)
1068+
return attr.ib(*args, **kwargs)
1069+
1070+
10291071
@attr.s(frozen=True)
10301072
class _CompareById:
10311073
"""Wrapper object to compare things by identity."""

refex/python/matchers/ast_matchers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,33 @@ def _match(self, context, candidate):
206206
(type(...),))
207207

208208
type_filter = frozenset({ast.Constant})
209+
210+
211+
#####################################
212+
# High level AST matching overrides #
213+
#####################################
214+
215+
# Firstly, a `*$...` is always equivalent to a `$...`.
216+
217+
_old_starred = Starred # pylint: disable=undefined-variable
218+
219+
220+
def Starred(**kw): # pylint: disable=invalid-name
221+
value = kw.get('value')
222+
if isinstance(value, base_matchers.GlobStar):
223+
return value
224+
return _old_starred(**kw)
225+
226+
227+
# Similarly, a `**$...` is always morally-equivalent to a `$...=$...` in a call.
228+
# (But the latter isn't valid syntax atm, so this is the only way to spell it.)
229+
230+
_old_keyword = keyword # pylint: disable=undefined-variable
231+
232+
233+
def keyword(**kw):
234+
value = kw.get('value')
235+
arg = kw.get('arg')
236+
if arg == base_matchers.Equals(None) and isinstance(value, base_matchers.GlobStar):
237+
return value
238+
return _old_keyword(**kw)

refex/python/matchers/base_matchers.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,17 @@ def _match(self, context, candidate):
125125
raise TestOnlyRaisedError(self.message)
126126

127127

128-
@attr.s(init=False, frozen=True)
128+
@attr.s(frozen=True)
129129
class _NAryMatcher(matcher.Matcher):
130130
"""Base class for matchers which take arbitrarily many submatchers in init."""
131131

132132
_matchers = matcher.submatcher_list_attrib()
133133

134+
135+
# override __init__ to take *args
136+
class _NAryMatcher(_NAryMatcher):
134137
def __init__(self, *matchers):
135-
super(_NAryMatcher, self).__init__()
136-
self.__dict__['_matchers'] = matchers
138+
super().__init__(matchers)
137139

138140

139141
@matcher.safe_to_eval
@@ -275,7 +277,6 @@ class Bind(matcher.Matcher):
275277
<refex.python.matcher.BindMerge>`, or None for the default strategy
276278
(``KEEP_LAST``).
277279
"""
278-
_NAME_REGEX = re.compile(r'\A(?!__)[a-zA-Z_]\w*\Z')
279280

280281
name = attr.ib()
281282
_submatcher = matcher.submatcher_attrib(default=Anything())
@@ -284,7 +285,12 @@ class Bind(matcher.Matcher):
284285
validator=attr.validators.in_(frozenset(matcher.BindConflict) | {None}))
285286
_on_merge = attr.ib(
286287
default=None,
287-
validator=attr.validators.in_(frozenset(matcher.BindMerge) | {None}))
288+
validator=attr.validators.in_(frozenset(matcher.BindMerge) | {None}),
289+
)
290+
291+
# Constants go after attrs-fields to work around static analysis tooling:
292+
# b/301979723
293+
_NAME_REGEX = re.compile(r'\A(?!__)[a-zA-Z_]\w*\Z')
288294

289295
@name.validator
290296
def _name_validator(self, attribute, value):
@@ -781,12 +787,15 @@ def _match(self, context, candidate):
781787
# bindings -- you can't add a bound GlobStar() :(
782788
# @matcher.safe_to_eval
783789
@attr.s(frozen=True)
784-
class GlobStar(matcher.Matcher):
790+
class GlobStar(matcher.ContextualMatcher):
785791
"""Matches any sequence of items in a sequence.
786792
787-
Only valid within :class:`Glob`.
793+
Only valid within special matchers like :class:`Glob`.
788794
"""
789795

796+
def __str__(self):
797+
return '$...'
798+
790799
def _match(self, context, candidate):
791800
del context, candidate # unused
792801
# _match isn't called by GlobMatcher; it instead specially recognizes it
@@ -827,7 +836,7 @@ class Glob(matcher.Matcher):
827836
class:`GlobStar()` is only valid directly within the body of a `Glob`.
828837
"""
829838

830-
_matchers = matcher.submatcher_list_attrib()
839+
_matchers = matcher.submatcher_list_attrib(contextual=GlobStar)
831840

832841
@cached_property.cached_property
833842
def _blocked_matchers(self):

0 commit comments

Comments
 (0)