def test_and_operator_matcher_true(self) -> None: # Match on True identifier in roundabout way. self.assertTrue( matches(cst.Name("True"), m.Name() & m.Name(value=m.MatchRegex(r"True"))) ) # Match in a really roundabout way that verifies the __or__ behavior on # AllOf itself. self.assertTrue( matches( cst.Name("True"), m.Name() & m.Name(value=m.MatchRegex(r"True")) & m.Name("True"), ) ) # Verify that MatchIfTrue works with __and__ behavior properly. self.assertTrue( matches( cst.Name("True"), m.MatchIfTrue(lambda x: isinstance(x, cst.Name)) & m.Name(value=m.MatchRegex(r"True")), ) ) self.assertTrue( matches( cst.Name("True"), m.Name(value=m.MatchRegex(r"True")) & m.MatchIfTrue(lambda x: isinstance(x, cst.Name)), ) )
def leave_BinaryOperation( self, original_node: cst.BinaryOperation, updated_node: cst.BinaryOperation ) -> cst.BaseExpression: expr_key = "expr" extracts = m.extract( original_node, m.BinaryOperation( left=m.MatchIfTrue(_match_simple_string), operator=m.Modulo(), right=m.SaveMatchedNode( m.MatchIfTrue(_gen_match_simple_expression(self.module)), expr_key, ), ), ) if extracts: expr = extracts[expr_key] parts = [] simple_string = cst.ensure_type(original_node.left, cst.SimpleString) innards = simple_string.raw_value.replace("{", "{{").replace("}", "}}") tokens = innards.split("%s") token = tokens[0] if len(token) > 0: parts.append(cst.FormattedStringText(value=token)) expressions = ( [elm.value for elm in expr.elements] if isinstance(expr, cst.Tuple) else [expr] ) escape_transformer = EscapeStringQuote(simple_string.quote) i = 1 while i < len(tokens): if i - 1 >= len(expressions): # the %-string doesn't come with same number of elements in tuple return original_node try: parts.append( cst.FormattedStringExpression( expression=cast( cst.BaseExpression, expressions[i - 1].visit(escape_transformer), ) ) ) except Exception: return original_node token = tokens[i] if len(token) > 0: parts.append(cst.FormattedStringText(value=token)) i += 1 start = f"f{simple_string.prefix}{simple_string.quote}" return cst.FormattedString( parts=parts, start=start, end=simple_string.quote ) return original_node
def match_logger_calls(node: cst.Call, loggers: Set[str]) -> bool: # Check if there is a log call to a logger return m.matches( node, m.Call( func=m.Attribute( value=m.Name(value=m.MatchIfTrue(lambda n: n in loggers)), attr=m.Name(value=m.MatchIfTrue(lambda n: n in LOG_FUNCTIONS)), ) ), )
def test_lambda_matcher_true(self) -> None: # Match based on identical attributes. self.assertTrue( matches( cst.Name("foo"), m.Name(value=m.MatchIfTrue(lambda value: "o" in value)) ) )
def test_lambda_matcher_false(self) -> None: # Fail to match due to incorrect value on Name. self.assertFalse( matches( cst.Name("foo"), m.Name(value=m.MatchIfTrue(lambda value: "a" in value)) ) )
def has_inline_comment(node: cst.BaseSuite): return m.matches( node, m.IndentedBlock( header=m.AllOf(m.TrailingWhitespace(), m.MatchIfTrue(lambda h: h.comment is not None))), )
def visit_AnnAssign(self, node: cst.AnnAssign) -> None: # The assignment value is optional, as it is possible to annotate an # expression without assigning to it: ``var: int`` if m.matches( node, m.AnnAssign( target=m.Name(), value=m.MatchIfTrue(lambda value: value is not None)), ): nodename = cst.ensure_type(node.target, cst.Name).value self._validate_nodename(node, nodename, NamingConvention.SNAKE_CASE)
def _has_testnode(node: cst.Module) -> bool: return m.matches( node, m.Module(body=[ # Sequence wildcard matchers matches LibCAST nodes in a row in a # sequence. It does not implicitly match on partial sequences. So, # when matching against a sequence we will need to provide a # complete pattern. This often means using helpers such as # ``ZeroOrMore()`` as the first and last element of the sequence. m.ZeroOrMore(), m.AtLeastN( n=1, matcher=m.OneOf( m.FunctionDef(name=m.Name(value=m.MatchIfTrue( lambda value: value.startswith("test_")))), m.ClassDef(name=m.Name(value=m.MatchIfTrue( lambda value: value.startswith("Test")))), ), ), m.ZeroOrMore(), ]), )
def visit_simple_stmt(self, node: SimpleStatementLine) -> None: assign = None for c in node.children: if m.matches(c, m.Assign()): assign = ensure_type(c, Assign) if assign: if m.MatchIfTrue( lambda n: n.trailing_whitespace.comment and "type:" in n.trailing_whitespace.comment.value ): class TypingVisitor(m.MatcherDecoratableVisitor): def __init__(self): super().__init__() self.vtype = None def visit_TrailingWhitespace_comment( self, node: "TrailingWhitespace" ) -> None: if node.comment: mo = re.match(r"#\s*type:\s*(\S*)", node.comment.value) if mo: vtype = mo.group(1) return None tv = TypingVisitor() node.visit(tv) vtype = tv.vtype else: vtype = None class NameVisitor(m.MatcherDecoratableVisitor): def __init__(self): super().__init__() self.names: List[str] = [] def visit_Name(self, node: Name) -> Optional[bool]: self.names.append(node.value) return None if self.verbose: pos = self.get_metadata(PositionProvider, node).start for target in assign.targets: v = NameVisitor() target.visit(v) for name in v.names: print( f"{self.path}:{pos.line}:{pos.column}: variable {name}: {vtype or 'unknown type'}" )
class MenuAppendCommand(VisitorBasedCodemodCommand): DESCRIPTION: str = "Migrate to wx.MenuAppend() method and update keywords" args_map = {"help": "helpString", "text": "item"} args_matchers_map = { matchers.Arg(keyword=matchers.Name(value=value)): renamed for value, renamed in args_map.items() } call_matcher = matchers.Call( func=matchers.Attribute(attr=matchers.Name(value="Append")), args=matchers.MatchIfTrue(lambda args: bool( set(arg.keyword.value for arg in args if arg and arg.keyword). intersection(MenuAppendCommand.args_map.keys()))), ) deprecated_call_matcher = matchers.Call( func=matchers.Attribute(attr=matchers.Name(value="AppendItem")), args=[matchers.DoNotCare()], ) def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: # Migrate form deprecated method AppendItem() if matchers.matches(updated_node, self.deprecated_call_matcher): updated_node = updated_node.with_changes( func=updated_node.func.with_changes(attr=cst.Name( value="Append"))) # Update keywords if matchers.matches(updated_node, self.call_matcher): updated_node_args = list(updated_node.args) for arg_matcher, renamed in self.args_matchers_map.items(): for i, node_arg in enumerate(updated_node.args): if matchers.matches(node_arg, arg_matcher): updated_node_args[i] = node_arg.with_changes( keyword=cst.Name(value=renamed)) updated_node = updated_node.with_changes( args=updated_node_args) return updated_node
class RemoveParenthesesFromReturn(NoqaAwareTransformer): @m.leave( m.Return(value=m.Tuple(lpar=m.MatchIfTrue(lambda v: v is not None)))) def remove_parentheses_from_return(self, original_node: cst.Return, updated_node: cst.Return) -> cst.Return: # We get position of the `original_node`, since `updated_node` is # by definition different and was not processed by metadata provider. position: cst.metadata.CodeRange = self.get_metadata( PositionProvider, original_node) # Removing parentheses which are used to enable multi-line expression # will lead to invalid code, so we do nothing. if position.start.line != position.end.line: return original_node return updated_node.with_deep_changes(cst.ensure_type( updated_node.value, cst.Tuple), lpar=[], rpar=[])
def visit_Lambda(self, node: cst.Lambda) -> None: if m.matches( node, m.Lambda( params=m.MatchIfTrue(self._is_simple_parameter_spec), body=m.Call(args=[ m.Arg(value=m.Name(value=param.name.value), star="", keyword=None) for param in node.params.params ]), ), ): call = cst.ensure_type(node.body, cst.Call) full_name = get_full_name_for_node(call) if full_name is None: full_name = "function" self.report( node, UNNECESSARY_LAMBDA.format(function=full_name), replacement=call.func, )
class ToolbarAddToolCommand(VisitorBasedCodemodCommand): DESCRIPTION: str = "Transforms wx.Toolbar.DoAddTool method into AddTool" args_map = {"id": "toolId"} args_matchers_map = { matchers.Arg(keyword=matchers.Name(value=value)): renamed for value, renamed in args_map.items() } call_matcher = matchers.Call( func=matchers.Attribute(attr=matchers.Name(value="DoAddTool")), args=matchers.MatchIfTrue(lambda args: bool( set(arg.keyword.value for arg in args if arg and arg.keyword). intersection(ToolbarAddToolCommand.args_map.keys()))), ) def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: if matchers.matches(updated_node, self.call_matcher): # Update method's call updated_node = updated_node.with_changes( func=updated_node.func.with_changes(attr=cst.Name( value="AddTool"))) # Transform keywords updated_node_args = list(updated_node.args) for arg_matcher, renamed in self.args_matchers_map.items(): for i, node_arg in enumerate(updated_node.args): if matchers.matches(node_arg, arg_matcher): updated_node_args[i] = node_arg.with_changes( keyword=cst.Name(value=renamed)) updated_node = updated_node.with_changes( args=updated_node_args) return updated_node
from typing import Iterator, List, cast import libcst from libcst import Assign, SimpleStatementLine from libcst import matchers as m from libcst.metadata import MetadataWrapper, PositionProvider from hooks.utils.pre_commit import get_input_files VALID_COMMENTS_FOR_NULL_TRUE = {'# null_by_design', '# null_for_compatibility'} Error = namedtuple('Error', 'line, col, field') null_comment = m.TrailingWhitespace( comment=m.Comment( m.MatchIfTrue(lambda n: n in VALID_COMMENTS_FOR_NULL_TRUE)), newline=m.Newline(), ) field_without_comment = m.SimpleStatementLine( body=[ m.Assign(value=(m.Call( args=[ m.ZeroOrMore(), m.Arg(keyword=m.Name('null'), value=m.Name('True')), m.ZeroOrMore(), ], whitespace_before_args=m.DoesNotMatch( m.ParenthesizedWhitespace(null_comment)), ) | m.Call(
from libcst import matchers as m from libcst.metadata import MetadataWrapper, PositionProvider from hooks.utils.pre_commit import get_input_files VALID_COMMENTS_FOR_NULL_TRUE = {'null_by_design', 'null_for_compatibility'} Error = namedtuple('Error', 'line, col, field') def is_valid_comment(comment_text: str) -> bool: return any(item in comment_text for item in VALID_COMMENTS_FOR_NULL_TRUE) null_comment = m.TrailingWhitespace( comment=m.Comment(m.MatchIfTrue(is_valid_comment)), newline=m.Newline(), ) field_without_comment = m.SimpleStatementLine( body=[ m.Assign(value=(m.Call( args=[ m.ZeroOrMore(), m.Arg(keyword=m.Name('null'), value=m.Name('True')), m.ZeroOrMore(), ], whitespace_before_args=m.DoesNotMatch( m.ParenthesizedWhitespace(null_comment)), ) | m.Call(
model_file_path: str line: int col: int field: str def __str__(self) -> str: return (f'{self.model_file_path}:{self.line}:{self.col} ' f'Field "{self.field}" needs a valid deprecation comment') def is_model_field_type(name: str) -> bool: return name.endswith(('Field', 'ForeignKey')) _any_comment = m.TrailingWhitespace(comment=m.Comment( m.MatchIfTrue(lambda n: n.startswith('#'))), newline=m.Newline()) _django_model_field_name_value = m.Call(func=m.Attribute( attr=m.Name(m.MatchIfTrue(is_model_field_type)))) | m.Call( func=m.Name(m.MatchIfTrue(is_model_field_type))) _django_model_field_name_with_leading_comment_value = m.Call( func=m.Attribute(attr=m.Name(m.MatchIfTrue(is_model_field_type))), whitespace_before_args=m.ParenthesizedWhitespace(_any_comment), ) | m.Call( func=m.Name(m.MatchIfTrue(is_model_field_type)), whitespace_before_args=m.ParenthesizedWhitespace(_any_comment), ) _django_model_field_with_leading_comment = m.SimpleStatementLine(body=[
"""Return a combined matcher for multiple similar types. *args are the matcher types, and **kwargs are arguments that will be passed to each type. Returns m.OneOf(...) the results. """ return m.OneOf(*(a(**kwargs) for a in args)) def remove_trailing_comma(node): # Remove the comma from this node, *unless* it's already a comma node with comments if node.comma is cst.MaybeSentinel.DEFAULT or m.findall(node, m.Comment()): return node return node.with_changes(comma=cst.MaybeSentinel.DEFAULT) MATCH_NONE = m.MatchIfTrue(lambda x: x is None) ALL_ELEMS_SLICE = m.Slice( lower=MATCH_NONE | m.Name("None"), upper=MATCH_NONE | m.Name("None"), step=MATCH_NONE | m.Name("None") | m.Integer("1") | m.UnaryOperation(m.Minus(), m.Integer("1")), ) class ShedFixers(VisitorBasedCodemodCommand): """Fix a variety of small problems. Replaces `raise NotImplemented` with `raise NotImplementedError`, and converts always-failing assert statements to explicit `raise` statements.
def visit_BinaryOperation(self, node: cst.BinaryOperation) -> None: expr_key = "expr" extracts = m.extract( node, m.BinaryOperation( left=m.MatchIfTrue(_match_simple_string), operator=m.Modulo(), right=m.SaveMatchedNode( m.MatchIfTrue( _gen_match_simple_expression( self.context.wrapper.module)), expr_key, ), ), ) if extracts: expr = extracts[expr_key] parts = [] simple_string = cst.ensure_type(node.left, cst.SimpleString) innards = simple_string.raw_value.replace("{", "{{").replace("}", "}}") tokens = innards.split("%s") token = tokens[0] if len(token) > 0: parts.append(cst.FormattedStringText(value=token)) expressions = ([elm.value for elm in expr.elements] if isinstance( expr, cst.Tuple) else [expr]) escape_transformer = EscapeStringQuote(simple_string.quote) i = 1 while i < len(tokens): if i - 1 >= len(expressions): # Only generate warning for cases where %-string not comes with same number of elements in tuple self.report(node) return try: parts.append( cst.FormattedStringExpression(expression=cast( cst.BaseExpression, expressions[i - 1].visit(escape_transformer), ))) except Exception: self.report(node) return token = tokens[i] if len(token) > 0: parts.append(cst.FormattedStringText(value=token)) i += 1 start = f"f{simple_string.prefix}{simple_string.quote}" replacement = cst.FormattedString(parts=parts, start=start, end=simple_string.quote) self.report(node, replacement=replacement) elif m.matches( node, m.BinaryOperation( left=m.SimpleString(), operator=m.Modulo())) and isinstance( cst.ensure_type( node.left, cst.SimpleString).evaluated_value, str): self.report(node)
class ShedFixers(VisitorBasedCodemodCommand): """Fix a variety of small problems. Replaces `raise NotImplemented` with `raise NotImplementedError`, and converts always-failing assert statements to explicit `raise` statements. Also includes code closely modelled on pybetter's fixers, because it's considerably faster to run all transforms in a single pass if possible. """ DESCRIPTION = "Fix a variety of style, performance, and correctness issues." @m.call_if_inside(m.Raise(exc=m.Name(value="NotImplemented"))) def leave_Name(self, _, updated_node): # noqa return updated_node.with_changes(value="NotImplementedError") def leave_Assert(self, _, updated_node): # noqa test_code = cst.Module("").code_for_node(updated_node.test) try: test_literal = literal_eval(test_code) except Exception: return updated_node if test_literal: return cst.RemovalSentinel.REMOVE if updated_node.msg is None: return cst.Raise(cst.Name("AssertionError")) return cst.Raise( cst.Call(cst.Name("AssertionError"), args=[cst.Arg(updated_node.msg)])) @m.leave( m.ComparisonTarget(comparator=oneof_names("None", "False", "True"), operator=m.Equal())) def convert_none_cmp(self, _, updated_node): """Inspired by Pybetter.""" return updated_node.with_changes(operator=cst.Is()) @m.leave( m.UnaryOperation( operator=m.Not(), expression=m.Comparison( comparisons=[m.ComparisonTarget(operator=m.In())]), )) def replace_not_in_condition(self, _, updated_node): """Also inspired by Pybetter.""" expr = cst.ensure_type(updated_node.expression, cst.Comparison) return cst.Comparison( left=expr.left, lpar=updated_node.lpar, rpar=updated_node.rpar, comparisons=[ expr.comparisons[0].with_changes(operator=cst.NotIn()) ], ) @m.leave( m.Call( lpar=[m.AtLeastN(n=1, matcher=m.LeftParen())], rpar=[m.AtLeastN(n=1, matcher=m.RightParen())], )) def remove_pointless_parens_around_call(self, _, updated_node): # This is *probably* valid, but we might have e.g. a multi-line parenthesised # chain of attribute accesses ("fluent interface"), where we need the parens. noparens = updated_node.with_changes(lpar=[], rpar=[]) try: compile(self.module.code_for_node(noparens), "<string>", "eval") return noparens except SyntaxError: return updated_node # The following methods fix https://pypi.org/project/flake8-comprehensions/ @m.leave(m.Call(func=m.Name("list"), args=[m.Arg(m.GeneratorExp())])) def replace_generator_in_call_with_comprehension(self, _, updated_node): """Fix flake8-comprehensions C400-402 and 403-404. C400-402: Unnecessary generator - rewrite as a <list/set/dict> comprehension. Note that set and dict conversions are handled by pyupgrade! """ return cst.ListComp(elt=updated_node.args[0].value.elt, for_in=updated_node.args[0].value.for_in) @m.leave( m.Call(func=m.Name("list"), args=[m.Arg(m.ListComp(), star="")]) | m.Call(func=m.Name("set"), args=[m.Arg(m.SetComp(), star="")]) | m.Call( func=m.Name("list"), args=[m.Arg(m.Call(func=oneof_names("sorted", "list")), star="")], )) def replace_unnecessary_list_around_sorted(self, _, updated_node): """Fix flake8-comprehensions C411 and C413. Unnecessary <list/reversed> call around sorted(). Also covers C411 Unnecessary list call around list comprehension for lists and sets. """ return updated_node.args[0].value @m.leave( m.Call( func=m.Name("reversed"), args=[m.Arg(m.Call(func=m.Name("sorted")), star="")], )) def replace_unnecessary_reversed_around_sorted(self, _, updated_node): """Fix flake8-comprehensions C413. Unnecessary reversed call around sorted(). """ call = updated_node.args[0].value args = list(call.args) for i, arg in enumerate(args): if m.matches(arg.keyword, m.Name("reverse")): try: val = bool( literal_eval(self.module.code_for_node(arg.value))) except Exception: args[i] = arg.with_changes( value=cst.UnaryOperation(cst.Not(), arg.value)) else: if not val: args[i] = arg.with_changes(value=cst.Name("True")) else: del args[i] args[i - 1] = remove_trailing_comma(args[i - 1]) break else: args.append( cst.Arg(keyword=cst.Name("reverse"), value=cst.Name("True"))) return call.with_changes(args=args) _sets = oneof_names("set", "frozenset") _seqs = oneof_names("list", "reversed", "sorted", "tuple") @m.leave( m.Call(func=_sets, args=[m.Arg(m.Call(func=_sets | _seqs), star="")]) | m.Call( func=oneof_names("list", "tuple"), args=[m.Arg(m.Call(func=oneof_names("list", "tuple")), star="")], ) | m.Call( func=m.Name("sorted"), args=[m.Arg(m.Call(func=_seqs), star=""), m.ZeroOrMore()], )) def replace_unnecessary_nested_calls(self, _, updated_node): """Fix flake8-comprehensions C414. Unnecessary <list/reversed/sorted/tuple> call within <list/set/sorted/tuple>().. """ return updated_node.with_changes( args=[cst.Arg(updated_node.args[0].value.args[0].value)] + list(updated_node.args[1:]), ) @m.leave( m.Call( func=oneof_names("reversed", "set", "sorted"), args=[ m.Arg(m.Subscript(slice=[m.SubscriptElement(ALL_ELEMS_SLICE)])) ], )) def replace_unnecessary_subscript_reversal(self, _, updated_node): """Fix flake8-comprehensions C415. Unnecessary subscript reversal of iterable within <reversed/set/sorted>(). """ return updated_node.with_changes( args=[cst.Arg(updated_node.args[0].value.value)], ) @m.leave( multi( m.ListComp, m.SetComp, elt=m.Name(), for_in=m.CompFor(target=m.Name(), ifs=[], inner_for_in=None, asynchronous=None), )) def replace_unnecessary_listcomp_or_setcomp(self, _, updated_node): """Fix flake8-comprehensions C416. Unnecessary <list/set> comprehension - rewrite using <list/set>(). """ if updated_node.elt.value == updated_node.for_in.target.value: func = cst.Name( "list" if isinstance(updated_node, cst.ListComp) else "set") return cst.Call(func=func, args=[cst.Arg(updated_node.for_in.iter)]) return updated_node @m.leave(m.Subscript(oneof_names("Union", "Literal"))) def reorder_union_literal_contents_none_last(self, _, updated_node): subscript = list(updated_node.slice) try: subscript.sort(key=lambda elt: elt.slice.value.value == "None") subscript[-1] = remove_trailing_comma(subscript[-1]) return updated_node.with_changes(slice=subscript) except Exception: # Single-element literals are not slices, etc. return updated_node @m.call_if_inside(m.Annotation(annotation=m.BinaryOperation())) @m.leave( m.BinaryOperation( left=m.Name("None") | m.BinaryOperation(), operator=m.BitOr(), right=m.DoNotCare(), )) def reorder_union_operator_contents_none_last(self, _, updated_node): def _has_none(node): if m.matches(node, m.Name("None")): return True elif m.matches(node, m.BinaryOperation()): return _has_none(node.left) or _has_none(node.right) else: return False node_left = updated_node.left if _has_none(node_left): return updated_node.with_changes(left=updated_node.right, right=node_left) else: return updated_node @m.leave(m.Subscript(value=m.Name("Literal"))) def flatten_literal_subscript(self, _, updated_node): new_slice = [] for item in updated_node.slice: if m.matches(item.slice.value, m.Subscript(m.Name("Literal"))): new_slice += item.slice.value.slice else: new_slice.append(item) return updated_node.with_changes(slice=new_slice) @m.leave(m.Subscript(value=m.Name("Union"))) def flatten_union_subscript(self, _, updated_node): new_slice = [] has_none = False for item in updated_node.slice: if m.matches(item.slice.value, m.Subscript(m.Name("Optional"))): new_slice += item.slice.value.slice # peel off "Optional" has_none = True elif m.matches(item.slice.value, m.Subscript(m.Name("Union"))) and m.matches( updated_node.value, item.slice.value.value): new_slice += item.slice.value.slice # peel off "Union" or "Literal" elif m.matches(item.slice.value, m.Name("None")): has_none = True else: new_slice.append(item) if has_none: new_slice.append( cst.SubscriptElement(slice=cst.Index(cst.Name("None")))) return updated_node.with_changes(slice=new_slice) @m.leave(m.Else(m.IndentedBlock([m.SimpleStatementLine([m.Pass()])]))) def discard_empty_else_blocks(self, _, updated_node): # An `else: pass` block can always simply be discarded, and libcst ensures # that an Else node can only ever occur attached to an If, While, For, or Try # node; in each case `None` is the valid way to represent "no else block". if m.findall(updated_node, m.Comment()): return updated_node # If there are any comments, keep the node return cst.RemoveFromParent() @m.leave( m.Lambda(params=m.MatchIfTrue(lambda node: ( node.star_kwarg is None and not node.kwonly_params and not node. posonly_params and isinstance(node.star_arg, cst.MaybeSentinel) and all(param.default is None for param in node.params))))) def remove_lambda_indirection(self, _, updated_node): same_args = [ m.Arg(m.Name(param.name.value), star="", keyword=None) for param in updated_node.params.params ] if m.matches(updated_node.body, m.Call(args=same_args)): return cst.ensure_type(updated_node.body, cst.Call).func return updated_node @m.leave( m.BooleanOperation( left=m.Call(m.Name("isinstance"), [m.Arg(), m.Arg()]), operator=m.Or(), right=m.Call(m.Name("isinstance"), [m.Arg(), m.Arg()]), )) def collapse_isinstance_checks(self, _, updated_node): left_target, left_type = updated_node.left.args right_target, right_type = updated_node.right.args if left_target.deep_equals(right_target): merged_type = cst.Arg( cst.Tuple([ cst.Element(left_type.value), cst.Element(right_type.value) ])) return updated_node.left.with_changes( args=[left_target, merged_type]) return updated_node