From e54f447082a657f1117a875a41cc4b7869c25641 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Sat, 5 Sep 2020 00:57:23 +0300 Subject: [PATCH 1/8] Implement ChainedInstanceRule --- fixit/rules/chained_instance_check.py | 101 ++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 fixit/rules/chained_instance_check.py diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py new file mode 100644 index 00000000..c9cd47b0 --- /dev/null +++ b/fixit/rules/chained_instance_check.py @@ -0,0 +1,101 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from typing import List, Set + +import libcst as cst +import libcst.matchers as m + +from fixit import CstLintRule, InvalidTestCase as Invalid, ValidTestCase as Valid + + +class ChainedInstanceRule(CstLintRule): + """ + The built-in ``isinstance`` function instead of a single type, + can take a tuple of types and check whether given target suits + any of them. Instead of chaining multiple ``isinstance`` calls + with a boolean-or operation, they can be simplified into a single + ``isinstance`` call. + """ + + MESSAGE: str = ( + "Multiple isinstance calls with the same target but " + + "different types can be collapsed into a single call " + + " with a tuple of types" + ) + + VALID = [ + Valid("foo(x, y) or foo(x, z)"), + Valid("foo(x, y) or foo(x, z) or foo(x, q)"), + Valid("isinstance(x) or isinstance(x)"), + Valid("isinstance(x, y) or isinstance(x)"), + Valid("isinstance(x) or isinstance(x, y)"), + Valid("isinstance(x, y) or isinstance(t, y)"), + Valid("isinstance(x, y) and isinstance(x, z)"), + Valid("isinstance(x, y) or isinstance(x, (z, q))"), + Valid("isinstance(x, (y, z)) or isinstance(x, q)"), + ] + INVALID = [ + Invalid( + "isinstance(x, y) or isinstance(x, z)", + expected_replacement="isinstance(x, (y, z))", + ), + Invalid( + "isinstance(x, y) or isinstance(x, z) or isinstance(x, q)", + expected_replacement="isinstance(x, (y, z, q))", + ), + Invalid( + "isinstance(x, y) or isinstance(x, z) or isinstance(x, q) or isinstance(x, w)", + expected_replacement="isinstance(x, (y, z, q, w))", + ), + ] + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + # Since we already unwrap a boolean op's + # children + self.seen_boolean_operations: Set[cst.BooleanOperation] = set() + + def visit_BooleanOperation(self, node: cst.BooleanOperation) -> None: + # Initially match with a partial pattern (in order to ensure + # we have enough args to construct more accurate / advanced + # pattern). + if node not in self.seen_boolean_operations and m.matches( + node, + m.BooleanOperation(right=m.Call(args=[m.AtLeastN(n=1)]), operator=m.Or()), + ): + args = self._collect_isinstance_args(node) + if args is not None: + elements = [cst.Element(arg.value) for arg in args] + new_isinstance_call = node.right.with_deep_changes( + old_node=node.right.args[1], value=cst.Tuple(elements) + ) + self.report(node, replacement=new_isinstance_call) + return False + + def _collect_isinstance_args(self, node: cst.BooleanOperation) -> List[cst.Arg]: + target = cst.ensure_type(node.right, cst.Call).args[0].value + expected_call = m.Call( + func=m.Name(value="isinstance"), + args=[m.Arg(value=target), m.Arg(value=~m.Tuple())], + ) + expected_boolop = m.BooleanOperation(operator=m.Or(), right=expected_call) + + seen = [] + stack = [] + current = node + while m.matches(current, expected_boolop): + seen.append(current) + stack.insert(0, current.right) + current = current.left + + if m.matches(current, expected_call): + stack.insert(0, current) + self.seen_boolean_operations.update(seen) + else: + return None + + return [cst.ensure_type(node, cst.Call).args[1] for node in stack] From bfd577aafc41541e20439410c59da44012fc03f6 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Sat, 5 Sep 2020 01:20:22 +0300 Subject: [PATCH 2/8] Satisfy the type checker --- fixit/rules/chained_instance_check.py | 31 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index c9cd47b0..73d6538b 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -3,15 +3,20 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -from typing import List, Set +from typing import List, Optional, Set import libcst as cst import libcst.matchers as m -from fixit import CstLintRule, InvalidTestCase as Invalid, ValidTestCase as Valid +from fixit import ( + CstContext, + CstLintRule, + InvalidTestCase as Invalid, + ValidTestCase as Valid, +) -class ChainedInstanceRule(CstLintRule): +class ChainedInstanceCheckRule(CstLintRule): """ The built-in ``isinstance`` function instead of a single type, can take a tuple of types and check whether given target suits @@ -52,8 +57,8 @@ class ChainedInstanceRule(CstLintRule): ), ] - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self, context: CstContext) -> None: + super().__init__(context) # Since we already unwrap a boolean op's # children @@ -69,18 +74,23 @@ def visit_BooleanOperation(self, node: cst.BooleanOperation) -> None: ): args = self._collect_isinstance_args(node) if args is not None: + right = cst.ensure_type(node.right, cst.Call) elements = [cst.Element(arg.value) for arg in args] - new_isinstance_call = node.right.with_deep_changes( - old_node=node.right.args[1], value=cst.Tuple(elements) + new_isinstance_call = right.with_deep_changes( + old_node=right.args[1], value=cst.Tuple(elements) ) self.report(node, replacement=new_isinstance_call) - return False - def _collect_isinstance_args(self, node: cst.BooleanOperation) -> List[cst.Arg]: + def _collect_isinstance_args( + self, node: cst.BooleanOperation + ) -> Optional[List[cst.Arg]]: target = cst.ensure_type(node.right, cst.Call).args[0].value expected_call = m.Call( func=m.Name(value="isinstance"), - args=[m.Arg(value=target), m.Arg(value=~m.Tuple())], + args=[ + m.Arg(value=m.MatchIfTrue(target.deep_equals)), + m.Arg(value=~m.Tuple()), + ], ) expected_boolop = m.BooleanOperation(operator=m.Or(), right=expected_call) @@ -88,6 +98,7 @@ def _collect_isinstance_args(self, node: cst.BooleanOperation) -> List[cst.Arg]: stack = [] current = node while m.matches(current, expected_boolop): + current = cst.ensure_type(current, cst.BooleanOperation) seen.append(current) stack.insert(0, current.right) current = current.left From 685412486cac871d56c903c7b76f4d41732f1897 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Sat, 5 Sep 2020 01:39:25 +0300 Subject: [PATCH 3/8] Improve docstring --- fixit/rules/chained_instance_check.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index 73d6538b..033dd8ad 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -18,17 +18,17 @@ class ChainedInstanceCheckRule(CstLintRule): """ - The built-in ``isinstance`` function instead of a single type, + The built-in ``isinstance`` function, instead of a single type, can take a tuple of types and check whether given target suits - any of them. Instead of chaining multiple ``isinstance`` calls - with a boolean-or operation, they can be simplified into a single - ``isinstance`` call. + any of them. Rather than chaining multiple ``isinstance`` calls + with a boolean-or operation, a single ``isinstance`` call where + the second argument is a tuple of all types can be used. """ MESSAGE: str = ( "Multiple isinstance calls with the same target but " + "different types can be collapsed into a single call " - + " with a tuple of types" + + "with a tuple of types." ) VALID = [ From e33c9ac01ccdf8da6076354ae1edbdf857004b96 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Sat, 5 Sep 2020 11:17:12 +0300 Subject: [PATCH 4/8] Add new test cases --- fixit/rules/chained_instance_check.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index 033dd8ad..15d0157f 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -32,8 +32,10 @@ class ChainedInstanceCheckRule(CstLintRule): ) VALID = [ + Valid("foo() or foo()"), Valid("foo(x, y) or foo(x, z)"), Valid("foo(x, y) or foo(x, z) or foo(x, q)"), + Valid("isinstance() or isinstance()"), Valid("isinstance(x) or isinstance(x)"), Valid("isinstance(x, y) or isinstance(x)"), Valid("isinstance(x) or isinstance(x, y)"), From b320afd6b900666874ed2357a140082b714b1b02 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Sun, 6 Sep 2020 13:05:55 +0300 Subject: [PATCH 5/8] Update fixit/rules/chained_instance_check.py Co-authored-by: Jimmy Lai --- fixit/rules/chained_instance_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index 15d0157f..50356aef 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -16,7 +16,7 @@ ) -class ChainedInstanceCheckRule(CstLintRule): +class CollapseIsinstanceChecksRule(CstLintRule): """ The built-in ``isinstance`` function, instead of a single type, can take a tuple of types and check whether given target suits From 568811bfcc27fb076e28008577fa0ed785a87913 Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Sun, 6 Sep 2020 17:08:05 +0300 Subject: [PATCH 6/8] Allow merge of multiple targets --- fixit/rules/chained_instance_check.py | 110 +++++++++++++++++++++----- 1 file changed, 92 insertions(+), 18 deletions(-) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index 50356aef..dacdd2c9 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -3,10 +3,11 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -from typing import List, Optional, Set +from typing import List, Optional, Set, Union import libcst as cst import libcst.matchers as m +from libcst.metadata import QualifiedName, QualifiedNameProvider, QualifiedNameSource from fixit import ( CstContext, @@ -16,6 +17,11 @@ ) +_ISINSTANCE = QualifiedName( + name="builtins.isinstance", source=QualifiedNameSource.BUILTIN +) + + class CollapseIsinstanceChecksRule(CstLintRule): """ The built-in ``isinstance`` function, instead of a single type, @@ -31,6 +37,8 @@ class CollapseIsinstanceChecksRule(CstLintRule): + "with a tuple of types." ) + METADATA_DEPENDENCIES = (QualifiedNameProvider,) + VALID = [ Valid("foo() or foo()"), Valid("foo(x, y) or foo(x, z)"), @@ -43,6 +51,15 @@ class CollapseIsinstanceChecksRule(CstLintRule): Valid("isinstance(x, y) and isinstance(x, z)"), Valid("isinstance(x, y) or isinstance(x, (z, q))"), Valid("isinstance(x, (y, z)) or isinstance(x, q)"), + Valid("isinstance(x, a) or isinstance(y, b) or isinstance(z, c)"), + Valid( + """ + def isinstance(x, y): + return None + if isinstance(x, y) or isinstance(x, z): + pass + """ + ), ] INVALID = [ Invalid( @@ -57,6 +74,23 @@ class CollapseIsinstanceChecksRule(CstLintRule): "isinstance(x, y) or isinstance(x, z) or isinstance(x, q) or isinstance(x, w)", expected_replacement="isinstance(x, (y, z, q, w))", ), + Invalid( + "isinstance(x, a) or isinstance(x, b) or isinstance(y, c) or isinstance(y, d)", + expected_replacement="isinstance(x, (a, b)) or isinstance(y, (c, d))", + ), + Invalid( + "isinstance(x, a) or isinstance(x, b) or isinstance(y, c) or isinstance(y, d) " + + " or isinstance(z, e)", + expected_replacement="isinstance(x, (a, b)) or isinstance(y, (c, d)) or isinstance(z, e)", + ), + Invalid( + "isinstance(x, a) or isinstance(x, b) or isinstance(y, c) or isinstance(y, d) " + + " or isinstance(z, e) or isinstance(q, f) or isinstance(q, g) or isinstance(q, h)", + expected_replacement=( + "isinstance(x, (a, b)) or isinstance(y, (c, d)) or isinstance(z, e)" + + " or isinstance(q, (f, g, h))" + ), + ), ] def __init__(self, context: CstContext) -> None: @@ -70,45 +104,85 @@ def visit_BooleanOperation(self, node: cst.BooleanOperation) -> None: # Initially match with a partial pattern (in order to ensure # we have enough args to construct more accurate / advanced # pattern). + if node not in self.seen_boolean_operations and m.matches( node, m.BooleanOperation(right=m.Call(args=[m.AtLeastN(n=1)]), operator=m.Or()), ): - args = self._collect_isinstance_args(node) - if args is not None: - right = cst.ensure_type(node.right, cst.Call) - elements = [cst.Element(arg.value) for arg in args] - new_isinstance_call = right.with_deep_changes( - old_node=right.args[1], value=cst.Tuple(elements) - ) - self.report(node, replacement=new_isinstance_call) + calls = self._collect_isinstance_calls(node) + if calls is None: + return None + + replacement = self._merge_isinstance_calls(calls) + if replacement is not None: + self.report(node, replacement=replacement) - def _collect_isinstance_args( + def _collect_isinstance_calls( self, node: cst.BooleanOperation - ) -> Optional[List[cst.Arg]]: - target = cst.ensure_type(node.right, cst.Call).args[0].value + ) -> Optional[List[cst.Call]]: expected_call = m.Call( func=m.Name(value="isinstance"), args=[ - m.Arg(value=m.MatchIfTrue(target.deep_equals)), + m.Arg(), m.Arg(value=~m.Tuple()), ], ) expected_boolop = m.BooleanOperation(operator=m.Or(), right=expected_call) seen = [] - stack = [] + stack: List[cst.Call] = [] current = node while m.matches(current, expected_boolop): current = cst.ensure_type(current, cst.BooleanOperation) seen.append(current) - stack.insert(0, current.right) + stack.insert(0, cst.ensure_type(current.right, cst.Call)) current = current.left if m.matches(current, expected_call): - stack.insert(0, current) - self.seen_boolean_operations.update(seen) + stack.insert(0, cst.ensure_type(current, cst.Call)) else: return None - return [cst.ensure_type(node, cst.Call).args[1] for node in stack] + self.seen_boolean_operations.update(seen) + return stack + + def _merge_isinstance_calls( + self, stack: List[cst.Call] + ) -> Optional[Union[cst.Call, cst.BooleanOperation]]: + + targets = {} + for call in stack: + func = cst.ensure_type(call, cst.Call).func + for context in self.get_metadata(QualifiedNameProvider, func): + if ( + context.name != "builtins.isinstance" + or context.source is not QualifiedNameSource.BUILTIN + ): + return None + + target, match = call.args[0].value, call.args[1].value + for possible_target in targets: + if target.deep_equals(possible_target): + targets[possible_target].append(match) + break + else: + targets[target] = [match] + + if all(len(matches) == 1 for matches in targets.values()): + return None + + replacement = None + for target, matches in targets.items(): + if len(matches) == 1: + arg = cst.Arg(*matches) + else: + arg = cst.Arg(cst.Tuple([cst.Element(match) for match in matches])) + call = cst.Call(cst.Name("isinstance"), [cst.Arg(target), arg]) + if replacement is None: + replacement = call + elif isinstance(replacement, (cst.Call, cst.BooleanOperation)): + replacement = cst.BooleanOperation( + left=replacement, right=call, operator=cst.Or() + ) + + return replacement From beb86eabb0bdbb0b8f00419cb32b5e76768c018c Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Mon, 7 Sep 2020 22:59:31 +0300 Subject: [PATCH 7/8] Re-structre the algorithm --- fixit/rules/chained_instance_check.py | 155 ++++++++++++-------------- 1 file changed, 70 insertions(+), 85 deletions(-) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index dacdd2c9..48281db4 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -3,7 +3,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -from typing import List, Optional, Set, Union +from typing import Dict, Iterator, List, Set, Tuple import libcst as cst import libcst.matchers as m @@ -54,10 +54,11 @@ class CollapseIsinstanceChecksRule(CstLintRule): Valid("isinstance(x, a) or isinstance(y, b) or isinstance(z, c)"), Valid( """ - def isinstance(x, y): - return None - if isinstance(x, y) or isinstance(x, z): - pass + def foo(): + def isinstance(x, y): + return _foo_bar(x, y) + if isinstance(x, y) or isinstance(x, z): + print("foo") """ ), ] @@ -70,6 +71,10 @@ def isinstance(x, y): "isinstance(x, y) or isinstance(x, z) or isinstance(x, q)", expected_replacement="isinstance(x, (y, z, q))", ), + Invalid( + "something or isinstance(x, y) or isinstance(x, z) or another", + expected_replacement="something or isinstance(x, (y, z)) or another" + ), Invalid( "isinstance(x, y) or isinstance(x, z) or isinstance(x, q) or isinstance(x, w)", expected_replacement="isinstance(x, (y, z, q, w))", @@ -80,12 +85,12 @@ def isinstance(x, y): ), Invalid( "isinstance(x, a) or isinstance(x, b) or isinstance(y, c) or isinstance(y, d) " - + " or isinstance(z, e)", + + "or isinstance(z, e)", expected_replacement="isinstance(x, (a, b)) or isinstance(y, (c, d)) or isinstance(z, e)", ), Invalid( "isinstance(x, a) or isinstance(x, b) or isinstance(y, c) or isinstance(y, d) " - + " or isinstance(z, e) or isinstance(q, f) or isinstance(q, g) or isinstance(q, h)", + + "or isinstance(z, e) or isinstance(q, f) or isinstance(q, g) or isinstance(q, h)", expected_replacement=( "isinstance(x, (a, b)) or isinstance(y, (c, d)) or isinstance(z, e)" + " or isinstance(q, (f, g, h))" @@ -95,94 +100,74 @@ def isinstance(x, y): def __init__(self, context: CstContext) -> None: super().__init__(context) - - # Since we already unwrap a boolean op's - # children self.seen_boolean_operations: Set[cst.BooleanOperation] = set() def visit_BooleanOperation(self, node: cst.BooleanOperation) -> None: - # Initially match with a partial pattern (in order to ensure - # we have enough args to construct more accurate / advanced - # pattern). - - if node not in self.seen_boolean_operations and m.matches( - node, - m.BooleanOperation(right=m.Call(args=[m.AtLeastN(n=1)]), operator=m.Or()), - ): - calls = self._collect_isinstance_calls(node) - if calls is None: - return None - - replacement = self._merge_isinstance_calls(calls) - if replacement is not None: - self.report(node, replacement=replacement) - - def _collect_isinstance_calls( - self, node: cst.BooleanOperation - ) -> Optional[List[cst.Call]]: - expected_call = m.Call( - func=m.Name(value="isinstance"), - args=[ - m.Arg(), - m.Arg(value=~m.Tuple()), - ], - ) - expected_boolop = m.BooleanOperation(operator=m.Or(), right=expected_call) - - seen = [] - stack: List[cst.Call] = [] - current = node - while m.matches(current, expected_boolop): - current = cst.ensure_type(current, cst.BooleanOperation) - seen.append(current) - stack.insert(0, cst.ensure_type(current.right, cst.Call)) - current = current.left - - if m.matches(current, expected_call): - stack.insert(0, cst.ensure_type(current, cst.Call)) - else: + if node in self.seen_boolean_operations: return None - self.seen_boolean_operations.update(seen) - return stack - - def _merge_isinstance_calls( - self, stack: List[cst.Call] - ) -> Optional[Union[cst.Call, cst.BooleanOperation]]: + stack = tuple(self.unwrap(node)) + operands, targets = self.collect_targets(stack) - targets = {} - for call in stack: - func = cst.ensure_type(call, cst.Call).func - for context in self.get_metadata(QualifiedNameProvider, func): - if ( - context.name != "builtins.isinstance" - or context.source is not QualifiedNameSource.BUILTIN - ): - return None - - target, match = call.args[0].value, call.args[1].value - for possible_target in targets: - if target.deep_equals(possible_target): - targets[possible_target].append(match) - break - else: - targets[target] = [match] - - if all(len(matches) == 1 for matches in targets.values()): + # If nothing gets collapsed, just exit from this short-path + if len(operands) == len(stack): return None replacement = None - for target, matches in targets.items(): - if len(matches) == 1: - arg = cst.Arg(*matches) - else: - arg = cst.Arg(cst.Tuple([cst.Element(match) for match in matches])) - call = cst.Call(cst.Name("isinstance"), [cst.Arg(target), arg]) + for operand in operands: + if operand in targets: + matches = targets[operand] + if len(matches) == 1: + arg = cst.Arg(value=matches[0]) + else: + arg = cst.Arg(cst.Tuple([cst.Element(match) for match in matches])) + operand = cst.Call(cst.Name("isinstance"), [cst.Arg(operand), arg]) + if replacement is None: - replacement = call - elif isinstance(replacement, (cst.Call, cst.BooleanOperation)): + replacement = operand + else: replacement = cst.BooleanOperation( - left=replacement, right=call, operator=cst.Or() + left=replacement, right=operand, operator=cst.Or() ) - return replacement + if replacement is not None: + self.report(node, replacement=replacement) + + def unwrap(self, node: cst.BaseExpression) -> Iterator[cst.BaseExpression]: + if m.matches(node, m.BooleanOperation(operator=m.Or())): + bool_op = cst.ensure_type(node, cst.BooleanOperation) + self.seen_boolean_operations.add(bool_op) + yield from self.unwrap(bool_op.left) + yield bool_op.right + else: + yield node + + def collect_targets( + self, stack: Tuple[cst.BaseExpression, ...] + ) -> Tuple[ + List[cst.BaseExpression], Dict[cst.BaseExpression, List[cst.BaseExpression]] + ]: + targets = {} + operands = [] + + for operand in stack: + if m.matches( + operand, m.Call(func=m.DoNotCare(), args=[m.Arg(), m.Arg(~m.Tuple())]) + ): + call = cst.ensure_type(operand, cst.Call) + if not QualifiedNameProvider.has_name(self, call, _ISINSTANCE): + operands.append(operand) + continue + + target, match = call.args[0].value, call.args[1].value + for possible_target in targets: + if target.deep_equals(possible_target): + targets[possible_target].append(match) + break + else: + operands.append(target) + targets[target] = [match] + else: + operands.append(operand) + + return operands, targets From 523d6d835b4d39e5a8d2c5ad3975656b1b14546c Mon Sep 17 00:00:00 2001 From: Batuhan Taskaya Date: Mon, 7 Sep 2020 23:02:41 +0300 Subject: [PATCH 8/8] Lint... --- fixit/rules/chained_instance_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixit/rules/chained_instance_check.py b/fixit/rules/chained_instance_check.py index 48281db4..fcd59259 100644 --- a/fixit/rules/chained_instance_check.py +++ b/fixit/rules/chained_instance_check.py @@ -73,7 +73,7 @@ def isinstance(x, y): ), Invalid( "something or isinstance(x, y) or isinstance(x, z) or another", - expected_replacement="something or isinstance(x, (y, z)) or another" + expected_replacement="something or isinstance(x, (y, z)) or another", ), Invalid( "isinstance(x, y) or isinstance(x, z) or isinstance(x, q) or isinstance(x, w)",