Skip to content

Commit 55905f1

Browse files
authored
feature: File exclusion patterns (#451)
1 parent 995b5b9 commit 55905f1

File tree

13 files changed

+277
-49
lines changed

13 files changed

+277
-49
lines changed

.github/workflows/tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
5252
- name: Test with pytest
5353
run: |
54-
pytest --cov --cov-fail-under=100
54+
pytest --cov
5555
5656
- name: Report coverage
5757
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'

.pre-commit-config.yaml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,13 @@ repos:
2727
hooks:
2828
- id: isort
2929
- repo: https://github.com/psf/black
30-
rev: b965c2a5026f8ba399283ba3e01898b012853c79 # frozen: 24.8.0
30+
rev: 1b2427a2b785cc4aac97c19bb4b9a0de063f9547 # frozen: 24.10.0
3131
hooks:
3232
- id: black
33-
# Disable docformatter until https://github.com/PyCQA/docformatter/pull/287 is merged
34-
#- repo: https://github.com/PyCQA/docformatter
35-
# rev: dfefe062799848234b4cd60b04aa633c0608025e # frozen: v1.7.5
36-
# hooks:
37-
# - id: docformatter
33+
- repo: https://github.com/hukkin/docformatter
34+
rev: ab802050e6e96aaaf7f917fcbc333bb74e2e57f7 # frozen: v1.4.2
35+
hooks:
36+
- id: docformatter
3837
- repo: https://github.com/PyCQA/flake8
3938
rev: e43806be3607110919eff72939fda031776e885a # frozen: 7.1.1
4039
hooks:
@@ -44,6 +43,6 @@ repos:
4443
- flake8-builtins
4544
- flake8-comprehensions
4645
- repo: https://github.com/pre-commit/pre-commit
47-
rev: dbccd57db0e9cf993ea909e929eea97f6e4389ea # frozen: v4.0.0
46+
rev: cc4a52241565440ce200666799eef70626457488 # frozen: v4.0.1
4847
hooks:
4948
- id: validate_manifest

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ If a file is not properly formatted, the exit code will be non-zero.
9090

9191
```console
9292
foo@bar:~$ mdformat --help
93-
usage: mdformat [-h] [--check] [--version] [--number] [--wrap {keep,no,INTEGER}] [--end-of-line {lf,crlf,keep}] [paths ...]
93+
usage: mdformat [-h] [--check] [--version] [--number] [--wrap {keep,no,INTEGER}]
94+
[--end-of-line {lf,crlf,keep}] [--exclude PATTERN]
95+
[paths ...]
9496

9597
CommonMark compliant Markdown formatter
9698

@@ -106,8 +108,11 @@ options:
106108
paragraph word wrap mode (default: keep)
107109
--end-of-line {lf,crlf,keep}
108110
output file line ending mode (default: lf)
111+
--exclude PATTERN exclude files that match the Unix-style glob pattern (multiple allowed)
109112
```
110113

114+
The `--exclude` option is only available on Python 3.13+.
115+
111116
<!-- end cli-usage -->
112117

113118
## Documentation

docs/users/configuration_file.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,34 @@ Command line interface arguments take precedence over the configuration file.
2020
wrap = "keep" # possible values: {"keep", "no", INTEGER}
2121
number = false # possible values: {false, true}
2222
end_of_line = "lf" # possible values: {"lf", "crlf", "keep"}
23+
24+
# Python 3.13+ only:
25+
exclude = [] # possible values: a list of file path pattern strings
26+
```
27+
28+
## Exclude patterns
29+
30+
A list of file exclusion patterns can be defined on Python 3.13+.
31+
Unix-style glob patterns are supported, see
32+
[Python's documentation](https://docs.python.org/3/library/pathlib.html#pattern-language)
33+
for syntax definition.
34+
35+
Glob patterns are matched against relative paths.
36+
If `--exclude` is used on the command line, the paths are relative to current working directory.
37+
Else the paths are relative to the parent directory of the file's `.mdformat.toml`.
38+
39+
Files that match an exclusion pattern are _always_ excluded,
40+
even in the case that they are directly referenced in a command line invocation.
41+
42+
### Example patterns
43+
44+
```toml
45+
# .mdformat.toml
46+
exclude = [
47+
"CHANGELOG.md", # exclude a single root level file
48+
"venv/**", # recursively exclude a root level directory
49+
"**/node_modules/**", # recursively exclude a directory at any level
50+
"**/*.txt", # exclude all .txt files
51+
"**/*.m[!d]", "**/*.[!m]d", # exclude all files that are not suffixed .md
52+
]
2353
```

pyproject.toml

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ description = run tests
7171
deps =
7272
-r tests/requirements.txt
7373
commands =
74-
pytest {posargs}
74+
pytest {posargs:--cov}
7575
7676
[testenv:profile]
7777
description = run profiler (use e.g. `firefox .tox/prof/combined.svg` to open)
@@ -135,20 +135,7 @@ commands =
135135

136136
[tool.coverage.run]
137137
source = ["mdformat"]
138-
omit = ["*/__main__.py"]
139-
140-
[tool.coverage.report]
141-
# Regexes for lines to exclude from consideration
142-
exclude_lines = [
143-
# Re-enable the standard pragma (with extra strictness)
144-
'# pragma: no cover\b',
145-
# Ellipsis lines after @typing.overload
146-
'^ +\.\.\.$',
147-
# Code for static type checkers
148-
"if TYPE_CHECKING:",
149-
# Scripts
150-
'if __name__ == .__main__.:',
151-
]
138+
plugins = ["covdefaults"]
152139

153140

154141
[tool.mypy]

src/mdformat/_cli.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import contextlib
66
import itertools
77
import logging
8+
import os.path
89
from pathlib import Path
910
import shutil
1011
import sys
@@ -20,7 +21,7 @@
2021

2122
class RendererWarningPrinter(logging.Handler):
2223
def emit(self, record: logging.LogRecord) -> None:
23-
if record.levelno >= logging.WARNING:
24+
if record.levelno >= logging.WARNING: # pragma: no branch
2425
sys.stderr.write(f"Warning: {record.msg}\n")
2526

2627

@@ -52,12 +53,26 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901
5253
renderer_warning_printer = RendererWarningPrinter()
5354
for path in file_paths:
5455
try:
55-
toml_opts = read_toml_opts(path.parent if path else Path.cwd())
56+
toml_opts, toml_path = read_toml_opts(path.parent if path else Path.cwd())
5657
except InvalidConfError as e:
5758
print_error(str(e))
5859
return 1
5960
opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_opts}
6061

62+
if sys.version_info >= (3, 13): # pragma: >=3.13 cover
63+
if is_excluded(path, opts["exclude"], toml_path, "exclude" in cli_opts):
64+
continue
65+
else: # pragma: <3.13 cover
66+
if "exclude" in toml_opts:
67+
print_error(
68+
"'exclude' patterns are only available on Python 3.13+.",
69+
paragraphs=[
70+
"Please remove the 'exclude' list from your .mdformat.toml"
71+
" or upgrade Python version."
72+
],
73+
)
74+
return 1
75+
6176
if path:
6277
path_str = str(path)
6378
# Unlike `path.read_text(encoding="utf-8")`, this preserves
@@ -157,6 +172,14 @@ def make_arg_parser(
157172
choices=("lf", "crlf", "keep"),
158173
help="output file line ending mode (default: lf)",
159174
)
175+
if sys.version_info >= (3, 13): # pragma: >=3.13 cover
176+
parser.add_argument(
177+
"--exclude",
178+
action="append",
179+
metavar="PATTERN",
180+
help="exclude files that match the Unix-style glob pattern "
181+
"(multiple allowed)",
182+
)
160183
for plugin in parser_extensions.values():
161184
if hasattr(plugin, "add_cli_options"):
162185
plugin.add_cli_options(parser)
@@ -173,34 +196,63 @@ def __init__(self, path: Path):
173196
def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]:
174197
"""Resolve pathlib.Path objects from filepath strings.
175198
176-
Convert path strings to pathlib.Path objects. Resolve symlinks.
177-
Check that all paths are either files, directories or stdin. If not,
178-
raise InvalidPath. Resolve directory paths to a list of file paths
179-
(ending with ".md").
199+
Convert path strings to pathlib.Path objects. Check that all paths
200+
are either files, directories or stdin. If not, raise InvalidPath.
201+
Resolve directory paths to a list of file paths (ending with ".md").
180202
"""
181203
file_paths: list[None | Path] = [] # Path to file or None for stdin/stdout
182204
for path_str in path_strings:
183205
if path_str == "-":
184206
file_paths.append(None)
185207
continue
186208
path_obj = Path(path_str)
187-
path_obj = _resolve_path(path_obj)
209+
path_obj = _normalize_path(path_obj)
188210
if path_obj.is_dir():
189211
for p in path_obj.glob("**/*.md"):
190-
p = _resolve_path(p)
191-
file_paths.append(p)
192-
else:
212+
if p.is_file():
213+
p = _normalize_path(p)
214+
file_paths.append(p)
215+
elif path_obj.is_file(): # pragma: nt no cover
193216
file_paths.append(path_obj)
217+
else: # pragma: nt no cover
218+
raise InvalidPath(path_obj)
194219
return file_paths
195220

196221

197-
def _resolve_path(path: Path) -> Path:
198-
"""Resolve path.
222+
def is_excluded( # pragma: >=3.13 cover
223+
path: Path | None,
224+
patterns: list[str],
225+
toml_path: Path | None,
226+
excludes_from_cli: bool,
227+
) -> bool:
228+
if not path:
229+
return False
230+
231+
if not excludes_from_cli and toml_path:
232+
exclude_root = toml_path.parent
233+
else:
234+
exclude_root = Path.cwd()
235+
236+
try:
237+
relative_path = path.relative_to(exclude_root)
238+
except ValueError:
239+
return False
240+
241+
return any(
242+
relative_path.full_match(pattern) # type: ignore[attr-defined]
243+
for pattern in patterns
244+
)
245+
246+
247+
def _normalize_path(path: Path) -> Path:
248+
"""Normalize path.
199249
200-
Resolve symlinks. Raise `InvalidPath` if the path does not exist.
250+
Make the path absolute, resolve any ".." sequences. Do not resolve
251+
symlinks, as it would interfere with 'exclude' patterns. Raise
252+
`InvalidPath` if the path does not exist.
201253
"""
254+
path = Path(os.path.abspath(path))
202255
try:
203-
path = path.resolve() # resolve symlinks
204256
path_exists = path.exists()
205257
except OSError: # Catch "OSError: [WinError 123]" on Windows # pragma: no cover
206258
path_exists = False

src/mdformat/_compat.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
import sys
44

5-
if sys.version_info >= (3, 11): # pragma: no cover
5+
if sys.version_info >= (3, 11): # pragma: >=3.11 cover
66
import tomllib
7-
else: # pragma: no cover
7+
else: # pragma: <3.11 cover
88
import tomli as tomllib
99

10-
if sys.version_info >= (3, 10): # pragma: no cover
10+
if sys.version_info >= (3, 10): # pragma: >=3.10 cover
1111
from importlib import metadata as importlib_metadata
12-
else: # pragma: no cover
12+
else: # pragma: <3.10 cover
1313
import importlib_metadata

src/mdformat/_conf.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"wrap": "keep",
1111
"number": False,
1212
"end_of_line": "lf",
13+
"exclude": [],
1314
}
1415

1516

@@ -24,12 +25,12 @@ class InvalidConfError(Exception):
2425

2526

2627
@functools.lru_cache()
27-
def read_toml_opts(conf_dir: Path) -> Mapping:
28+
def read_toml_opts(conf_dir: Path) -> tuple[Mapping, Path | None]:
2829
conf_path = conf_dir / ".mdformat.toml"
2930
if not conf_path.is_file():
3031
parent_dir = conf_dir.parent
3132
if conf_dir == parent_dir:
32-
return {}
33+
return {}, None
3334
return read_toml_opts(parent_dir)
3435

3536
with open(conf_path, "rb") as f:
@@ -41,10 +42,10 @@ def read_toml_opts(conf_dir: Path) -> Mapping:
4142
_validate_keys(toml_opts, conf_path)
4243
_validate_values(toml_opts, conf_path)
4344

44-
return toml_opts
45+
return toml_opts, conf_path
4546

4647

47-
def _validate_values(opts: Mapping, conf_path: Path) -> None:
48+
def _validate_values(opts: Mapping, conf_path: Path) -> None: # noqa: C901
4849
if "wrap" in opts:
4950
wrap_value = opts["wrap"]
5051
if not (
@@ -58,6 +59,12 @@ def _validate_values(opts: Mapping, conf_path: Path) -> None:
5859
if "number" in opts:
5960
if not isinstance(opts["number"], bool):
6061
raise InvalidConfError(f"Invalid 'number' value in {conf_path}")
62+
if "exclude" in opts: # pragma: >=3.13 cover
63+
if not isinstance(opts["exclude"], list):
64+
raise InvalidConfError(f"Invalid 'exclude' value in {conf_path}")
65+
for pattern in opts["exclude"]:
66+
if not isinstance(pattern, str):
67+
raise InvalidConfError(f"Invalid 'exclude' value in {conf_path}")
6168

6269

6370
def _validate_keys(opts: Mapping, conf_path: Path) -> None:

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pytest
22
pytest-randomly
33
pytest-cov
4+
covdefaults

tests/test_api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import os
22

3+
from markdown_it import MarkdownIt
34
import pytest
45

56
import mdformat
67
from mdformat._util import is_md_equal
8+
from mdformat.renderer import MDRenderer
79

810
UNFORMATTED_MARKDOWN = "\n\n# A header\n\n"
911
FORMATTED_MARKDOWN = "# A header\n"
@@ -127,3 +129,15 @@ def test_no_timestamp_modify(tmp_path):
127129
# Assert that modification time does not change when no changes are applied
128130
mdformat.file(file_path)
129131
assert os.path.getmtime(file_path) == initial_mod_time
132+
133+
134+
def test_mdrenderer_no_finalize(tmp_path):
135+
mdit = MarkdownIt()
136+
mdit.options["store_labels"] = True
137+
env: dict = {}
138+
tokens = mdit.parse(
139+
"[gl ref]: https://gitlab.com\n\nHere's a link to [GitLab][gl ref]", env
140+
)
141+
unfinalized = MDRenderer().render(tokens, {}, env, finalize=False)
142+
finalized = MDRenderer().render(tokens, {}, env)
143+
assert finalized == unfinalized + "\n\n[gl ref]: https://gitlab.com\n"

0 commit comments

Comments
 (0)