Skip to content

Commit

Permalink
feature: File exclusion patterns (#451)
Browse files Browse the repository at this point in the history
  • Loading branch information
hukkin authored Oct 14, 2024
1 parent 995b5b9 commit 55905f1
Show file tree
Hide file tree
Showing 13 changed files with 277 additions and 49 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
- name: Test with pytest
run: |
pytest --cov --cov-fail-under=100
pytest --cov
- name: Report coverage
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
Expand Down
13 changes: 6 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,13 @@ repos:
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: b965c2a5026f8ba399283ba3e01898b012853c79 # frozen: 24.8.0
rev: 1b2427a2b785cc4aac97c19bb4b9a0de063f9547 # frozen: 24.10.0
hooks:
- id: black
# Disable docformatter until https://github.com/PyCQA/docformatter/pull/287 is merged
#- repo: https://github.com/PyCQA/docformatter
# rev: dfefe062799848234b4cd60b04aa633c0608025e # frozen: v1.7.5
# hooks:
# - id: docformatter
- repo: https://github.com/hukkin/docformatter
rev: ab802050e6e96aaaf7f917fcbc333bb74e2e57f7 # frozen: v1.4.2
hooks:
- id: docformatter
- repo: https://github.com/PyCQA/flake8
rev: e43806be3607110919eff72939fda031776e885a # frozen: 7.1.1
hooks:
Expand All @@ -44,6 +43,6 @@ repos:
- flake8-builtins
- flake8-comprehensions
- repo: https://github.com/pre-commit/pre-commit
rev: dbccd57db0e9cf993ea909e929eea97f6e4389ea # frozen: v4.0.0
rev: cc4a52241565440ce200666799eef70626457488 # frozen: v4.0.1
hooks:
- id: validate_manifest
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ If a file is not properly formatted, the exit code will be non-zero.

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

CommonMark compliant Markdown formatter

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

The `--exclude` option is only available on Python 3.13+.

<!-- end cli-usage -->

## Documentation
Expand Down
30 changes: 30 additions & 0 deletions docs/users/configuration_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,34 @@ Command line interface arguments take precedence over the configuration file.
wrap = "keep" # possible values: {"keep", "no", INTEGER}
number = false # possible values: {false, true}
end_of_line = "lf" # possible values: {"lf", "crlf", "keep"}

# Python 3.13+ only:
exclude = [] # possible values: a list of file path pattern strings
```

## Exclude patterns

A list of file exclusion patterns can be defined on Python 3.13+.
Unix-style glob patterns are supported, see
[Python's documentation](https://docs.python.org/3/library/pathlib.html#pattern-language)
for syntax definition.

Glob patterns are matched against relative paths.
If `--exclude` is used on the command line, the paths are relative to current working directory.
Else the paths are relative to the parent directory of the file's `.mdformat.toml`.

Files that match an exclusion pattern are _always_ excluded,
even in the case that they are directly referenced in a command line invocation.

### Example patterns

```toml
# .mdformat.toml
exclude = [
"CHANGELOG.md", # exclude a single root level file
"venv/**", # recursively exclude a root level directory
"**/node_modules/**", # recursively exclude a directory at any level
"**/*.txt", # exclude all .txt files
"**/*.m[!d]", "**/*.[!m]d", # exclude all files that are not suffixed .md
]
```
17 changes: 2 additions & 15 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ description = run tests
deps =
-r tests/requirements.txt
commands =
pytest {posargs}
pytest {posargs:--cov}
[testenv:profile]
description = run profiler (use e.g. `firefox .tox/prof/combined.svg` to open)
Expand Down Expand Up @@ -135,20 +135,7 @@ commands =

[tool.coverage.run]
source = ["mdformat"]
omit = ["*/__main__.py"]

[tool.coverage.report]
# Regexes for lines to exclude from consideration
exclude_lines = [
# Re-enable the standard pragma (with extra strictness)
'# pragma: no cover\b',
# Ellipsis lines after @typing.overload
'^ +\.\.\.$',
# Code for static type checkers
"if TYPE_CHECKING:",
# Scripts
'if __name__ == .__main__.:',
]
plugins = ["covdefaults"]


[tool.mypy]
Expand Down
80 changes: 66 additions & 14 deletions src/mdformat/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import contextlib
import itertools
import logging
import os.path
from pathlib import Path
import shutil
import sys
Expand All @@ -20,7 +21,7 @@

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


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

if sys.version_info >= (3, 13): # pragma: >=3.13 cover
if is_excluded(path, opts["exclude"], toml_path, "exclude" in cli_opts):
continue
else: # pragma: <3.13 cover
if "exclude" in toml_opts:
print_error(
"'exclude' patterns are only available on Python 3.13+.",
paragraphs=[
"Please remove the 'exclude' list from your .mdformat.toml"
" or upgrade Python version."
],
)
return 1

if path:
path_str = str(path)
# Unlike `path.read_text(encoding="utf-8")`, this preserves
Expand Down Expand Up @@ -157,6 +172,14 @@ def make_arg_parser(
choices=("lf", "crlf", "keep"),
help="output file line ending mode (default: lf)",
)
if sys.version_info >= (3, 13): # pragma: >=3.13 cover
parser.add_argument(
"--exclude",
action="append",
metavar="PATTERN",
help="exclude files that match the Unix-style glob pattern "
"(multiple allowed)",
)
for plugin in parser_extensions.values():
if hasattr(plugin, "add_cli_options"):
plugin.add_cli_options(parser)
Expand All @@ -173,34 +196,63 @@ def __init__(self, path: Path):
def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]:
"""Resolve pathlib.Path objects from filepath strings.
Convert path strings to pathlib.Path objects. Resolve symlinks.
Check that all paths are either files, directories or stdin. If not,
raise InvalidPath. Resolve directory paths to a list of file paths
(ending with ".md").
Convert path strings to pathlib.Path objects. Check that all paths
are either files, directories or stdin. If not, raise InvalidPath.
Resolve directory paths to a list of file paths (ending with ".md").
"""
file_paths: list[None | Path] = [] # Path to file or None for stdin/stdout
for path_str in path_strings:
if path_str == "-":
file_paths.append(None)
continue
path_obj = Path(path_str)
path_obj = _resolve_path(path_obj)
path_obj = _normalize_path(path_obj)
if path_obj.is_dir():
for p in path_obj.glob("**/*.md"):
p = _resolve_path(p)
file_paths.append(p)
else:
if p.is_file():
p = _normalize_path(p)
file_paths.append(p)
elif path_obj.is_file(): # pragma: nt no cover
file_paths.append(path_obj)
else: # pragma: nt no cover
raise InvalidPath(path_obj)
return file_paths


def _resolve_path(path: Path) -> Path:
"""Resolve path.
def is_excluded( # pragma: >=3.13 cover
path: Path | None,
patterns: list[str],
toml_path: Path | None,
excludes_from_cli: bool,
) -> bool:
if not path:
return False

if not excludes_from_cli and toml_path:
exclude_root = toml_path.parent
else:
exclude_root = Path.cwd()

try:
relative_path = path.relative_to(exclude_root)
except ValueError:
return False

return any(
relative_path.full_match(pattern) # type: ignore[attr-defined]
for pattern in patterns
)


def _normalize_path(path: Path) -> Path:
"""Normalize path.
Resolve symlinks. Raise `InvalidPath` if the path does not exist.
Make the path absolute, resolve any ".." sequences. Do not resolve
symlinks, as it would interfere with 'exclude' patterns. Raise
`InvalidPath` if the path does not exist.
"""
path = Path(os.path.abspath(path))
try:
path = path.resolve() # resolve symlinks
path_exists = path.exists()
except OSError: # Catch "OSError: [WinError 123]" on Windows # pragma: no cover
path_exists = False
Expand Down
8 changes: 4 additions & 4 deletions src/mdformat/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import sys

if sys.version_info >= (3, 11): # pragma: no cover
if sys.version_info >= (3, 11): # pragma: >=3.11 cover
import tomllib
else: # pragma: no cover
else: # pragma: <3.11 cover
import tomli as tomllib

if sys.version_info >= (3, 10): # pragma: no cover
if sys.version_info >= (3, 10): # pragma: >=3.10 cover
from importlib import metadata as importlib_metadata
else: # pragma: no cover
else: # pragma: <3.10 cover
import importlib_metadata
15 changes: 11 additions & 4 deletions src/mdformat/_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"wrap": "keep",
"number": False,
"end_of_line": "lf",
"exclude": [],
}


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


@functools.lru_cache()
def read_toml_opts(conf_dir: Path) -> Mapping:
def read_toml_opts(conf_dir: Path) -> tuple[Mapping, Path | None]:
conf_path = conf_dir / ".mdformat.toml"
if not conf_path.is_file():
parent_dir = conf_dir.parent
if conf_dir == parent_dir:
return {}
return {}, None
return read_toml_opts(parent_dir)

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

return toml_opts
return toml_opts, conf_path


def _validate_values(opts: Mapping, conf_path: Path) -> None:
def _validate_values(opts: Mapping, conf_path: Path) -> None: # noqa: C901
if "wrap" in opts:
wrap_value = opts["wrap"]
if not (
Expand All @@ -58,6 +59,12 @@ def _validate_values(opts: Mapping, conf_path: Path) -> None:
if "number" in opts:
if not isinstance(opts["number"], bool):
raise InvalidConfError(f"Invalid 'number' value in {conf_path}")
if "exclude" in opts: # pragma: >=3.13 cover
if not isinstance(opts["exclude"], list):
raise InvalidConfError(f"Invalid 'exclude' value in {conf_path}")
for pattern in opts["exclude"]:
if not isinstance(pattern, str):
raise InvalidConfError(f"Invalid 'exclude' value in {conf_path}")


def _validate_keys(opts: Mapping, conf_path: Path) -> None:
Expand Down
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pytest
pytest-randomly
pytest-cov
covdefaults
14 changes: 14 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os

from markdown_it import MarkdownIt
import pytest

import mdformat
from mdformat._util import is_md_equal
from mdformat.renderer import MDRenderer

UNFORMATTED_MARKDOWN = "\n\n# A header\n\n"
FORMATTED_MARKDOWN = "# A header\n"
Expand Down Expand Up @@ -127,3 +129,15 @@ def test_no_timestamp_modify(tmp_path):
# Assert that modification time does not change when no changes are applied
mdformat.file(file_path)
assert os.path.getmtime(file_path) == initial_mod_time


def test_mdrenderer_no_finalize(tmp_path):
mdit = MarkdownIt()
mdit.options["store_labels"] = True
env: dict = {}
tokens = mdit.parse(
"[gl ref]: https://gitlab.com\n\nHere's a link to [GitLab][gl ref]", env
)
unfinalized = MDRenderer().render(tokens, {}, env, finalize=False)
finalized = MDRenderer().render(tokens, {}, env)
assert finalized == unfinalized + "\n\n[gl ref]: https://gitlab.com\n"
Loading

0 comments on commit 55905f1

Please sign in to comment.