Skip to content
This repository has been archived by the owner on Jan 12, 2021. It is now read-only.

Per-file exceptions support #40

Merged
merged 6 commits into from
May 19, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 26 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ Key can be exact plugin name or wildcard template. For example `"flake8-commas"`

Value is a list of templates for error codes for this plugin. First symbol in every template must be `+` (include) or `-` (exclude). The latest matched pattern wins. For example, `["+*", "-F*", "-E30?", "-E401"]` means "Include everything except all checks that starts with `F`, check from `E301` to `E310`, and `E401`".

## Exceptions

Use `exceptions` section to specify special rules for particular paths:

```toml
[tool.flakehell.plugins]
pycodestyle = ["+*"]
pyflakes = ["+*"]

[tool.flakehell.exceptions."tests/"]
pycodestyle = ["-F401"] # disable a check
pyflakes = ["-*"] # disable a plugin

[tool.flakehell.exceptions."tests/test_example.py"]
pyflakes = ["+*"] # enable a plugin
```

## Base

Option `base` allows to specify base config from which you want to inherit this one. It can be path to local config or remote URL. You can specify one path or list of paths as well. For example:
Expand Down Expand Up @@ -63,6 +80,15 @@ flake8-bandit = ["-*", "+S1??"]
"flake8-*" = ["+*"]
# explicitly disable plugin
flake8-docstrings = ["-*"]

# disable some checks for tests
[tool.flakehell.exceptions."tests/"]
pycodestyle = ["-F401"] # disable a check
pyflakes = ["-*"] # disable a plugin

# do not disable `pyflakes` for one file in tests
[tool.flakehell.exceptions."tests/test_example.py"]
pyflakes = ["+*"] # enable a plugin
```

See [Flake8 documentation](http://flake8.pycqa.org/en/latest/user/configuration.html) to read more about Flake8-specific configuration.
4 changes: 2 additions & 2 deletions flakehell/_logic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from ._config import read_config
from ._discover import get_installed
from ._extractors import extract
from ._plugin import get_plugin_name, get_plugin_rules, check_include
from ._plugin import get_plugin_name, get_plugin_rules, check_include, get_exceptions
from ._snapshot import Snapshot, prepare_cache


Expand All @@ -13,6 +13,6 @@
'color_code', 'color_description',
'get_installed',
'extract',
'get_plugin_name', 'get_plugin_rules', 'check_include',
'get_plugin_name', 'get_plugin_rules', 'check_include', 'get_exceptions',
'Snapshot', 'prepare_cache',
]
21 changes: 12 additions & 9 deletions flakehell/_logic/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,37 @@ def read_config(*paths) -> Dict[str, Any]:
return config


def _read_local(path: Path):
def _read_local(path: Path) -> Dict[str, Any]:
with path.open('r') as stream:
return _parse_config(stream.read())


def _read_remote(url: str):
def _read_remote(url: str) -> Dict[str, Any]:
http = urllib3.PoolManager()
response = http.request('GET', url)
return _parse_config(response.data.decode())


def _merge_configs(*configs):
def _merge_configs(*configs) -> Dict[str, Any]:
config = dict()
for subconfig in configs:
config.update(subconfig)

config['plugins'] = dict()
for subconfig in configs:
config['plugins'].update(subconfig.get('plugins', {}))
for section in ('plugins', 'exceptions'):
config[section] = dict()
for subconfig in configs:
config[section].update(subconfig.get(section, {}))

return config


def _parse_config(content: str):
def _parse_config(content: str) -> Dict[str, Any]:
config = toml.loads(content).get('tool', {}).get('flakehell', {})
config = dict(config)
if 'plugins' in config:
config['plugins'] = dict(config['plugins'])

for section in ('plugins', 'exceptions'):
if section in config:
config[section] = dict(config[section])

if 'base' in config:
paths = config['base']
Expand Down
26 changes: 24 additions & 2 deletions flakehell/_logic/_plugin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re
from typing import Dict, Any, List
from pathlib import Path
from typing import Dict, Any, List, Union

from flake8.utils import fnmatch

Expand All @@ -11,6 +12,7 @@
'naming': 'pep8-naming',
'logging-format': 'flake8-logging-format',
}
PluginsType = Dict[str, List[str]]


def get_plugin_name(plugin: Dict[str, Any]) -> str:
Expand Down Expand Up @@ -41,7 +43,7 @@ def get_plugin_name(plugin: Dict[str, Any]) -> str:
return names[0]


def get_plugin_rules(plugin_name: str, plugins: Dict[str, List[str]]) -> List[str]:
def get_plugin_rules(plugin_name: str, plugins: PluginsType) -> List[str]:
"""Get rules for plugin from `plugins` in the config

Plugin name can be specified as a glob expression.
Expand Down Expand Up @@ -94,3 +96,23 @@ def check_include(code: str, rules: List[str]) -> bool:
if fnmatch(code, patterns=[rule[1:]]):
include = rule[0] == '+'
return include


def get_exceptions(
path: Union[str, Path], exceptions: Dict[str, PluginsType], root: Path = None,
) -> PluginsType:
if isinstance(path, str):
path = Path(path)
if root is None:
root = Path().resolve()
path = path.resolve().relative_to(root).as_posix()
exceptions = sorted(
exceptions.items(),
key=lambda item: len(item[0]),
reverse=True,
)
for path_rule, rules in exceptions:
if path.startswith(path_rule):
return rules

return dict()
4 changes: 3 additions & 1 deletion flakehell/_patched/_app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
from argparse import ArgumentParser
from itertools import chain
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple

Expand Down Expand Up @@ -27,7 +28,8 @@ def get_toml_config(self, path: Path = None) -> Dict[str, Any]:
if path is not None:
return read_config(path)
# lookup for config from current dir up to root
for dir_path in Path('lol').parents:
root = Path().resolve()
for dir_path in chain([root], root.parents):
path = dir_path / 'pyproject.toml'
if path.exists():
return read_config(path)
Expand Down
35 changes: 23 additions & 12 deletions flakehell/_patched/_checkers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import List, Tuple, Optional
from typing import Any, Dict, List, Tuple, Optional

from flake8.checker import Manager, FileChecker
from flake8.utils import fnmatch, filenames_from

from .._logic import (
get_plugin_name, get_plugin_rules, check_include, make_baseline,
Snapshot, prepare_cache,
Snapshot, prepare_cache, get_exceptions,
)


Expand Down Expand Up @@ -62,12 +62,8 @@ def make_checkers(self, paths: List[str] = None) -> None:
def _make_checker(self, argument, filename, check_type,
check) -> Optional['FlakeHellFileChecker']:
# do not run plugins without rules specified
plugin_name = get_plugin_name(check)
rules = get_plugin_rules(
plugin_name=plugin_name,
plugins=self.options.plugins,
)
if not rules or rules == ['-*']:
rules = self._get_rules(check=check, filename=filename)
if not rules or set(rules) == {'-*'}:
return None

if not self._should_create_file_checker(filename=filename, argument=argument):
Expand All @@ -84,6 +80,24 @@ def _make_checker(self, argument, filename, check_type,
# return None
return checker

def _get_rules(self, check: Dict[str, Any], filename: str):
plugin_name = get_plugin_name(check)
rules = get_plugin_rules(
plugin_name=plugin_name,
plugins=self.options.plugins,
)
exceptions = get_exceptions(
path=filename,
exceptions=self.options.exceptions,
)
if exceptions:
rules = rules.copy()
rules += get_plugin_rules(
plugin_name=plugin_name,
plugins=exceptions,
)
return rules

def _should_create_file_checker(self, filename: str, argument) -> bool:
"""Filter out excluded files
"""
Expand Down Expand Up @@ -133,10 +147,7 @@ def report(self) -> Tuple[int, int]:

def _handle_results(self, filename: str, results: list, check: dict) -> int:
plugin_name = get_plugin_name(check)
rules = get_plugin_rules(
plugin_name=plugin_name,
plugins=self.options.plugins,
)
rules = self._get_rules(check=check, filename=filename)
reported_results_count = 0
for (error_code, line_number, column, text, physical_line) in results:
if self.baseline:
Expand Down
2 changes: 1 addition & 1 deletion flakehell/commands/_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ def lint_command(argv) -> CommandResult:
app.run(argv)
app.exit()
except SystemExit as exc:
return exc.code, ''
return int(exc.code), ''
41 changes: 41 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import subprocess
import os
import sys
from contextlib import contextmanager
from pathlib import Path
from textwrap import dedent

import pytest
from flakehell._cli import main


@contextmanager
def chdir(path):
"""Context manager for changing dir and restoring previous workdir after exit.
"""
curdir = os.getcwd()
os.chdir(str(path))
try:
yield
finally:
os.chdir(curdir)


def test_flake8helled_file():
"""Baseline behavior, when an actual filename is passed."""
cmd = [
Expand Down Expand Up @@ -57,3 +73,28 @@ def test_lint_help(capsys):
assert '-h, --help' in captured.out
assert '--builtins' in captured.out
assert '--isort-show-traceback' in captured.out


def test_exclude(capsys, tmp_path: Path):
text = """
[tool.flakehell.plugins]
pyflakes = ["+*"]

[tool.flakehell.exceptions."tests/"]
pyflakes = ["-F401"]
"""
(tmp_path / 'pyproject.toml').write_text(dedent(text))
(tmp_path / 'example.py').write_text('import sys\na')
(tmp_path / 'tests').mkdir()
(tmp_path / 'tests' / 'test_example.py').write_text('import sys\na')
with chdir(tmp_path):
result = main(['lint', '--format', 'default'])
assert result == (1, '')
captured = capsys.readouterr()
assert captured.err == ''
exp = """
./example.py:1:1: F401 'sys' imported but unused
./example.py:2:1: F821 undefined name 'a'
./tests/test_example.py:2:1: F821 undefined name 'a'
"""
assert captured.out.strip() == dedent(exp).strip()
Empty file added tests/test_logic/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions tests/test_logic/test_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pathlib import Path
from flakehell._logic import get_exceptions


def test_get_exceptions(tmp_path: Path):
tests_path = tmp_path / 'tests'
tests_path.mkdir()
test_path = tests_path / 'test_example.py'
test_path.touch()
source_path = tmp_path / 'example.py'
source_path.touch()

exceptions = {
'tests/': {'pyflakes': ['+*']},
}
result = get_exceptions(path=test_path, exceptions=exceptions, root=tmp_path)
assert result == {'pyflakes': ['+*']}
result = get_exceptions(path=source_path, exceptions=exceptions, root=tmp_path)
assert result == {}