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 --extend-ignore`
- :option:`flake8 --per-file-ignores`
- :option:`flake8 --max-line-length` - :option:`flake8 --max-line-length`
- :option:`flake8 --select` - :option:`flake8 --select`
@ -422,6 +424,8 @@ Options and their Descriptions
:ref:`Go back to index <top>` :ref:`Go back to index <top>`
.. versionadded:: 3.6.0
Specify a list of codes to add to the list of ignored ones. Similar 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 considerations as in :option:`--ignore` apply here with regard to the
value. value.
@ -449,6 +453,40 @@ Options and their Descriptions
extend-ignore = E4,E51,W234 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> .. option:: --max-line-length=<n>
:ref:`Go back to index <top>` :ref:`Go back to index <top>`
@ -743,6 +781,8 @@ Options and their Descriptions
:ref:`Go back to index <top>` :ref:`Go back to index <top>`
.. versionadded:: 3.6.0
Provide extra config files to parse in after and in addition to the files 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 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 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 = logging.getLogger(__name__)
LOG.addHandler(logging.NullHandler()) LOG.addHandler(logging.NullHandler())
__version__ = "3.6.0" __version__ = "3.7.0dev0"
__version_info__ = tuple( __version_info__ = tuple(
int(i) for i in __version__.split(".") if i.isdigit() int(i) for i in __version__.split(".") if i.isdigit()
) )

View file

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

View file

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

View file

@ -1,5 +1,6 @@
"""Contains the logic for all of the default options for Flake8.""" """Contains the logic for all of the default options for Flake8."""
from flake8 import defaults from flake8 import defaults
from flake8 import utils
from flake8.main import debug from flake8.main import debug
from flake8.main import vcs from flake8.main import vcs
@ -19,6 +20,7 @@ def register_default_options(option_manager):
- ``--hang-closing`` - ``--hang-closing``
- ``--ignore`` - ``--ignore``
- ``--extend-ignore`` - ``--extend-ignore``
- ``--per-file-ignores``
- ``--max-line-length`` - ``--max-line-length``
- ``--select`` - ``--select``
- ``--disable-noqa`` - ``--disable-noqa``
@ -142,6 +144,18 @@ def register_default_options(option_manager):
" of ignored ones. For example, ``--extend-ignore=E4,E51,W234``.", " 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( add_option(
"--max-line-length", "--max-line-length",
type="int", type="int",

View file

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

View file

@ -1,6 +1,7 @@
"""Implementation of the StyleGuide used by Flake8.""" """Implementation of the StyleGuide used by Flake8."""
import collections import collections
import contextlib import contextlib
import copy
import enum import enum
import functools import functools
import itertools import itertools
@ -321,8 +322,8 @@ class DecisionEngine(object):
return decision return decision
class StyleGuide(object): class StyleGuideManager(object):
"""Manage a Flake8 user's style guide.""" """Manage multiple style guides for a single run."""
def __init__(self, options, listener_trie, formatter, decider=None): def __init__(self, options, listener_trie, formatter, decider=None):
"""Initialize our StyleGuide. """Initialize our StyleGuide.
@ -334,8 +335,136 @@ class StyleGuide(object):
self.formatter = formatter self.formatter = formatter
self.stats = statistics.Statistics() self.stats = statistics.Statistics()
self.decider = decider or DecisionEngine(options) 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 = {} 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 @contextlib.contextmanager
def processing_file(self, filename): def processing_file(self, filename):
"""Record the fact that we're processing the file's results.""" """Record the fact that we're processing the file's results."""
@ -343,6 +472,26 @@ class StyleGuide(object):
yield self yield self
self.formatter.finished(filename) 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): def should_report_error(self, code):
# type: (str) -> Decision # type: (str) -> Decision
"""Determine if the error code should be reported or ignored. """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+))? @@.*$") DIFF_HUNK_REGEXP = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@.*$")
COMMA_SEPARATED_LIST_RE = re.compile(r"[,\s]") 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]") LOCAL_PLUGIN_LIST_RE = re.compile(r"[,\t\n\r\f\v]")
@ -335,6 +336,38 @@ def parameters_for(plugin):
return parameters 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(): def get_python_version():
"""Find and format the python implementation and version. """Find and format the python implementation and version.

View file

@ -5,6 +5,7 @@ import mock
import pytest import pytest
from flake8 import style_guide from flake8 import style_guide
from flake8 import utils
from flake8.formatting import base from flake8.formatting import base
from flake8.plugins import notifier from flake8.plugins import notifier
@ -17,6 +18,7 @@ def create_options(**kwargs):
kwargs.setdefault('extend_ignore', []) kwargs.setdefault('extend_ignore', [])
kwargs.setdefault('disable_noqa', False) kwargs.setdefault('disable_noqa', False)
kwargs.setdefault('enable_extensions', []) kwargs.setdefault('enable_extensions', [])
kwargs.setdefault('per_file_ignores', [])
return optparse.Values(kwargs) 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') guide.handle_error(error_code, 'stdin', 1, 1, 'error found')
assert listener_trie.notify.called is False assert listener_trie.notify.called is False
assert formatter.handle.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