From 25171aa044833f74b9472e18f02c3cd2fa582d73 Mon Sep 17 00:00:00 2001 From: Heoga Date: Wed, 31 Mar 2021 20:02:17 +0100 Subject: [PATCH 1/2] Add a match_path option for comparison against a full path. It has been noted in issue #363 that match & match_dir are unwieldy when attempting to match against full paths. For unexample if you have A.py in directories B & C and you only want to run pydocstyle on one of them. From my own experience trying to deploy pydocstyle against a large legacy codebase it is unworkable as it would mean the entire codebase being converted as a big bang change. A more nuanced approach means the codebase can be converted gradually. This commit adds a new option, match_path, to the config & command lines which can be used to provide more nuanced matching. For example the following specification: match_path = [AB]/[ab].py D/e.py This defines two regexes. If either match a given path, relative to the directory specified, the file will be yielded for comparison. The is complimentary to match & match_dir and the three can be used together. --- src/pydocstyle/config.py | 40 +++++++++++++++++++--- src/tests/test_integration.py | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/pydocstyle/config.py b/src/pydocstyle/config.py index f5226204..b9530b7e 100644 --- a/src/pydocstyle/config.py +++ b/src/pydocstyle/config.py @@ -69,12 +69,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 @@ -149,6 +151,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 ( @@ -160,14 +169,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), @@ -176,7 +193,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) @@ -283,7 +304,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) @@ -371,7 +391,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 ) @@ -405,7 +425,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 @@ -721,6 +741,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( @@ -743,7 +773,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 c0209b19..20663eae 100644 --- a/src/tests/test_integration.py +++ b/src/tests/test_integration.py @@ -1308,6 +1308,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'): From 8dfb1907d3a2248951e243fd513af0f87ad279b6 Mon Sep 17 00:00:00 2001 From: Heoga Date: Thu, 1 Apr 2021 08:39:02 +0100 Subject: [PATCH 2/2] Add release note for match_path option --- docs/release_notes.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 0c9b87fe..6264b1c0 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) + 6.0.0 - March 18th, 2021 ---------------------------