diff --git a/docs/source/user/options.rst b/docs/source/user/options.rst index 1cbc22f..00164ae 100644 --- a/docs/source/user/options.rst +++ b/docs/source/user/options.rst @@ -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 ` + .. 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=[ ] + + :ref:`Go back to index ` + + .. 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= :ref:`Go back to index ` @@ -743,6 +781,8 @@ Options and their Descriptions :ref:`Go back to index ` + .. 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 diff --git a/src/flake8/__init__.py b/src/flake8/__init__.py index 5f316c9..c15bf87 100644 --- a/src/flake8/__init__.py +++ b/src/flake8/__init__.py @@ -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() ) diff --git a/src/flake8/checker.py b/src/flake8/checker.py index e8a71d4..3b97414 100644 --- a/src/flake8/checker.py +++ b/src/flake8/checker.py @@ -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 diff --git a/src/flake8/main/application.py b/src/flake8/main/application.py index bd409d7..b86b3cc 100644 --- a/src/flake8/main/application.py +++ b/src/flake8/main/application.py @@ -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 ) diff --git a/src/flake8/main/options.py b/src/flake8/main/options.py index 73b3932..2aabab8 100644 --- a/src/flake8/main/options.py +++ b/src/flake8/main/options.py @@ -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", diff --git a/src/flake8/options/manager.py b/src/flake8/options/manager.py index 3f4e883..7be4315 100644 --- a/src/flake8/options/manager.py +++ b/src/flake8/options/manager.py @@ -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: diff --git a/src/flake8/style_guide.py b/src/flake8/style_guide.py index 7565b00..46f72fc 100644 --- a/src/flake8/style_guide.py +++ b/src/flake8/style_guide.py @@ -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 "".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. diff --git a/src/flake8/utils.py b/src/flake8/utils.py index a837577..68627af 100644 --- a/src/flake8/utils.py +++ b/src/flake8/utils.py @@ -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. diff --git a/tests/unit/test_style_guide.py b/tests/unit/test_style_guide.py index c9005ab..c4ef5ef 100644 --- a/tests/unit/test_style_guide.py +++ b/tests/unit/test_style_guide.py @@ -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