Skip to content

Commit 25171aa

Browse files
committed
Add a match_path option for comparison against a full path.
It has been noted in issue PyCQA#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.
1 parent 837c0c2 commit 25171aa

File tree

2 files changed

+98
-5
lines changed

2 files changed

+98
-5
lines changed

Diff for: src/pydocstyle/config.py

+35-5
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ class ConfigurationParser:
6969
'add-ignore',
7070
'match',
7171
'match-dir',
72+
'match-path',
7273
'ignore-decorators',
7374
)
7475
BASE_ERROR_SELECTION_OPTIONS = ('ignore', 'select', 'convention')
7576

7677
DEFAULT_MATCH_RE = r'(?!test_).*\.py'
7778
DEFAULT_MATCH_DIR_RE = r'[^\.].*'
79+
DEFAULT_MATCH_PATH_RE = [r'[^\.].*']
7880
DEFAULT_IGNORE_DECORATORS_RE = ''
7981
DEFAULT_CONVENTION = conventions.pep257
8082

@@ -149,6 +151,13 @@ def _get_matches(conf):
149151
match_dir_func = re(conf.match_dir + '$').match
150152
return match_func, match_dir_func
151153

154+
def _get_path_matches(conf):
155+
"""Return a list of `match_path` regexes."""
156+
matches = conf.match_path
157+
if isinstance(matches, str):
158+
matches = matches.split()
159+
return [re(x) for x in matches]
160+
152161
def _get_ignore_decorators(conf):
153162
"""Return the `ignore_decorators` as None or regex."""
154163
return (
@@ -160,14 +169,22 @@ def _get_ignore_decorators(conf):
160169
for root, dirs, filenames in os.walk(name):
161170
config = self._get_config(os.path.abspath(root))
162171
match, match_dir = _get_matches(config)
172+
match_paths = _get_path_matches(config)
163173
ignore_decorators = _get_ignore_decorators(config)
164174

165175
# Skip any dirs that do not match match_dir
166176
dirs[:] = [d for d in dirs if match_dir(d)]
167177

168178
for filename in filenames:
179+
full_path = os.path.join(root, filename)
180+
relative_posix = os.path.normpath(
181+
os.path.relpath(full_path, start=name)
182+
).replace(os.path.sep, "/")
183+
if not any(
184+
x.match(relative_posix) for x in match_paths
185+
):
186+
continue
169187
if match(filename):
170-
full_path = os.path.join(root, filename)
171188
yield (
172189
full_path,
173190
list(config.checked_codes),
@@ -176,7 +193,11 @@ def _get_ignore_decorators(conf):
176193
else:
177194
config = self._get_config(os.path.abspath(name))
178195
match, _ = _get_matches(config)
196+
match_paths = _get_path_matches(config)
179197
ignore_decorators = _get_ignore_decorators(config)
198+
posix = os.path.normpath(name).replace(os.path.sep, "/")
199+
if not any(x.match(posix) for x in match_paths):
200+
continue
180201
if match(name):
181202
yield (name, list(config.checked_codes), ignore_decorators)
182203

@@ -283,7 +304,6 @@ def _get_config(self, node):
283304
cli_val = getattr(self._override_by_cli, attr)
284305
conf_val = getattr(config, attr)
285306
final_config[attr] = cli_val if cli_val is not None else conf_val
286-
287307
config = CheckConfiguration(**final_config)
288308

289309
self._set_add_options(config.checked_codes, self._options)
@@ -371,7 +391,7 @@ def _merge_configuration(self, parent_config, child_options):
371391
self._set_add_options(error_codes, child_options)
372392

373393
kwargs = dict(checked_codes=error_codes)
374-
for key in ('match', 'match_dir', 'ignore_decorators'):
394+
for key in ('match', 'match_dir', 'match_path', 'ignore_decorators'):
375395
kwargs[key] = getattr(child_options, key) or getattr(
376396
parent_config, key
377397
)
@@ -405,7 +425,7 @@ def _create_check_config(cls, options, use_defaults=True):
405425
checked_codes = cls._get_checked_errors(options)
406426

407427
kwargs = dict(checked_codes=checked_codes)
408-
for key in ('match', 'match_dir', 'ignore_decorators'):
428+
for key in ('match', 'match_dir', 'match_path', 'ignore_decorators'):
409429
kwargs[key] = (
410430
getattr(cls, f'DEFAULT_{key.upper()}_RE')
411431
if getattr(options, key) is None and use_defaults
@@ -721,6 +741,16 @@ def _create_option_parser(cls):
721741
"a dot"
722742
).format(cls.DEFAULT_MATCH_DIR_RE),
723743
)
744+
option(
745+
'--match-path',
746+
metavar='<pattern>',
747+
default=None,
748+
nargs="+",
749+
help=(
750+
"search only paths that exactly match <pattern> regular "
751+
"expressions. Can take multiple values."
752+
),
753+
)
724754

725755
# Decorators
726756
option(
@@ -743,7 +773,7 @@ def _create_option_parser(cls):
743773
# Check configuration - used by the ConfigurationParser class.
744774
CheckConfiguration = namedtuple(
745775
'CheckConfiguration',
746-
('checked_codes', 'match', 'match_dir', 'ignore_decorators'),
776+
('checked_codes', 'match', 'match_dir', 'match_path', 'ignore_decorators'),
747777
)
748778

749779

Diff for: src/tests/test_integration.py

+63
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,69 @@ def foo():
13081308
assert code == 0
13091309

13101310

1311+
def test_config_file_nearest_match_path(env):
1312+
r"""Test that the `match-path` option is handled correctly.
1313+
1314+
env_base
1315+
+-- tox.ini
1316+
| This configuration will set `convention=pep257` and
1317+
| `match_path=A/[BC]/[bc]\.py\n A/D/bla.py`.
1318+
+-- A
1319+
+-- B
1320+
| +-- b.py
1321+
| Will violate D100,D103.
1322+
+-- C
1323+
| +-- c.py
1324+
| | Will violate D100,D103.
1325+
| +-- bla.py
1326+
| Will violate D100.
1327+
+-- D
1328+
+-- c.py
1329+
| Will violate D100,D103.
1330+
+-- bla.py
1331+
Will violate D100.
1332+
1333+
We expect the call to pydocstyle to fail, and since we run with verbose the
1334+
output should contain `A/B/b.py`, `A/C/c.py` and `A/D/bla.py` but not the
1335+
others.
1336+
"""
1337+
env.write_config(convention='pep257')
1338+
env.write_config(match_path='A/[BC]/[bc]\.py\n A/D/bla.py')
1339+
1340+
content = textwrap.dedent("""\
1341+
def foo():
1342+
pass
1343+
""")
1344+
1345+
env.makedirs(os.path.join('A', 'B'))
1346+
env.makedirs(os.path.join('A', 'C'))
1347+
env.makedirs(os.path.join('A', 'D'))
1348+
with env.open(os.path.join('A', 'B', 'b.py'), 'wt') as test:
1349+
test.write(content)
1350+
1351+
with env.open(os.path.join('A', 'C', 'c.py'), 'wt') as test:
1352+
test.write(content)
1353+
1354+
with env.open(os.path.join('A', 'C', 'bla.py'), 'wt') as test:
1355+
test.write('')
1356+
1357+
with env.open(os.path.join('A', 'D', 'c.py'), 'wt') as test:
1358+
test.write(content)
1359+
1360+
with env.open(os.path.join('A', 'D', 'bla.py'), 'wt') as test:
1361+
test.write('')
1362+
1363+
out, _, code = env.invoke(args="--verbose")
1364+
1365+
assert os.path.join("A", "B", "b.py") in out
1366+
assert os.path.join("A", "C", "c.py") in out
1367+
assert os.path.join("A", "C", "bla.py") not in out
1368+
assert os.path.join("A", "D", "c.py") not in out
1369+
assert os.path.join("A", "D", "bla.py") in out
1370+
1371+
assert code == 1
1372+
1373+
13111374
def test_syntax_error_multiple_files(env):
13121375
"""Test that a syntax error in a file doesn't prevent further checking."""
13131376
for filename in ('first.py', 'second.py'):

0 commit comments

Comments
 (0)