Skip to content

20 - AttributeError: Can't pickle local object 'augment_visit.<locals>.augment_func' #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Dec 28, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
[paths]
source =
pylint_plugin_utils

[run]
source=pylint_plugin_utils
[report]
include =
pylint_plugin_utils/*
omit =
*/test/*
exclude_lines =
# Re-enable default pragma
pragma: no cover

# Debug-only code
def __repr__

# Type checking code not executed during pytest runs
if TYPE_CHECKING:
@overload
6 changes: 6 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[flake8]
ignore =
E203, W503, # Incompatible with black see https://github.com/ambv/black/issues/315

max-line-length=88
max-complexity=39
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pip-log.txt
.coverage
.tox
nosetests.xml
.pytest_cache
.benchmarks
htmlcov

# Translations
*.mo
Expand All @@ -33,3 +36,5 @@ nosetests.xml
.mr.developer.cfg
.project
.pydevproject
.pylint-plugin-utils
.idea
24 changes: 24 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 21.12b0
hooks:
- id: black
args: [--safe, --quiet]
- repo: https://github.com/Pierre-Sassoulas/black-disable-checker/
rev: 1.0.1
hooks:
- id: black-disable-checker
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
hooks:
- id: flake8
additional_dependencies: [flake8-typing-imports==1.10.1]
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ python:
- "3.5"
- "3.6"
- "3.7"
- "3.8"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup.py lists supported versions as 3.6 to 3.10 (which is good!) so the CI config should match.

env:
- PYLINT=2.0.0
- PYLINT=2.1.1
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,41 @@

Utilities and helpers for writing Pylint plugins. This is not a direct Pylint plugin, but rather a set of tools and functions used by other plugins such as [pylint-django](https://github.com/PyCQA/pylint-django) and [pylint-celery](https://github.com/PyCQA/pylint-celery).

# Testing
Create virtualenv:
```bash
python3.8 -m venv .pylint-plugin-utils
source .pylint-plugin-utils/bin/activate
pip install --upgrade pip setuptools
```

We use [tox](https://tox.readthedocs.io/en/latest/) and [pytest-benchmark](https://pytest-benchmark.readthedocs.io/en/latest/index.html) for running the test suite. You should be able to install it with:
```bash
pip install tox pytest pytest-benchmark
```

To run the test suite for a particular Python version, you can do:
```bash
tox -e py38
```

To run individual tests with ``tox``, you can do::
```bash
tox -e py38 -- -k name_of_the_test
```

We use pytest_ for testing ``pylint``, which you can use without using ``tox`` for a faster development cycle.

If you want to run tests on a specific portion of the code with [pytest](https://docs.pytest.org/en/latest/), [pytest-cov](https://pypi.org/project/pytest-cov/) and your local python version::
```bash
pip install pytest-cov
# Everything:
python3 -m pytest tests/ --cov=pylint_plugin_utils
coverage html
```



# License

`pylint-plugin-utils` is available under the GPLv2 License.
165 changes: 93 additions & 72 deletions pylint_plugin_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,75 @@
import sys
from typing import List

from pylint.exceptions import UnknownMessageError
from pylint.lint import PyLinter


def get_class(module_name, kls):
parts = kls.split('.')
parts = kls.split(".")
m = __import__(module_name)
for mp in module_name.split('.')[1:]:
for mp in module_name.split(".")[1:]:
m = getattr(m, mp)
klass = getattr(m, parts[0])
return klass


class NoSuchChecker(Exception):

def __init__(self, checker_class):
self.message = "Checker class %s was not found" % checker_class

def __repr__(self):
return self.message


def get_checker(linter, checker_class):
def get_checker(linter: PyLinter, checker_class):
for checker in linter.get_checkers():
if isinstance(checker, checker_class):
return checker
raise NoSuchChecker(checker_class)


def augment_visit(linter, checker_method, augmentation):
def augment_visit(linter: PyLinter, checker_method, augmentation):
"""
Augmenting a visit enables additional errors to be raised (although that case is
better served using a new checker) or to suppress all warnings in certain circumstances.
better served using a new checker) or to suppress all warnings in certain
circumstances.

Augmenting functions should accept a 'chain' function, which runs the checker method
and possibly any other augmentations, and secondly an Astroid node. "chain()" can be
called at any point to trigger the continuation of other checks, or not at all to
prevent any further checking.
Augmenting functions should accept a 'chain' function, which runs the checker
method and possibly any other augmentations, and secondly an Astroid node.
"chain()" can be called at any point to trigger the continuation of other
checks, or not at all to prevent any further checking.
"""

if sys.version_info[0] <= 2:
checker = get_checker(linter, checker_method.im_class)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that this old v2 compatibility code is going :)

else:
try:
checker = get_checker(linter, checker_method.__self__.__class__)
except AttributeError:
checker = get_checker(linter, get_class(checker_method.__module__, checker_method.__qualname__))
try:
checker = get_checker(linter, checker_method.__self__.__class__)
except AttributeError:
checker = get_checker(
linter, get_class(checker_method.__module__, checker_method.__qualname__)
)

old_method = getattr(checker, checker_method.__name__)
setattr(checker, checker_method.__name__, AugmentFunc(old_method, augmentation))


class AugmentFunc:
def __init__(self, old_method, augmentation_func):
self.old_method = old_method
self.augmentation_func = augmentation_func

def augment_func(node):
def chain():
old_method(node)
augmentation(chain, node)
def __call__(self, node):
self.augmentation_func(Chain(self.old_method, node), node)

setattr(checker, checker_method.__name__, augment_func)

class Chain:
def __init__(self, old_method, node):
self.old_method = old_method
self.node = node

class Suppress(object):
def __call__(self):
self.old_method(self.node)


class Suppress:
def __init__(self, linter):
self._linter = linter
self._suppress = []
Expand All @@ -79,69 +90,79 @@ def suppress(self, *symbols):
def __exit__(self, exc_type, exc_val, exc_tb):
self._linter.add_message = self._orig_add_message
for to_append_args, to_append_kwargs in self._messages_to_append:
# Depending on the Pylint version, the add_message API is different.
# Either a single object called 'message' is passed, or the first argument
# is a message symbol.
if hasattr('symbol', to_append_args[0]):
code = to_append_args[0].symbol
else:
code = to_append_args[0]
if to_append_args[0] in self._suppress:
continue
self._linter.add_message(*to_append_args, **to_append_kwargs)


def supress_message(linter, checker_method, message_id, test_func):
import warnings
warnings.warn("'supress_message' has been deprecated in favour of the correctly-spelled 'suppress_message'",
DeprecationWarning)
return suppress_message(linter, checker_method, message_id, test_func)


def suppress_message(linter, checker_method, message_id_or_symbol, test_func):
def suppress_message(linter: PyLinter, checker_method, message_id_or_symbol, test_func):
"""
This wrapper allows the suppression of a message if the supplied test function
returns True. It is useful to prevent one particular message from being raised
in one particular case, while leaving the rest of the messages intact.
"""
# At some point, pylint started preferring message symbols to message IDs. However this is not done
# consistently or uniformly - occasionally there are some message IDs with no matching symbols.
# We try to work around this here by suppressing both the ID and the symbol, if we can find it.
# This also gives us compatability with a broader range of pylint versions.

# Similarly, a commit between version 1.2 and 1.3 changed where the messages are stored - see:
# https://bitbucket.org/logilab/pylint/commits/0b67f42799bed08aebb47babdc9fb0e761efc4ff#chg-reporters/__init__.py
# Therefore here, we try the new attribute name, and fall back to the old version for
# compatability with <=1.2 and >=1.3
msgs_store = getattr(linter, 'msgs_store', linter)

def get_message_definitions(message_id_or_symbol):
if hasattr(msgs_store, 'check_message_id'):
augment_visit(
linter, checker_method, DoSuppress(linter, message_id_or_symbol, test_func)
)


class DoSuppress:
def __init__(self, linter: PyLinter, message_id_or_symbol, test_func):
self.linter = linter
self.message_id_or_symbol = message_id_or_symbol
self.test_func = test_func

def __call__(self, chain, node):
with Suppress(self.linter) as s:
if self.test_func(node):
s.suppress(*self.symbols)
chain()

@property
def symbols(self) -> List:
# At some point, pylint started preferring message symbols to message IDs.
# However, this is not done consistently or uniformly
# - occasionally there are some message IDs with no matching symbols.
# We try to work around this here by suppressing both the ID and the symbol.
# This also gives us compatability with a broader range of pylint versions.

# Similarly, between version 1.2 and 1.3 changed where the messages are stored
# - see:
# https://bitbucket.org/logilab/pylint/commits/0b67f42799bed08aebb47babdc9fb0e761efc4ff#chg-reporters/__init__.py
# Therefore here, we try the new attribute name, and fall back to the old
# version for compatability with <=1.2 and >=1.3

try:
pylint_messages = self.get_message_definitions(self.message_id_or_symbol)
the_symbols = [
symbol
for pylint_message in pylint_messages
for symbol in (pylint_message.msgid, pylint_message.symbol)
if symbol is not None
]
except UnknownMessageError:
# This can happen due to mismatches of pylint versions and plugin
# expectations of available messages
the_symbols = [self.message_id_or_symbol]

return the_symbols

def get_message_definitions(self, message_id_or_symbol):
msgs_store = getattr(self.linter, "msgs_store", self.linter)

if hasattr(msgs_store, "check_message_id"):
return [msgs_store.check_message_id(message_id_or_symbol)]
# pylint 2.0 renamed check_message_id to get_message_definition in:
# https://github.com/PyCQA/pylint/commit/5ccbf9eaa54c0c302c9180bdfb745566c16e416d
elif hasattr(msgs_store, 'get_message_definition'):
elif hasattr(msgs_store, "get_message_definition"):
return [msgs_store.get_message_definition(message_id_or_symbol)]
# pylint 2.3.0 renamed get_message_definition to get_message_definitions in:
# https://github.com/PyCQA/pylint/commit/da67a9da682e51844fbc674229ff6619eb9c816a
elif hasattr(msgs_store, 'get_message_definitions'):
elif hasattr(msgs_store, "get_message_definitions"):
return msgs_store.get_message_definitions(message_id_or_symbol)
else:
raise ValueError('pylint.utils.MessagesStore does not have a get_message_definition(s) method')

try:
pylint_messages = get_message_definitions(message_id_or_symbol)
symbols = [symbol
for pylint_message in pylint_messages
for symbol in (pylint_message.msgid, pylint_message.symbol)
if symbol is not None]
except UnknownMessageError:
# This can happen due to mismatches of pylint versions and plugin expectations of available messages
symbols = [message_id_or_symbol]

def do_suppress(chain, node):
with Suppress(linter) as s:
if test_func(node):
s.suppress(*symbols)
chain()
augment_visit(linter, checker_method, do_suppress)
msg = (
"pylint.utils.MessagesStore does not have a "
"get_message_definition(s) method"
)
raise ValueError(msg)
4 changes: 4 additions & 0 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements_test_pre_commit.txt
-r requirements_test_min.txt
pre-commit~=2.16
pytest-cov~=3.0
2 changes: 2 additions & 0 deletions requirements_test_min.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-e .
pytest~=6.2
6 changes: 6 additions & 0 deletions requirements_test_pre_commit.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Everything in this file should reflect the pre-commit configuration
# in .pre-commit-config.yaml
black==21.12b0
flake8==4.0.1
flake8-typing-imports==1.12.0
isort==5.10.1
9 changes: 9 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[aliases]
test = pytest

[tool:pytest]
testpaths = tests
python_files = *test_*.py

[isort]
profile = black
Loading