From 1f3031669524ba4ec7a2a7f3312764996e3f3859 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:21:41 +0300 Subject: [PATCH 01/10] Exclude patterns (no tests, no docs) --- src/mdformat/_cli.py | 66 ++++++++++++++++++++++++++++++++++++------- src/mdformat/_conf.py | 15 +++++++--- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index 9f7cb52..d079140 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -5,6 +5,7 @@ import contextlib import itertools import logging +import os.path from pathlib import Path import shutil import sys @@ -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): + if is_excluded(path, opts["exclude"], toml_path, "exclude" in cli_opts): + continue + else: + 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 @@ -157,6 +172,12 @@ def make_arg_parser( choices=("lf", "crlf", "keep"), help="output file line ending mode (default: lf)", ) + if sys.version_info >= (3, 13): + parser.add_argument( + "--exclude", + action="append", + help="exclude files that match the pattern (multiple allowed)", + ) for plugin in parser_extensions.values(): if hasattr(plugin, "add_cli_options"): plugin.add_cli_options(parser) @@ -173,7 +194,7 @@ 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. + 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"). @@ -184,23 +205,48 @@ def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]: 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(): file_paths.append(path_obj) + else: + raise InvalidPath(path_obj) return file_paths -def _resolve_path(path: Path) -> Path: - """Resolve path. +def is_excluded( + 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) 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 diff --git a/src/mdformat/_conf.py b/src/mdformat/_conf.py index b42f008..eabaf0e 100644 --- a/src/mdformat/_conf.py +++ b/src/mdformat/_conf.py @@ -10,6 +10,7 @@ "wrap": "keep", "number": False, "end_of_line": "lf", + "exclude": [], } @@ -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: @@ -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 ( @@ -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: + 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: From fec660d83d0592f7688d5ea445cbed2457b05eee Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 12 Oct 2024 15:22:25 +0000 Subject: [PATCH 02/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/mdformat/_cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index d079140..d7725e9 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -219,7 +219,10 @@ def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]: def is_excluded( - path: Path | None, patterns: list[str], toml_path: Path | None, excludes_from_cli: bool + path: Path | None, + patterns: list[str], + toml_path: Path | None, + excludes_from_cli: bool, ) -> bool: if not path: return False From 7e8069939b34f59ce6f487a5019723c8e52d23f6 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:31:07 +0300 Subject: [PATCH 03/10] Satisfy linters --- src/mdformat/_cli.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index d7725e9..e11b5a2 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -59,10 +59,10 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901 return 1 opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_opts} - if sys.version_info >= (3, 13): + if sys.version_info >= (3, 13): # pragma: no cover if is_excluded(path, opts["exclude"], toml_path, "exclude" in cli_opts): continue - else: + else: # pragma: no cover if "exclude" in toml_opts: print_error( "'exclude' patterns are only available on Python 3.13+.", @@ -172,7 +172,7 @@ def make_arg_parser( choices=("lf", "crlf", "keep"), help="output file line ending mode (default: lf)", ) - if sys.version_info >= (3, 13): + if sys.version_info >= (3, 13): # pragma: no cover parser.add_argument( "--exclude", action="append", @@ -237,7 +237,10 @@ def is_excluded( except ValueError: return False - return any(relative_path.full_match(pattern) for pattern in patterns) + return any( + relative_path.full_match(pattern) # type: ignore[attr-defined] + for pattern in patterns + ) def _normalize_path(path: Path) -> Path: From f52be10fd99c7cb0397ac28e448d7bfee63709a8 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Sat, 12 Oct 2024 20:02:55 +0300 Subject: [PATCH 04/10] Add a test --- tests/test_cli.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index a00acfc..4b51212 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -331,3 +331,39 @@ def test_no_timestamp_modify(tmp_path): # Assert that modification time does not change when no changes are applied assert run([str(file_path)]) == 0 assert os.path.getmtime(file_path) == initial_mod_time + + +@pytest.mark.skipif( + sys.version_info < (3, 13), reason="'exclude' only possible on 3.13+" +) +def test_exclude(tmp_path): + subdir_path_1 = tmp_path / "folder1" + subdir_path_2 = subdir_path_1 / "folder2" + file_path_1 = subdir_path_2 / "file1.md" + subdir_path_1.mkdir() + subdir_path_2.mkdir() + file_path_1.write_text(UNFORMATTED_MARKDOWN) + cwd = tmp_path + + with patch("mdformat._cli.Path.cwd", return_value=cwd): + for good_pattern in [ + "folder1/folder2/file1.md", + "**", + "**/*.md", + "**/file1.md", + "folder1/**", + ]: + assert run([str(file_path_1), "--exclude", good_pattern]) == 0 + assert file_path_1.read_text() == UNFORMATTED_MARKDOWN + + for bad_pattern in [ + "**file1.md", + "file1.md", + "folder1", + "*.md", + "*", + "folder1/*", + ]: + file_path_1.write_text(UNFORMATTED_MARKDOWN) + assert run([str(file_path_1), "--exclude", bad_pattern]) == 0 + assert file_path_1.read_text() == FORMATTED_MARKDOWN From 13ace6413178d0785ec7b5d3c4e70e72ffb14384 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Sun, 13 Oct 2024 22:04:23 +0300 Subject: [PATCH 05/10] Add tests --- .github/workflows/tests.yaml | 2 +- pyproject.toml | 17 ++--------- src/mdformat/_cli.py | 10 +++---- src/mdformat/_compat.py | 8 ++--- src/mdformat/_conf.py | 2 +- tests/requirements.txt | 1 + tests/test_api.py | 14 +++++++++ tests/test_cli.py | 29 ++++++++++++++++++ tests/test_config_file.py | 57 +++++++++++++++++++++++++++++++++++- tests/test_plugins.py | 16 ++++++++-- 10 files changed, 127 insertions(+), 29 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c9a94db..469219c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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' diff --git a/pyproject.toml b/pyproject.toml index 6685222..8593d09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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) @@ -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] diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index e11b5a2..8cfa34a 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -21,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") @@ -59,10 +59,10 @@ def run(cli_args: Sequence[str]) -> int: # noqa: C901 return 1 opts: Mapping = {**DEFAULT_OPTS, **toml_opts, **cli_opts} - if sys.version_info >= (3, 13): # pragma: no cover + 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: no cover + else: # pragma: <3.13 cover if "exclude" in toml_opts: print_error( "'exclude' patterns are only available on Python 3.13+.", @@ -172,7 +172,7 @@ def make_arg_parser( choices=("lf", "crlf", "keep"), help="output file line ending mode (default: lf)", ) - if sys.version_info >= (3, 13): # pragma: no cover + if sys.version_info >= (3, 13): # pragma: >=3.13 cover parser.add_argument( "--exclude", action="append", @@ -218,7 +218,7 @@ def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]: return file_paths -def is_excluded( +def is_excluded( # pragma: >=3.13 cover path: Path | None, patterns: list[str], toml_path: Path | None, diff --git a/src/mdformat/_compat.py b/src/mdformat/_compat.py index 5bbd75a..8c788e7 100644 --- a/src/mdformat/_compat.py +++ b/src/mdformat/_compat.py @@ -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 diff --git a/src/mdformat/_conf.py b/src/mdformat/_conf.py index eabaf0e..d9975c9 100644 --- a/src/mdformat/_conf.py +++ b/src/mdformat/_conf.py @@ -59,7 +59,7 @@ def _validate_values(opts: Mapping, conf_path: Path) -> None: # noqa: C901 if "number" in opts: if not isinstance(opts["number"], bool): raise InvalidConfError(f"Invalid 'number' value in {conf_path}") - if "exclude" in opts: + 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"]: diff --git a/tests/requirements.txt b/tests/requirements.txt index 6f05550..333201f 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ pytest pytest-randomly pytest-cov +covdefaults diff --git a/tests/test_api.py b/tests/test_api.py index 66bc3d5..3a57d79 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" @@ -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 = {} + 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" diff --git a/tests/test_cli.py b/tests/test_cli.py index 4b51212..68d405f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,6 +37,13 @@ def test_format__folder(tmp_path): assert file_path_3.read_text() == UNFORMATTED_MARKDOWN +def test_format__folder_leads_to_invalid(tmp_path): + file_path_1 = tmp_path / "test_markdown1.md" + file_path_1.mkdir() + assert run((str(tmp_path),)) == 0 + assert file_path_1.is_dir() + + def test_format__symlinks(tmp_path): # Create two MD files file_path_1 = tmp_path / "test_markdown1.md" @@ -62,6 +69,19 @@ def test_format__symlinks(tmp_path): assert symlink_2.is_symlink() +def test_broken_symlink(tmp_path, capsys): + # Create a broken symlink + file_path = tmp_path / "test_markdown1.md" + symlink_path = tmp_path / "symlink" + symlink_path.symlink_to(file_path) + + with pytest.raises(SystemExit) as exc_info: + run([str(symlink_path)]) + assert exc_info.value.code == 2 + captured = capsys.readouterr() + assert "does not exist" in captured.err + + def test_invalid_file(capsys): with pytest.raises(SystemExit) as exc_info: run(("this is not a valid filepath?`=|><@{[]\\/,.%ยค#'",)) @@ -70,6 +90,15 @@ def test_invalid_file(capsys): assert "does not exist" in captured.err +def test_fifo(tmp_path, capsys): + fifo_path = tmp_path / "fifo1" + os.mkfifo(fifo_path) + with pytest.raises(SystemExit) as exc_info: + run((str(fifo_path),)) + assert exc_info.value.code == 2 + assert "does not exist" in capsys.readouterr().err + + def test_check(tmp_path): file_path = tmp_path / "test_markdown.md" file_path.write_bytes(FORMATTED_MARKDOWN.encode()) diff --git a/tests/test_config_file.py b/tests/test_config_file.py index b7cb652..b8652c1 100644 --- a/tests/test_config_file.py +++ b/tests/test_config_file.py @@ -6,11 +6,12 @@ from mdformat._cli import run from mdformat._conf import read_toml_opts +from tests.test_cli import FORMATTED_MARKDOWN, UNFORMATTED_MARKDOWN def test_cli_override(tmp_path): config_path = tmp_path / ".mdformat.toml" - config_path.write_text("wrap = 'no'") + config_path.write_text("wrap = 'no'\nend_of_line = 'lf'") file_path = tmp_path / "test_markdown.md" file_path.write_text("remove\nthis\nwrap\n") @@ -65,9 +66,13 @@ def test_invalid_toml(tmp_path, capsys): ("wrap", "wrap = -3"), ("end_of_line", "end_of_line = 'lol'"), ("number", "number = 0"), + ("exclude", "exclude = '**'"), + ("exclude", "exclude = ['1',3]"), ], ) def test_invalid_conf_value(bad_conf, conf_key, tmp_path, capsys): + if conf_key == "exclude" and sys.version_info < (3, 13): + pytest.skip("exclude conf only on Python 3.13+") config_path = tmp_path / ".mdformat.toml" config_path.write_text(bad_conf) @@ -91,3 +96,53 @@ def test_conf_with_stdin(tmp_path, capfd, monkeypatch): assert run(("-",)) == 0 captured = capfd.readouterr() assert captured.out == "1. one\n2. two\n3. three\n" + + +@pytest.mark.skipif( + sys.version_info >= (3, 13), reason="'exclude' only possible on 3.13+" +) +def test_exclude_conf_on_old_python(tmp_path, capsys): + config_path = tmp_path / ".mdformat.toml" + config_path.write_text("exclude = ['**']") + + file_path = tmp_path / "test_markdown.md" + file_path.write_text("# Test Markdown") + + assert run((str(file_path),)) == 1 + assert "only available on Python 3.13+" in capsys.readouterr().err + + +@pytest.mark.skipif( + sys.version_info < (3, 13), reason="'exclude' only possible on 3.13+" +) +def test_exclude(tmp_path, capsys): + config_path = tmp_path / ".mdformat.toml" + config_path.write_text("exclude = ['dir1/*', 'file1.md']") + + dir1_path = tmp_path / "dir1" + file1_path = tmp_path / "file1.md" + file2_path = tmp_path / "file2.md" + file3_path = tmp_path / dir1_path / "file3.md" + dir1_path.mkdir() + file1_path.write_text(UNFORMATTED_MARKDOWN) + file2_path.write_text(UNFORMATTED_MARKDOWN) + file3_path.write_text(UNFORMATTED_MARKDOWN) + + assert run((str(tmp_path),)) == 0 + assert file1_path.read_text() == UNFORMATTED_MARKDOWN + assert file2_path.read_text() == FORMATTED_MARKDOWN + assert file3_path.read_text() == UNFORMATTED_MARKDOWN + + +@pytest.mark.skipif( + sys.version_info < (3, 13), reason="'exclude' only possible on 3.13+" +) +def test_empty_exclude(tmp_path, capsys): + config_path = tmp_path / ".mdformat.toml" + config_path.write_text("exclude = []") + + file1_path = tmp_path / "file1.md" + file1_path.write_text(UNFORMATTED_MARKDOWN) + + assert run((str(tmp_path),)) == 0 + assert file1_path.read_text() == FORMATTED_MARKDOWN diff --git a/tests/test_plugins.py b/tests/test_plugins.py index c879629..27c4e81 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -103,7 +103,7 @@ def test_table(monkeypatch): other text """ ), - extensions=["table"], + extensions=["table", "table"], ) assert text == dedent( """\ @@ -195,7 +195,7 @@ def format_json(unformatted: str, _info_str: str) -> str: return json.dumps(parsed, indent=2) + "\n" -def test_code_format_warnings(monkeypatch, tmp_path, capsys): +def test_code_format_warnings__cli(monkeypatch, tmp_path, capsys): monkeypatch.setitem(CODEFORMATTERS, "json", JSONFormatterPlugin.format_json) file_path = tmp_path / "test_markdown.md" file_path.write_text("```json\nthis is invalid json\n```\n") @@ -207,6 +207,18 @@ def test_code_format_warnings(monkeypatch, tmp_path, capsys): ) +def test_code_format_warnings__api(monkeypatch, caplog): + monkeypatch.setitem(CODEFORMATTERS, "json", JSONFormatterPlugin.format_json) + assert ( + mdformat.text("```json\nthis is invalid json\n```\n", codeformatters=("json",)) + == "```json\nthis is invalid json\n```\n" + ) + assert ( + caplog.messages[0] + == "Failed formatting content of a json code block (line 1 before formatting)" + ) + + def test_plugin_conflict(monkeypatch, tmp_path, capsys): """Test a warning when plugins try to render same syntax.""" plugin_name_1 = "plug1" From ac429a6ad1d86c06ead09ac93ed9595b34f60903 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Sun, 13 Oct 2024 22:06:06 +0300 Subject: [PATCH 06/10] mypy --- tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 3a57d79..f6fe108 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -134,7 +134,7 @@ def test_no_timestamp_modify(tmp_path): def test_mdrenderer_no_finalize(tmp_path): mdit = MarkdownIt() mdit.options["store_labels"] = True - env = {} + env: dict = {} tokens = mdit.parse( "[gl ref]: https://gitlab.com\n\nHere's a link to [GitLab][gl ref]", env ) From 5907d16b04f77af90a6d20c40bbe294cb356cedf Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Sun, 13 Oct 2024 22:17:59 +0300 Subject: [PATCH 07/10] Skip fifo on windows --- src/mdformat/_cli.py | 4 ++-- tests/test_cli.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index 8cfa34a..5c6175e 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -211,9 +211,9 @@ def resolve_file_paths(path_strings: Iterable[str]) -> list[None | Path]: if p.is_file(): p = _normalize_path(p) file_paths.append(p) - elif path_obj.is_file(): + elif path_obj.is_file(): # pragma: nt no cover file_paths.append(path_obj) - else: + else: # pragma: nt no cover raise InvalidPath(path_obj) return file_paths diff --git a/tests/test_cli.py b/tests/test_cli.py index 68d405f..2bdf668 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -90,6 +90,7 @@ def test_invalid_file(capsys): assert "does not exist" in captured.err +@pytest.mark.skipif(os.name == "nt", reason="No os.mkfifo on windows") def test_fifo(tmp_path, capsys): fifo_path = tmp_path / "fifo1" os.mkfifo(fifo_path) From e002f8d17d588c60d2c07da6983f7a26899f8ad4 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:01:36 +0300 Subject: [PATCH 08/10] Add docs --- .pre-commit-config.yaml | 13 ++++++------- README.md | 5 ++++- docs/users/configuration_file.md | 29 +++++++++++++++++++++++++++++ src/mdformat/_cli.py | 18 +++++++++--------- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55a3748..6bcb8ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: @@ -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 diff --git a/README.md b/README.md index 4e8b2bc..4521480 100644 --- a/README.md +++ b/README.md @@ -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 @@ -106,6 +108,7 @@ 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) ``` diff --git a/docs/users/configuration_file.md b/docs/users/configuration_file.md index b119a09..4b95be7 100644 --- a/docs/users/configuration_file.md +++ b/docs/users/configuration_file.md @@ -20,4 +20,33 @@ 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 +] ``` diff --git a/src/mdformat/_cli.py b/src/mdformat/_cli.py index 5c6175e..a4f3aca 100644 --- a/src/mdformat/_cli.py +++ b/src/mdformat/_cli.py @@ -176,7 +176,9 @@ def make_arg_parser( parser.add_argument( "--exclude", action="append", - help="exclude files that match the pattern (multiple allowed)", + 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"): @@ -194,10 +196,9 @@ 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. - 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: @@ -246,10 +247,9 @@ def is_excluded( # pragma: >=3.13 cover def _normalize_path(path: Path) -> Path: """Normalize path. - 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. + 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: From 1c04c37329d13af7d7bc779b1fce34898eafb252 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:02:56 +0300 Subject: [PATCH 09/10] mdformat --- docs/users/configuration_file.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/users/configuration_file.md b/docs/users/configuration_file.md index 4b95be7..e4fb553 100644 --- a/docs/users/configuration_file.md +++ b/docs/users/configuration_file.md @@ -40,6 +40,7 @@ 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 = [ From 2648ebe234983824e7984b458c31faf35a62ae89 Mon Sep 17 00:00:00 2001 From: Taneli Hukkinen <3275109+hukkin@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:08:00 +0300 Subject: [PATCH 10/10] Edit README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4521480..e488285 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ options: --exclude PATTERN exclude files that match the Unix-style glob pattern (multiple allowed) ``` +The `--exclude` option is only available on Python 3.13+. + ## Documentation