diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 6a1d7ae4..9f8d0eae 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -4,6 +4,13 @@ Release Notes **pydocstyle** version numbers follow the `Semantic Versioning `_ specification. +Current Development Version +--------------------------- + +New Features + +* Add --match-path which operates over relative paths (#529) + Current Development Version --------------------------- diff --git a/src/pydocstyle/config.py b/src/pydocstyle/config.py index ae1e6b58..696d8be2 100644 --- a/src/pydocstyle/config.py +++ b/src/pydocstyle/config.py @@ -179,12 +179,14 @@ class ConfigurationParser: 'add-ignore', 'match', 'match-dir', + 'match-path', 'ignore-decorators', ) BASE_ERROR_SELECTION_OPTIONS = ('ignore', 'select', 'convention') DEFAULT_MATCH_RE = r'(?!test_).*\.py' DEFAULT_MATCH_DIR_RE = r'[^\.].*' + DEFAULT_MATCH_PATH_RE = [r'[^\.].*'] DEFAULT_IGNORE_DECORATORS_RE = '' DEFAULT_CONVENTION = conventions.pep257 @@ -260,6 +262,13 @@ def _get_matches(conf): match_dir_func = re(conf.match_dir + '$').match return match_func, match_dir_func + def _get_path_matches(conf): + """Return a list of `match_path` regexes.""" + matches = conf.match_path + if isinstance(matches, str): + matches = matches.split() + return [re(x) for x in matches] + def _get_ignore_decorators(conf): """Return the `ignore_decorators` as None or regex.""" return ( @@ -271,14 +280,22 @@ def _get_ignore_decorators(conf): for root, dirs, filenames in os.walk(name): config = self._get_config(os.path.abspath(root)) match, match_dir = _get_matches(config) + match_paths = _get_path_matches(config) ignore_decorators = _get_ignore_decorators(config) # Skip any dirs that do not match match_dir dirs[:] = [d for d in dirs if match_dir(d)] for filename in filenames: + full_path = os.path.join(root, filename) + relative_posix = os.path.normpath( + os.path.relpath(full_path, start=name) + ).replace(os.path.sep, "/") + if not any( + x.match(relative_posix) for x in match_paths + ): + continue if match(filename): - full_path = os.path.join(root, filename) yield ( full_path, list(config.checked_codes), @@ -287,7 +304,11 @@ def _get_ignore_decorators(conf): else: config = self._get_config(os.path.abspath(name)) match, _ = _get_matches(config) + match_paths = _get_path_matches(config) ignore_decorators = _get_ignore_decorators(config) + posix = os.path.normpath(name).replace(os.path.sep, "/") + if not any(x.match(posix) for x in match_paths): + continue if match(name): yield (name, list(config.checked_codes), ignore_decorators) @@ -394,7 +415,6 @@ def _get_config(self, node): cli_val = getattr(self._override_by_cli, attr) conf_val = getattr(config, attr) final_config[attr] = cli_val if cli_val is not None else conf_val - config = CheckConfiguration(**final_config) self._set_add_options(config.checked_codes, self._options) @@ -485,7 +505,7 @@ def _merge_configuration(self, parent_config, child_options): self._set_add_options(error_codes, child_options) kwargs = dict(checked_codes=error_codes) - for key in ('match', 'match_dir', 'ignore_decorators'): + for key in ('match', 'match_dir', 'match_path', 'ignore_decorators'): kwargs[key] = getattr(child_options, key) or getattr( parent_config, key ) @@ -519,7 +539,7 @@ def _create_check_config(cls, options, use_defaults=True): checked_codes = cls._get_checked_errors(options) kwargs = dict(checked_codes=checked_codes) - for key in ('match', 'match_dir', 'ignore_decorators'): + for key in ('match', 'match_dir', 'match_path', 'ignore_decorators'): kwargs[key] = ( getattr(cls, f'DEFAULT_{key.upper()}_RE') if getattr(options, key) is None and use_defaults @@ -840,6 +860,16 @@ def _create_option_parser(cls): "a dot" ).format(cls.DEFAULT_MATCH_DIR_RE), ) + option( + '--match-path', + metavar='', + default=None, + nargs="+", + help=( + "search only paths that exactly match regular " + "expressions. Can take multiple values." + ), + ) # Decorators option( @@ -862,7 +892,7 @@ def _create_option_parser(cls): # Check configuration - used by the ConfigurationParser class. CheckConfiguration = namedtuple( 'CheckConfiguration', - ('checked_codes', 'match', 'match_dir', 'ignore_decorators'), + ('checked_codes', 'match', 'match_dir', 'match_path', 'ignore_decorators'), ) diff --git a/src/tests/test_integration.py b/src/tests/test_integration.py index eb4994ff..fa24e31f 100644 --- a/src/tests/test_integration.py +++ b/src/tests/test_integration.py @@ -1393,6 +1393,69 @@ def foo(): assert code == 0 +def test_config_file_nearest_match_path(env): + r"""Test that the `match-path` option is handled correctly. + + env_base + +-- tox.ini + | This configuration will set `convention=pep257` and + | `match_path=A/[BC]/[bc]\.py\n A/D/bla.py`. + +-- A + +-- B + | +-- b.py + | Will violate D100,D103. + +-- C + | +-- c.py + | | Will violate D100,D103. + | +-- bla.py + | Will violate D100. + +-- D + +-- c.py + | Will violate D100,D103. + +-- bla.py + Will violate D100. + + We expect the call to pydocstyle to fail, and since we run with verbose the + output should contain `A/B/b.py`, `A/C/c.py` and `A/D/bla.py` but not the + others. + """ + env.write_config(convention='pep257') + env.write_config(match_path='A/[BC]/[bc]\.py\n A/D/bla.py') + + content = textwrap.dedent("""\ + def foo(): + pass + """) + + env.makedirs(os.path.join('A', 'B')) + env.makedirs(os.path.join('A', 'C')) + env.makedirs(os.path.join('A', 'D')) + with env.open(os.path.join('A', 'B', 'b.py'), 'wt') as test: + test.write(content) + + with env.open(os.path.join('A', 'C', 'c.py'), 'wt') as test: + test.write(content) + + with env.open(os.path.join('A', 'C', 'bla.py'), 'wt') as test: + test.write('') + + with env.open(os.path.join('A', 'D', 'c.py'), 'wt') as test: + test.write(content) + + with env.open(os.path.join('A', 'D', 'bla.py'), 'wt') as test: + test.write('') + + out, _, code = env.invoke(args="--verbose") + + assert os.path.join("A", "B", "b.py") in out + assert os.path.join("A", "C", "c.py") in out + assert os.path.join("A", "C", "bla.py") not in out + assert os.path.join("A", "D", "c.py") not in out + assert os.path.join("A", "D", "bla.py") in out + + assert code == 1 + + def test_syntax_error_multiple_files(env): """Test that a syntax error in a file doesn't prevent further checking.""" for filename in ('first.py', 'second.py'):