From 816d3212d15ba297aa3a234d7874974d9f39b4bd Mon Sep 17 00:00:00 2001 From: Dmitry Pribysh Date: Mon, 19 Oct 2015 17:44:10 +0300 Subject: Add checker for membership rules and tests Idea of checking is very simple: value used to the right of the 'in' operator should support membership test protocol (i.e. define either __contains__ or __iter__ or __getitem__). Fixes issue #589. --- ChangeLog | 4 + pylint/checkers/typecheck.py | 48 ++++++++++++ pylint/test/functional/membership_protocol.py | 85 ++++++++++++++++++++++ pylint/test/functional/membership_protocol.txt | 7 ++ pylint/test/functional/membership_protocol_py2.py | 36 +++++++++ pylint/test/functional/membership_protocol_py2.rc | 3 + pylint/test/functional/membership_protocol_py2.txt | 3 + pylint/test/functional/membership_protocol_py3.py | 36 +++++++++ pylint/test/functional/membership_protocol_py3.rc | 3 + pylint/test/functional/membership_protocol_py3.txt | 3 + 10 files changed, 228 insertions(+) create mode 100644 pylint/test/functional/membership_protocol.py create mode 100644 pylint/test/functional/membership_protocol.txt create mode 100644 pylint/test/functional/membership_protocol_py2.py create mode 100644 pylint/test/functional/membership_protocol_py2.rc create mode 100644 pylint/test/functional/membership_protocol_py2.txt create mode 100644 pylint/test/functional/membership_protocol_py3.py create mode 100644 pylint/test/functional/membership_protocol_py3.rc create mode 100644 pylint/test/functional/membership_protocol_py3.txt diff --git a/ChangeLog b/ChangeLog index b25533b..957ed56 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,10 @@ ChangeLog for Pylint -------------------- -- + * Add a new error, 'unsupported-membership-test', emitted when value + to the right of the 'in' operator doesn't support membership test + protocol (i.e. doesn't define __contains__/__iter__/__getitem__) + * Add new errors, 'not-an-iterable', emitted when non-iterable value is used in an iterating context (starargs, for-statement, comprehensions, etc), and 'not-a-mapping', emitted when non-mapping diff --git a/pylint/checkers/typecheck.py b/pylint/checkers/typecheck.py index 687dd79..e9fddbd 100644 --- a/pylint/checkers/typecheck.py +++ b/pylint/checkers/typecheck.py @@ -47,6 +47,7 @@ STR_FORMAT = "%s.str.format" % BUILTINS ITER_METHOD = '__iter__' NEXT_METHOD = 'next' if six.PY2 else '__next__' GETITEM_METHOD = '__getitem__' +CONTAINS_METHOD = '__contains__' KEYS_METHOD = 'keys' @@ -115,6 +116,9 @@ def _is_iterator(value): def _is_mapping(value): return _hasattr(value, GETITEM_METHOD) and _hasattr(value, KEYS_METHOD) +def _supports_membership_test(value): + return _hasattr(value, CONTAINS_METHOD) + def _is_inside_mixin_declaration(node): while node is not None: @@ -189,6 +193,10 @@ MSGS = { 'E1132': ('Got multiple values for keyword argument %r in function call', 'repeated-keyword', 'Emitted when a function call got multiple values for a keyword.'), + 'E1135': ("Value '%s' doesn't support membership test", + 'unsupported-membership-test', + 'Emitted when an instance in membership test expression doesn\'t' + 'implement membership protocol (__contains__/__iter__/__getitem__)'), } # builtin sequence types in Python 2 and 3. @@ -832,6 +840,46 @@ accessed. Python regular expressions are accepted.'} self.add_message('unsupported-binary-operation', args=str(error), node=node) + def _check_membership_test(self, node): + # instance supports membership test in either of those cases: + # 1. instance defines __contains__ method + # 2. instance is iterable (defines __iter__ or __getitem__) + if _is_comprehension(node) or _is_inside_mixin_declaration(node): + return + + infered = helpers.safe_infer(node) + if infered is None or infered is astroid.YES: + return + + # classes can be iterables/containers too + if isinstance(infered, astroid.ClassDef): + if not helpers.has_known_bases(infered): + return + meta = infered.metaclass() + if meta is not None: + if _supports_membership_test(meta): + return + if _is_iterable(meta): + return + + if isinstance(infered, astroid.Instance): + if not helpers.has_known_bases(infered): + return + if _supports_membership_test(infered) or _is_iterable(infered): + return + + self.add_message('unsupported-membership-test', + args=node.as_string(), + node=node) + + @check_messages('unsupported-membership-test') + def visit_compare(self, node): + if len(node.ops) != 1: + return + operator, right = node.ops[0] + if operator in ['in', 'not in']: + self._check_membership_test(right) + class IterableChecker(BaseChecker): """ diff --git a/pylint/test/functional/membership_protocol.py b/pylint/test/functional/membership_protocol.py new file mode 100644 index 0000000..7b3a46f --- /dev/null +++ b/pylint/test/functional/membership_protocol.py @@ -0,0 +1,85 @@ +# pylint: disable=missing-docstring,pointless-statement,expression-not-assigned,too-few-public-methods,import-error,no-init + +# standard types +1 in [1, 2, 3] +1 in {'a': 1, 'b': 2} +1 in {1, 2, 3} +1 in (1, 2, 3) +1 in "123" +1 in u"123" +1 in bytearray(b"123") +1 in frozenset([1, 2, 3]) + +# comprehensions +1 in [x ** 2 % 10 for x in range(10)] +1 in {x ** 2 % 10 for x in range(10)} +1 in {x: x ** 2 % 10 for x in range(10)} + +# iterators +1 in iter([1, 2, 3]) + +# generator +def count(upto=float("inf")): + i = 0 + while True: + if i > upto: + break + yield i + i += 1 + +10 in count(upto=10) + +# custom instance +class UniversalContainer(object): + def __contains__(self, key): + return True + +42 in UniversalContainer() + +# custom iterable +class CustomIterable(object): + def __iter__(self): + return iter((1, 2, 3)) +3 in CustomIterable() + +# old-style iterable +class OldStyleIterable(object): + def __getitem__(self, key): + if key < 10: + return 2 ** key + else: + raise IndexError("bad index") +64 in OldStyleIterable() + +# do not emit warning if class has unknown bases +from some_missing_module import ImportedClass + +class MaybeIterable(ImportedClass): + pass + +10 in MaybeIterable() + +# do not emit warning inside mixins +class UsefulMixin(object): + stuff = None + + def get_stuff(self): + return self.stuff + + def act(self, thing): + stuff = self.get_stuff() + if thing in stuff: + pass + +# error cases +42 in 42 # [unsupported-membership-test] +42 not in None # [unsupported-membership-test] +42 in 8.5 # [unsupported-membership-test] + +class EmptyClass(object): + pass + +42 not in EmptyClass() # [unsupported-membership-test] +42 in EmptyClass # [unsupported-membership-test] +42 not in count # [unsupported-membership-test] +42 in range # [unsupported-membership-test] diff --git a/pylint/test/functional/membership_protocol.txt b/pylint/test/functional/membership_protocol.txt new file mode 100644 index 0000000..6e9bd8e --- /dev/null +++ b/pylint/test/functional/membership_protocol.txt @@ -0,0 +1,7 @@ +unsupported-membership-test:75::Value '42' doesn't support membership test +unsupported-membership-test:76::Value 'None' doesn't support membership test +unsupported-membership-test:77::Value '8.5' doesn't support membership test +unsupported-membership-test:82::Value 'EmptyClass()' doesn't support membership test +unsupported-membership-test:83::Value 'EmptyClass' doesn't support membership test +unsupported-membership-test:84::Value 'count' doesn't support membership test +unsupported-membership-test:85::Value 'range' doesn't support membership test diff --git a/pylint/test/functional/membership_protocol_py2.py b/pylint/test/functional/membership_protocol_py2.py new file mode 100644 index 0000000..1a01637 --- /dev/null +++ b/pylint/test/functional/membership_protocol_py2.py @@ -0,0 +1,36 @@ +# pylint: disable=missing-docstring,too-few-public-methods,no-init,no-self-use,unused-argument,pointless-statement,expression-not-assigned,undefined-variable + +# metaclasses that support membership test protocol +class MetaIterable(type): + def __iter__(cls): + return iter((1, 2, 3)) + +class MetaOldIterable(type): + def __getitem__(cls, key): + if key < 10: + return key ** 2 + else: + raise IndexError("bad index") + +class MetaContainer(type): + def __contains__(cls, key): + return False + + +class IterableClass(object): + __metaclass__ = MetaIterable + +class OldIterableClass(object): + __metaclass__ = MetaOldIterable + +class ContainerClass(object): + __metaclass__ = MetaContainer + + +def test(): + 1 in IterableClass + 1 in OldIterableClass + 1 in ContainerClass + 1 in IterableClass() # [unsupported-membership-test] + 1 in OldIterableClass() # [unsupported-membership-test] + 1 in ContainerClass() # [unsupported-membership-test] diff --git a/pylint/test/functional/membership_protocol_py2.rc b/pylint/test/functional/membership_protocol_py2.rc new file mode 100644 index 0000000..c78f32f --- /dev/null +++ b/pylint/test/functional/membership_protocol_py2.rc @@ -0,0 +1,3 @@ +[testoptions] +max_pyver=3.0 + diff --git a/pylint/test/functional/membership_protocol_py2.txt b/pylint/test/functional/membership_protocol_py2.txt new file mode 100644 index 0000000..4ba7575 --- /dev/null +++ b/pylint/test/functional/membership_protocol_py2.txt @@ -0,0 +1,3 @@ +unsupported-membership-test:34:test:Value 'IterableClass()' doesn't support membership test +unsupported-membership-test:35:test:Value 'OldIterableClass()' doesn't support membership test +unsupported-membership-test:36:test:Value 'ContainerClass()' doesn't support membership test diff --git a/pylint/test/functional/membership_protocol_py3.py b/pylint/test/functional/membership_protocol_py3.py new file mode 100644 index 0000000..6a77f20 --- /dev/null +++ b/pylint/test/functional/membership_protocol_py3.py @@ -0,0 +1,36 @@ +# pylint: disable=missing-docstring,too-few-public-methods,no-init,no-self-use,unused-argument,pointless-statement,expression-not-assigned + +# metaclasses that support membership test protocol +class MetaIterable(type): + def __iter__(cls): + return iter((1, 2, 3)) + +class MetaOldIterable(type): + def __getitem__(cls, key): + if key < 10: + return key ** 2 + else: + raise IndexError("bad index") + +class MetaContainer(type): + def __contains__(cls, key): + return False + + +class IterableClass(metaclass=MetaOldIterable): + pass + +class OldIterableClass(metaclass=MetaOldIterable): + pass + +class ContainerClass(metaclass=MetaContainer): + pass + + +def test(): + 1 in IterableClass + 1 in OldIterableClass + 1 in ContainerClass + 1 in IterableClass() # [unsupported-membership-test] + 1 in OldIterableClass() # [unsupported-membership-test] + 1 in ContainerClass() # [unsupported-membership-test] diff --git a/pylint/test/functional/membership_protocol_py3.rc b/pylint/test/functional/membership_protocol_py3.rc new file mode 100644 index 0000000..9bf6df0 --- /dev/null +++ b/pylint/test/functional/membership_protocol_py3.rc @@ -0,0 +1,3 @@ +[testoptions] +min_pyver=3.0 + diff --git a/pylint/test/functional/membership_protocol_py3.txt b/pylint/test/functional/membership_protocol_py3.txt new file mode 100644 index 0000000..4ba7575 --- /dev/null +++ b/pylint/test/functional/membership_protocol_py3.txt @@ -0,0 +1,3 @@ +unsupported-membership-test:34:test:Value 'IterableClass()' doesn't support membership test +unsupported-membership-test:35:test:Value 'OldIterableClass()' doesn't support membership test +unsupported-membership-test:36:test:Value 'ContainerClass()' doesn't support membership test -- cgit v1.2.1