Merge branch 'per-file-style-guide' into 'master'

Add support for per-file ignores in config

Closes #156

See merge request pycqa/flake8!259
This commit is contained in:
Ian Stapleton Cordasco 2018-10-29 15:37:11 +00:00
commit 4439ea2025
9 changed files with 351 additions and 20 deletions

View file

@ -58,6 +58,8 @@ Index of Options
- :option:`flake8 --extend-ignore`
- :option:`flake8 --per-file-ignores`
- :option:`flake8 --max-line-length`
- :option:`flake8 --select`
@ -422,6 +424,8 @@ Options and their Descriptions
:ref:`Go back to index <top>`
.. versionadded:: 3.6.0
Specify a list of codes to add to the list of ignored ones. Similar
considerations as in :option:`--ignore` apply here with regard to the
value.
@ -449,6 +453,40 @@ Options and their Descriptions
extend-ignore = E4,E51,W234
.. option:: --per-file-ignores=<filename:errors>[ <filename:errors>]
:ref:`Go back to index <top>`
.. versionadded:: 3.7.0
Specify a list of mappings of files and the codes that should be ignored
for the entirety of the file. This allows for a project to have a default
list of violations that should be ignored as well as file-specific
violations for files that have not been made compliant with the project
rules.
This option supports syntax similar to :option:`--exclude` such that glob
patterns will also work here.
This can be combined with both :option:`--ignore` and
:option:`--extend-ignore` to achieve a full flexibility of style options.
Command-line usage:
.. prompt:: bash
flake8 --per-file-ignores='project/__init__.py:F401 setup.py:E121'
flake8 --per-file-ignores='project/*/__init__.py:F401 setup.py:E121'
This **can** be specified in config files.
.. code-block:: ini
per-file-ignores =
project/__init__.py:F401
setup.py:E121
other_project/*:W9
.. option:: --max-line-length=<n>
:ref:`Go back to index <top>`
@ -743,6 +781,8 @@ Options and their Descriptions
:ref:`Go back to index <top>`
.. versionadded:: 3.6.0
Provide extra config files to parse in after and in addition to the files
that |Flake8| found on its own. Since these files are the last ones read
into the Configuration Parser, so it has the highest precedence if it

View file

@ -15,7 +15,7 @@ import sys
LOG = logging.getLogger(__name__)
LOG.addHandler(logging.NullHandler())
__version__ = "3.6.0"
__version__ = "3.7.0dev0"
__version_info__ = tuple(
int(i) for i in __version__.split(".") if i.isdigit()
)

View file

@ -2,7 +2,6 @@
import collections
import errno
import logging
import os
import signal
import sys
import tokenize
@ -188,20 +187,12 @@ class Manager(object):
return False
path = self.options.stdin_display_name
exclude = self.options.exclude
if not exclude:
return False
basename = os.path.basename(path)
if utils.fnmatch(basename, exclude):
LOG.debug('"%s" has been excluded', basename)
return True
absolute_path = os.path.abspath(path)
match = utils.fnmatch(absolute_path, exclude)
LOG.debug(
'"%s" has %sbeen excluded', absolute_path, "" if match else "not "
return utils.matches_filename(
path,
patterns=self.options.exclude,
log_message='"%(path)s" has %(whether)sbeen excluded',
logger=LOG,
)
return match
def make_checkers(self, paths=None):
# type: (List[str]) -> NoneType

View file

@ -65,8 +65,8 @@ class Application(object):
self.formatter = None
#: The :class:`flake8.plugins.notifier.Notifier` for listening plugins
self.listener_trie = None
#: The :class:`flake8.style_guide.StyleGuide` built from the user's
#: options
#: The :class:`flake8.style_guide.StyleGuideManager` built from the
#: user's options
self.guide = None
#: The :class:`flake8.checker.Manager` that will handle running all of
#: the checks selected by the user.
@ -283,7 +283,7 @@ class Application(object):
# type: () -> NoneType
"""Initialize our StyleGuide."""
if self.guide is None:
self.guide = style_guide.StyleGuide(
self.guide = style_guide.StyleGuideManager(
self.options, self.listener_trie, self.formatter
)

View file

@ -1,5 +1,6 @@
"""Contains the logic for all of the default options for Flake8."""
from flake8 import defaults
from flake8 import utils
from flake8.main import debug
from flake8.main import vcs
@ -19,6 +20,7 @@ def register_default_options(option_manager):
- ``--hang-closing``
- ``--ignore``
- ``--extend-ignore``
- ``--per-file-ignores``
- ``--max-line-length``
- ``--select``
- ``--disable-noqa``
@ -142,6 +144,18 @@ def register_default_options(option_manager):
" of ignored ones. For example, ``--extend-ignore=E4,E51,W234``.",
)
add_option(
"--per-file-ignores",
parse_from_config=True,
comma_separated_list=True,
separator=utils.NEWLINE_SEPARATED_LIST_RE,
help="A pairing of filenames and violation codes that defines which "
"violations to ignore in a particular file. The filenames can be "
"specified in a manner similar to the ``--exclude`` option and the "
"violations work similarly to the ``--ignore`` and ``--select`` "
"options.",
)
add_option(
"--max-line-length",
type="int",

View file

@ -32,6 +32,7 @@ class Option(object):
parse_from_config=False,
comma_separated_list=False,
normalize_paths=False,
separator=None,
):
"""Initialize an Option instance wrapping optparse.Option.
@ -79,6 +80,8 @@ class Option(object):
:param bool normalize_paths:
Whether the option is expecting a path or list of paths and should
attempt to normalize the paths to absolute paths.
:param separator:
The item that separates the "comma"-separated list.
"""
self.short_option_name = short_option_name
self.long_option_name = long_option_name
@ -107,6 +110,7 @@ class Option(object):
self.parse_from_config = parse_from_config
self.comma_separated_list = comma_separated_list
self.normalize_paths = normalize_paths
self.separator = separator or utils.COMMA_SEPARATED_LIST_RE
self.config_name = None
if parse_from_config:

View file

@ -1,6 +1,7 @@
"""Implementation of the StyleGuide used by Flake8."""
import collections
import contextlib
import copy
import enum
import functools
import itertools
@ -321,8 +322,8 @@ class DecisionEngine(object):
return decision
class StyleGuide(object):
"""Manage a Flake8 user's style guide."""
class StyleGuideManager(object):
"""Manage multiple style guides for a single run."""
def __init__(self, options, listener_trie, formatter, decider=None):
"""Initialize our StyleGuide.
@ -334,8 +335,136 @@ class StyleGuide(object):
self.formatter = formatter
self.stats = statistics.Statistics()
self.decider = decider or DecisionEngine(options)
self.style_guides = []
self.default_style_guide = StyleGuide(
options, listener_trie, formatter, decider=decider
)
self.style_guides = list(
itertools.chain(
[self.default_style_guide],
self.populate_style_guides_with(options),
)
)
def populate_style_guides_with(self, options):
"""Generate style guides from the per-file-ignores option.
:param options:
The original options parsed from the CLI and config file.
:type options:
:class:`~optparse.Values`
:returns:
A copy of the default style guide with overridden values.
:rtype:
:class:`~flake8.style_guide.StyleGuide`
"""
for value in options.per_file_ignores:
filename, violations_str = value.split(":")
violations = utils.parse_comma_separated_list(violations_str)
yield self.default_style_guide.copy(
filename=filename, extend_ignore_with=violations
)
def style_guide_for(self, filename):
"""Find the StyleGuide for the filename in particular."""
guides = sorted(
(g for g in self.style_guides if g.applies_to(filename)),
key=lambda g: len(g.filename or ""),
)
if len(guides) > 1:
return guides[-1]
return guides[0]
@contextlib.contextmanager
def processing_file(self, filename):
"""Record the fact that we're processing the file's results."""
guide = self.style_guide_for(filename)
with guide.processing_file(filename):
yield guide
def handle_error(
self,
code,
filename,
line_number,
column_number,
text,
physical_line=None,
):
# type: (str, str, int, int, str) -> int
"""Handle an error reported by a check.
:param str code:
The error code found, e.g., E123.
:param str filename:
The file in which the error was found.
:param int line_number:
The line number (where counting starts at 1) at which the error
occurs.
:param int column_number:
The column number (where counting starts at 1) at which the error
occurs.
:param str text:
The text of the error message.
:param str physical_line:
The actual physical line causing the error.
:returns:
1 if the error was reported. 0 if it was ignored. This is to allow
for counting of the number of errors found that were not ignored.
:rtype:
int
"""
guide = self.style_guide_for(filename)
return guide.handle_error(
code, filename, line_number, column_number, text, physical_line
)
def add_diff_ranges(self, diffinfo):
"""Update the StyleGuides to filter out information not in the diff.
This provides information to the underlying StyleGuides so that only
the errors in the line number ranges are reported.
:param dict diffinfo:
Dictionary mapping filenames to sets of line number ranges.
"""
for guide in self.style_guides.values():
guide.add_diff_ranges(diffinfo)
class StyleGuide(object):
"""Manage a Flake8 user's style guide."""
def __init__(
self, options, listener_trie, formatter, filename=None, decider=None
):
"""Initialize our StyleGuide.
.. todo:: Add parameter documentation.
"""
self.options = options
self.listener = listener_trie
self.formatter = formatter
self.stats = statistics.Statistics()
self.decider = decider or DecisionEngine(options)
self.filename = filename
if self.filename:
self.filename = utils.normalize_path(self.filename)
self._parsed_diff = {}
def __repr__(self):
"""Make it easier to debug which StyleGuide we're using."""
return "<StyleGuide [{}]>".format(self.filename)
def copy(self, filename=None, extend_ignore_with=None, **kwargs):
"""Create a copy of this style guide with different values."""
filename = filename or self.filename
options = copy.deepcopy(self.options)
options.ignore.extend(extend_ignore_with or [])
return StyleGuide(
options, self.listener, self.formatter, filename=filename
)
@contextlib.contextmanager
def processing_file(self, filename):
"""Record the fact that we're processing the file's results."""
@ -343,6 +472,26 @@ class StyleGuide(object):
yield self
self.formatter.finished(filename)
def applies_to(self, filename):
"""Check if this StyleGuide applies to the file.
:param str filename:
The name of the file with violations that we're potentially
applying this StyleGuide to.
:returns:
True if this applies, False otherwise
:rtype:
bool
"""
if self.filename is None:
return True
return utils.matches_filename(
filename,
patterns=[self.filename],
log_message='{!r} does %(whether)smatch "%(path)s"'.format(self),
logger=LOG,
)
def should_report_error(self, code):
# type: (str) -> Decision
"""Determine if the error code should be reported or ignored.

View file

@ -11,6 +11,7 @@ import tokenize
DIFF_HUNK_REGEXP = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@.*$")
COMMA_SEPARATED_LIST_RE = re.compile(r"[,\s]")
NEWLINE_SEPARATED_LIST_RE = re.compile(r"[\s]")
LOCAL_PLUGIN_LIST_RE = re.compile(r"[,\t\n\r\f\v]")
@ -335,6 +336,38 @@ def parameters_for(plugin):
return parameters
def matches_filename(path, patterns, log_message, logger):
"""Use fnmatch to discern if a path exists in patterns.
:param str path:
The path to the file under question
:param patterns:
The patterns to match the path against.
:type patterns:
list[str]
:param str log_message:
The message used for logging purposes.
:returns:
True if path matches patterns, False otherwise
:rtype:
bool
"""
if not patterns:
return False
basename = os.path.basename(path)
if fnmatch(basename, patterns):
logger.debug(log_message, {"path": basename, "whether": ""})
return True
absolute_path = os.path.abspath(path)
match = fnmatch(absolute_path, patterns)
logger.debug(
log_message,
{"path": absolute_path, "whether": "" if match else "not "},
)
return match
def get_python_version():
"""Find and format the python implementation and version.

View file

@ -5,6 +5,7 @@ import mock
import pytest
from flake8 import style_guide
from flake8 import utils
from flake8.formatting import base
from flake8.plugins import notifier
@ -17,6 +18,7 @@ def create_options(**kwargs):
kwargs.setdefault('extend_ignore', [])
kwargs.setdefault('disable_noqa', False)
kwargs.setdefault('enable_extensions', [])
kwargs.setdefault('per_file_ignores', [])
return optparse.Values(kwargs)
@ -81,3 +83,101 @@ def test_handle_error_does_not_notify_listeners(select_list, ignore_list,
guide.handle_error(error_code, 'stdin', 1, 1, 'error found')
assert listener_trie.notify.called is False
assert formatter.handle.called is False
def test_style_guide_manager():
"""Verify how the StyleGuideManager creates a default style guide."""
listener_trie = mock.create_autospec(notifier.Notifier, instance=True)
formatter = mock.create_autospec(base.BaseFormatter, instance=True)
options = create_options()
guide = style_guide.StyleGuideManager(options,
listener_trie=listener_trie,
formatter=formatter)
assert guide.default_style_guide.options is options
assert len(guide.style_guides) == 1
PER_FILE_IGNORES_UNPARSED = [
"first_file.py:W9",
"second_file.py:F4,F9",
"third_file.py:E3",
"sub_dir/*:F4",
]
@pytest.mark.parametrize('style_guide_file,filename,expected', [
("first_file.py", "first_file.py", True),
("first_file.py", "second_file.py", False),
("sub_dir/*.py", "first_file.py", False),
("sub_dir/*.py", "sub_dir/file.py", True),
("sub_dir/*.py", "other_dir/file.py", False),
])
def test_style_guide_applies_to(style_guide_file, filename, expected):
"""Verify that we match a file to its style guide."""
listener_trie = mock.create_autospec(notifier.Notifier, instance=True)
formatter = mock.create_autospec(base.BaseFormatter, instance=True)
options = create_options()
guide = style_guide.StyleGuide(options,
listener_trie=listener_trie,
formatter=formatter,
filename=style_guide_file)
assert guide.applies_to(filename) is expected
def test_style_guide_manager_pre_file_ignores_parsing():
"""Verify how the StyleGuideManager creates a default style guide."""
listener_trie = mock.create_autospec(notifier.Notifier, instance=True)
formatter = mock.create_autospec(base.BaseFormatter, instance=True)
options = create_options(per_file_ignores=PER_FILE_IGNORES_UNPARSED)
guide = style_guide.StyleGuideManager(options,
listener_trie=listener_trie,
formatter=formatter)
assert len(guide.style_guides) == 5
assert list(map(utils.normalize_path,
["first_file.py", "second_file.py", "third_file.py",
"sub_dir/*"])
) == [g.filename for g in guide.style_guides[1:]]
@pytest.mark.parametrize('ignores,violation,filename,handle_error_return', [
(['E1', 'E2'], 'F401', 'first_file.py', 1),
(['E1', 'E2'], 'E121', 'first_file.py', 0),
(['E1', 'E2'], 'F401', 'second_file.py', 0),
(['E1', 'E2'], 'F401', 'third_file.py', 1),
(['E1', 'E2'], 'E311', 'third_file.py', 0),
(['E1', 'E2'], 'F401', 'sub_dir/file.py', 0),
])
def test_style_guide_manager_pre_file_ignores(ignores, violation, filename,
handle_error_return):
"""Verify how the StyleGuideManager creates a default style guide."""
listener_trie = mock.create_autospec(notifier.Notifier, instance=True)
formatter = mock.create_autospec(base.BaseFormatter, instance=True)
options = create_options(ignore=ignores,
select=['E', 'F', 'W'],
per_file_ignores=PER_FILE_IGNORES_UNPARSED)
guide = style_guide.StyleGuideManager(options,
listener_trie=listener_trie,
formatter=formatter)
assert (guide.handle_error(violation, filename, 1, 1, "Fake text")
== handle_error_return)
@pytest.mark.parametrize('filename,expected', [
('first_file.py', utils.normalize_path('first_file.py')),
('second_file.py', utils.normalize_path('second_file.py')),
('third_file.py', utils.normalize_path('third_file.py')),
('fourth_file.py', None),
('sub_dir/__init__.py', utils.normalize_path('sub_dir/*')),
('other_dir/__init__.py', None),
])
def test_style_guide_manager_style_guide_for(filename, expected):
"""Verify the style guide selection function."""
listener_trie = mock.create_autospec(notifier.Notifier, instance=True)
formatter = mock.create_autospec(base.BaseFormatter, instance=True)
options = create_options(per_file_ignores=PER_FILE_IGNORES_UNPARSED)
guide = style_guide.StyleGuideManager(options,
listener_trie=listener_trie,
formatter=formatter)
file_guide = guide.style_guide_for(filename)
assert file_guide.filename == expected