Skip to content
This repository was archived by the owner on Oct 8, 2024. It is now read-only.

Commit b21baba

Browse files
author
Igor T. Ghisi
authored
Merge pull request #45 from ESSS/allow-ignore-patterns
Allow exclude files and directories from formatting
2 parents 63f84bd + a344340 commit b21baba

File tree

6 files changed

+150
-28
lines changed

6 files changed

+150
-28
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
History
33
=======
44

5+
2.1.0
6+
------------------
7+
8+
* ``fix-format`` now allows exclude patterns to be configured through ``pyproject.toml``.
9+
510
2.0.1 (2019-06-26)
611
------------------
712

README.rst

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,38 @@ Use ``fix-format`` (or ``ff`` for short) to reorder imports and format source co
5353

5454
.. _black:
5555

56+
Options
57+
-------
58+
59+
Options for ``fix-format`` are defined in the section ``[tool.esss_fix_format]]`` of a ``pyproject.toml`` file. The
60+
TOML file should be placed in an ancestor directory of the filenames passed on the command-line.
61+
62+
63+
Exclude
64+
^^^^^^^
65+
66+
A list of file name patterns to be excluded from the formatting. Patterns are matched using python ``fnmatch``:
67+
68+
.. code-block:: toml
69+
70+
[tool.esss_fix_format]
71+
exclude = [
72+
"src/generated/*.py",
73+
"tmp/*",
74+
]
75+
76+
5677
Black
5778
^^^^^
5879

5980
Since version ``2.0.0`` it is possible to use `black <https://github.com/python/black>`__ as the
6081
code formatter for Python code.
6182

62-
``fix-format`` will use ``black`` automatically if it finds a ``pyproject.toml`` with a ``[tool.black]`` section in an
63-
ancestor directories of the filenames passed on the command-line.
83+
``fix-format`` will use ``black`` automatically if it finds a ``[tool.black]`` section declared in ``pyproject.toml``
84+
file.
6485

6586
See "Converting master to black" below for details.
6687

67-
6888
Migrating a project to use fix-format
6989
-------------------------------------
7090

environment.devenv.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies:
99
- isort>=4.3.4
1010
- python={{ '.'.join(CONDA_PY) }}
1111
- pydevf==0.1.5
12+
- toml>=0.8.0
1213

1314
# develop
1415
- black

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
'click>=6.0',
1212
'isort',
1313
'pydevf==0.1.5',
14+
'toml>=0.8.0',
1415
]
1516

1617
setup(

src/esss_fix_format/cli.py

100644100755
Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
#!python
12
import codecs
23
import io
34
import os
45
import re
56
import subprocess
67
import sys
7-
from typing import Optional, Tuple
8+
from pathlib import Path
9+
from typing import Optional, Tuple, Iterable, List
810

911
import boltons.iterutils
1012
import click
1113
import pydevf
12-
from pathlib import Path
1314

1415
CPP_PATTERNS = {
1516
'*.cpp',
@@ -42,15 +43,27 @@ def is_cpp(filename):
4243
return any(fnmatch(os.path.basename(filename), p) for p in CPP_PATTERNS)
4344

4445

45-
def should_format(filename):
46+
def should_format(filename: str, include_patterns: Iterable[str], exclude_patterns: Iterable[str]):
4647
"""
47-
Return a tuple (fmt, reason) where fmt is True if the filename
48-
is of a type that is supported by this tool.
48+
Return a tuple (fmt, reason) where fmt is True if the filename should be formatted.
49+
50+
:param filename: file name to verify if should be formatted or not
51+
52+
:param include_patterns: list of file patterns to be included in the formatting
53+
54+
:param exclude_patterns: list of file patterns to be excluded from formatting. Has precedence
55+
over `include_patterns`
56+
57+
:rtype: Tuple[bool, str]
4958
"""
5059
from fnmatch import fnmatch
60+
61+
if any(fnmatch(filename, pattern) for pattern in exclude_patterns):
62+
return False, 'Excluded file'
63+
5164
filename_no_ext, ext = os.path.splitext(filename)
52-
ipynb_filename = filename_no_ext + '.ipynb'
5365
# ignore .py file that has a jupytext configured notebook with the same base name
66+
ipynb_filename = filename_no_ext + '.ipynb'
5467
if ext == '.py' and os.path.isfile(ipynb_filename):
5568
with open(ipynb_filename, 'rb') as f:
5669
if b'jupytext' not in f.read():
@@ -59,12 +72,14 @@ def should_format(filename):
5972
if b'jupytext:' not in f.read():
6073
return True, ''
6174
return False, 'Jupytext generated file'
62-
if any(fnmatch(os.path.basename(filename), p) for p in PATTERNS):
75+
76+
if any(fnmatch(os.path.basename(filename), pattern) for pattern in include_patterns):
6377
return True, ''
78+
6479
return False, 'Unknown file type'
6580

6681

67-
def find_black_config(files_or_directories) -> Optional[Path]:
82+
def find_pyproject_toml(files_or_directories) -> Optional[Path]:
6883
"""
6984
Searches for a valid pyproject.toml file based on the list of files/directories given.
7085
@@ -76,11 +91,35 @@ def find_black_config(files_or_directories) -> Optional[Path]:
7691
common = Path(os.path.commonpath(files_or_directories)).resolve()
7792
for p in ([common] + list(common.parents)):
7893
fn = p / 'pyproject.toml'
79-
if fn.is_file() and '[tool.black]' in fn.read_text(encoding='UTF-8'):
94+
if fn.is_file():
8095
return fn
8196
return None
8297

8398

99+
def read_exclude_patterns(pyproject_toml: Path) -> List[str]:
100+
import toml
101+
102+
toml_contents = toml.load(pyproject_toml)
103+
ff_options = toml_contents.get('tool', {}).get('esss_fix_format', {})
104+
excludes_option = ff_options.get('exclude', [])
105+
if not isinstance(excludes_option, list):
106+
raise TypeError(
107+
f"pyproject.toml excludes option must be a list, got {type(excludes_option)})"
108+
)
109+
110+
# Fix exclude paths based on cwd (exclude paths are defined relative to TOML file)
111+
cwd_relpath_from_toml = os.path.relpath(os.getcwd(), pyproject_toml.parent)
112+
if cwd_relpath_from_toml != '.':
113+
excludes_option = [p.replace(cwd_relpath_from_toml + '/', '', 1) for p in excludes_option]
114+
return excludes_option
115+
116+
117+
def has_black_config(pyproject_toml: Optional[Path]) -> bool:
118+
if pyproject_toml is None:
119+
return False
120+
return pyproject_toml.is_file() and '[tool.black]' in pyproject_toml.read_text(encoding='UTF-8')
121+
122+
84123
# caches which directories have the `.clang-format` file, *in or above it*, to avoid hitting the
85124
# disk too many times
86125
__HAS_DOT_CLANG_FORMAT = dict()
@@ -313,7 +352,7 @@ def _process_file(filename, check, format_code, *, verbose):
313352
return changed, errors, formatter
314353

315354

316-
def run_black_on_python_files(files, check, verbose) -> Tuple[bool, bool]:
355+
def run_black_on_python_files(files, check, exclude_patterns, verbose) -> Tuple[bool, bool]:
317356
"""
318357
Runs black on the given files (checking or formatting).
319358
@@ -326,7 +365,7 @@ def run_black_on_python_files(files, check, verbose) -> Tuple[bool, bool]:
326365
327366
:return: a pair (would_be_formatted, black_failed)
328367
"""
329-
py_files = [x for x in files if x.suffix == '.py' and should_format(str(x))[0]]
368+
py_files = [x for x in files if should_format(str(x), ['*.py'], exclude_patterns)[0]]
330369
black_failed = False
331370
would_be_formatted = False
332371
if py_files:
@@ -367,27 +406,35 @@ def _main(files_or_directories, check, stdin, commit, pydevf_format_func, *, ver
367406
for dirname in list(dirs):
368407
if dirname in SKIP_DIRS:
369408
dirs.remove(dirname)
370-
files.extend(os.path.join(root, n) for n in names if should_format(n))
409+
files.extend(
410+
os.path.join(root, n) for n in names if should_format(n, PATTERNS, [])
411+
)
371412
else:
372413
files.append(file_or_dir)
373414

374415
files = sorted(Path(x) for x in files)
375416
errors = []
376417

377-
black_config = find_black_config(files)
418+
pyproject_toml = find_pyproject_toml(files)
419+
if pyproject_toml:
420+
exclude_patterns = read_exclude_patterns(pyproject_toml)
421+
else:
422+
exclude_patterns = []
423+
378424
would_be_formatted = False
379-
if black_config:
425+
if has_black_config(pyproject_toml):
380426
# skip pydevf formatter
381427
pydevf_format_func = None
382-
would_be_formatted, black_failed = run_black_on_python_files(files, check, verbose)
428+
would_be_formatted, black_failed = \
429+
run_black_on_python_files(files, check, exclude_patterns, verbose)
383430
if black_failed:
384431
errors.append('Error formatting black (see console)')
385432

386433
changed_files = []
387434
analysed_files = []
388435
for filename in files:
389436
filename = str(filename)
390-
fmt, reason = should_format(filename)
437+
fmt, reason = should_format(filename, PATTERNS, exclude_patterns)
391438
if not fmt:
392439
if verbose:
393440
click.secho(click.format_filename(filename) + ': ' + reason, fg='white')

tests/test_esss_fix_format.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -636,28 +636,30 @@ def check_invalid_file(input_file, formatter=None):
636636
output.fnmatch_lines(str(input_file) + ': Failed' + _get_formatter_msg(formatter))
637637

638638

639-
def test_find_black_config(tmp_path, monkeypatch):
639+
def test_find_pyproject_toml(tmp_path, monkeypatch):
640640
(tmp_path / 'pA/p2/p3').mkdir(parents=True)
641641
(tmp_path / 'pA/p2/p3/foo.py').touch()
642642
(tmp_path / 'pA/p2/p3/pyproject.toml').touch()
643643
(tmp_path / 'pX/p9').mkdir(parents=True)
644644
(tmp_path / 'pX/p9/pyproject.toml').touch()
645645

646-
assert cli.find_black_config([tmp_path / 'pA/p2/p3/foo.py', tmp_path / 'pX/p9']) is None
647-
assert cli.find_black_config([tmp_path / 'pA/p2/p3']) is None
648-
assert cli.find_black_config([tmp_path]) is None
649-
assert cli.find_black_config([]) is None
646+
assert cli.find_pyproject_toml([tmp_path / 'pA/p2/p3/foo.py', tmp_path / 'pX/p9']) is None
647+
assert cli.find_pyproject_toml([tmp_path / 'pA/p2/p3'])
648+
assert cli.has_black_config(tmp_path / 'pA/p2/p3/pyproject.toml') is False
649+
assert cli.find_pyproject_toml([tmp_path]) is None
650+
assert cli.find_pyproject_toml([]) is None
650651

651652
(tmp_path / 'pX/p9/pyproject.toml').write_text('[tool.black]')
652-
assert cli.find_black_config([tmp_path / 'pA/p2/p3/foo.py', tmp_path / 'pX/p9']) is None
653-
assert cli.find_black_config([tmp_path / 'pX/p9']) == tmp_path / 'pX/p9/pyproject.toml'
653+
assert cli.find_pyproject_toml([tmp_path / 'pA/p2/p3/foo.py', tmp_path / 'pX/p9']) is None
654+
assert cli.find_pyproject_toml([tmp_path / 'pX/p9']) == tmp_path / 'pX/p9/pyproject.toml'
655+
assert cli.has_black_config(tmp_path / 'pX/p9/pyproject.toml') is True
654656

655657
root_toml = tmp_path / 'pyproject.toml'
656658
(root_toml).write_text('[tool.black]')
657-
assert cli.find_black_config([tmp_path / 'pA/p2/p3/foo.py', tmp_path / 'pX/p9']) == root_toml
659+
assert cli.find_pyproject_toml([tmp_path / 'pA/p2/p3/foo.py', tmp_path / 'pX/p9']) == root_toml
658660

659661
monkeypatch.chdir(str(tmp_path / 'pA/p2'))
660-
assert cli.find_black_config(['.']) == root_toml
662+
assert cli.find_pyproject_toml(['.']) == root_toml
661663

662664

663665
def test_black_integration(tmp_path, sort_cfg_to_tmpdir):
@@ -735,3 +737,49 @@ def test_black_operates_on_chunks(tmp_path, mocker, sort_cfg_to_tmpdir):
735737
])
736738
call_list = subprocess.call.call_args_list
737739
assert len(call_list) == 6 # 521 files in batches of 100
740+
741+
742+
def test_exclude_patterns(tmp_path, monkeypatch):
743+
config_content = '''[tool.esss_fix_format]
744+
exclude = [
745+
"src/drafts/*.py",
746+
"tmp/*",
747+
]
748+
'''
749+
750+
config_file = tmp_path / 'pyproject.toml'
751+
config_file.write_text(config_content)
752+
include_patterns = ['*.cpp', '*.py']
753+
exclude_patterns = cli.read_exclude_patterns(config_file)
754+
assert not cli.should_format('src/drafts/foo.py', include_patterns, exclude_patterns)[0]
755+
assert cli.should_format('src/drafts/foo.cpp', include_patterns, exclude_patterns)[0]
756+
assert not cli.should_format('tmp/foo.cpp', include_patterns, exclude_patterns)[0]
757+
assert cli.should_format('src/python/foo.py', include_patterns, exclude_patterns)[0]
758+
759+
760+
def test_invalid_exclude_patterns(tmp_path):
761+
config_content = '''[tool.esss_fix_format]
762+
exclude = "src/drafts/*.py"
763+
'''
764+
765+
config_file = tmp_path / 'pyproject.toml'
766+
config_file.write_text(config_content)
767+
pytest.raises(TypeError, cli.read_exclude_patterns, config_file)
768+
769+
770+
def test_exclude_patterns_relative_path_fix(tmp_path, monkeypatch):
771+
config_content = '''[tool.esss_fix_format]
772+
exclude = [
773+
"src/drafts/*.py",
774+
"tmp/*",
775+
]
776+
'''
777+
778+
config_file = tmp_path / 'pyproject.toml'
779+
run_dir = tmp_path / 'src'
780+
run_dir.mkdir()
781+
config_file.write_text(config_content)
782+
monkeypatch.chdir(run_dir)
783+
include_patterns = ['*.py']
784+
exclude_patterns = cli.read_exclude_patterns(config_file)
785+
assert not cli.should_format('drafts/foo.py', include_patterns, exclude_patterns)[0]

0 commit comments

Comments
 (0)