Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/users/configuration_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ When formatting standard input stream, resolution will be started from current w

Command line interface arguments take precedence over the configuration file.

### Explicit Configuration Path

Alternatively, you can explicitly point to a configuration file using the **`--config`** command-line argument. When this argument is used, the default recursive search for `.mdformat.toml` is **disabled**, and only the configuration found at the specified path will be loaded. This provides direct control over the configuration file location, which is useful when integrating `mdformat` into tools like `pre-commit` hooks that require configs to be stored in custom locations.

**Example:**

```bash
mdformat file.md --config .config/mdformat.toml
```

## Example configuration

```toml
Expand Down
33 changes: 29 additions & 4 deletions src/mdformat/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
import textwrap

import mdformat
from mdformat._conf import DEFAULT_OPTS, InvalidConfError, read_toml_opts
from mdformat._conf import (
DEFAULT_OPTS,
InvalidConfError,
read_single_config_file,
read_toml_opts,
)
from mdformat._util import detect_newline_type, is_md_equal
import mdformat.plugins

Expand All @@ -34,6 +39,10 @@ def run(cli_args: Sequence[str], cache_toml: bool = True) -> int: # noqa: C901
}
cli_core_opts, cli_plugin_opts = separate_core_and_plugin_opts(cli_opts)

config_override_path = cli_core_opts.pop("config", None)
if config_override_path and not isinstance(config_override_path, Path):
config_override_path = Path(config_override_path)

if not cli_opts["paths"]:
print_paragraphs(["No files have been passed in. Doing nothing."])
return 0
Expand All @@ -46,12 +55,20 @@ def run(cli_args: Sequence[str], cache_toml: bool = True) -> int: # noqa: C901
format_errors_found = False
renderer_warning_printer = RendererWarningPrinter()
for path in file_paths:
read_toml = read_toml_opts if cache_toml else read_toml_opts.__wrapped__
try:
toml_opts, toml_path = read_toml(path.parent if path else Path.cwd())
if config_override_path:
toml_opts, toml_path = read_single_config_file(config_override_path)
else:
read_toml = read_toml_opts if cache_toml else read_toml_opts.__wrapped__
toml_opts, toml_path = read_toml(path.parent if path else Path.cwd())
except InvalidConfError as e:
print_error(str(e))
return 1
except FileNotFoundError as e:
if config_override_path and str(config_override_path) == str(e.args[0]):
print_error(f"Configuration file not found at: {e.args[0]}")
return 1
raise

opts = {**DEFAULT_OPTS, **toml_opts, **cli_core_opts}

Expand Down Expand Up @@ -241,6 +258,13 @@ def make_arg_parser(
choices=("lf", "crlf", "keep"),
help="output file line ending mode (default: lf)",
)

parser.add_argument(
"--config",
type=Path,
help="path to a TOML configuration file to use (overrides auto-detection)",
)

if sys.version_info >= (3, 13): # pragma: >=3.13 cover
parser.add_argument(
"--exclude",
Expand Down Expand Up @@ -326,7 +350,8 @@ def separate_core_and_plugin_opts(opts: Mapping) -> tuple[dict, dict]:
class InvalidPath(Exception):
"""Exception raised when a path does not exist."""

def __init__(self, path: Path):
def __init__(self, path: Path) -> None:
super().__init__(path)
self.path = path


Expand Down
17 changes: 17 additions & 0 deletions src/mdformat/_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ class InvalidConfError(Exception):
"""


def read_single_config_file(config_path: Path) -> tuple[Mapping, Path | None]:
"""Read configuration from a single specified TOML file."""
if not config_path.is_file():
raise FileNotFoundError(config_path)

with open(config_path, "rb") as f:
try:
toml_opts = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise InvalidConfError(f"Invalid TOML syntax in {config_path}: {e}")

_validate_keys(toml_opts, config_path)
_validate_values(toml_opts, config_path)

return toml_opts, config_path


@functools.lru_cache
def read_toml_opts(conf_dir: Path) -> tuple[Mapping, Path | None]:
conf_path = conf_dir / ".mdformat.toml"
Expand Down
85 changes: 85 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import argparse
import os
import sys
from unittest.mock import patch
Expand Down Expand Up @@ -512,3 +513,87 @@ def test_no_extensions(tmp_path, monkeypatch):
file_path.write_text(original_md)
assert run((str(file_path), "--no-extensions")) == 0
assert file_path.read_text() == original_md


def test_config_override_precedence(tmp_path):
explicit_config_path = tmp_path / "explicit.toml"
explicit_config_path.write_text("wrap = 50\nend_of_line = 'crlf'")

auto_config_path = tmp_path / ".mdformat.toml"
auto_config_path.write_text("wrap = 100")

file_path = tmp_path / "test.md"
file_path.write_text(
"A very long line to test wrapping and EOLs.\nA very very long line."
)

expected_content = (
"A very long line to test wrapping and EOLs. A very"
+ "\r\n"
+ "very long line."
+ "\r\n"
)

assert run([str(file_path), "--config", str(explicit_config_path)]) == 0
assert file_path.read_bytes() == expected_content.encode("utf-8")

non_existent_path = tmp_path / "non_existent.toml"
unformatted_content = "unformatted content"
file_path.write_text(unformatted_content)

assert run([str(file_path), "--config", str(non_existent_path)]) == 1
assert file_path.read_text() == unformatted_content


def test_config_search_from_stdin(tmp_path, capfd, patch_stdin):
"""Test that config is searched from CWD when reading from stdin (path is
None)."""

config_path = tmp_path / ".mdformat.toml"
config_path.write_text("wrap = 50")

input_content = "This is a very long line that should be wrapped if config is read."

expected_content = """\
This is a very long line that should be wrapped if
config is read.
"""

with patch("mdformat._cli.Path.cwd", return_value=tmp_path):
patch_stdin(input_content)

assert run(("-",), cache_toml=False) == 0

captured = capfd.readouterr()

assert captured.out == expected_content


def test_config_manual_path_conversion_coverage(tmp_path):
"""Tests the edge case where config path is passed as a string, covering
the manual Path(config_path) conversion in run()."""

config_file = tmp_path / "custom.toml"
config_file.write_text("wrap = 40")

test_file = tmp_path / "test.md"
test_file.write_text("placeholder")

mock_args = {
"paths": [str(test_file)],
"config": str(config_file),
"check": False,
"validate": True,
"number": None,
"wrap": None,
"end_of_line": None,
"exclude": (),
"extensions": None,
"codeformatters": None,
}

with patch(
"mdformat._cli.argparse.ArgumentParser.parse_args",
return_value=argparse.Namespace(**mock_args),
):
assert run(["placeholder"]) == 0
28 changes: 28 additions & 0 deletions tests/test_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from mdformat._cli import run
from mdformat._conf import InvalidConfError, read_single_config_file
from tests.utils import FORMATTED_MARKDOWN, UNFORMATTED_MARKDOWN


Expand Down Expand Up @@ -168,3 +169,30 @@ def test_conf_no_validate(tmp_path):

assert run((str(file_path),), cache_toml=False) == 0
assert file_path.read_text() == "1? ordered\n"


def test_single_config_file_invalid_toml(tmp_path):
"""Test that reading an explicitly supplied config file with invalid TOML
raises InvalidConfError."""
invalid_toml_path = tmp_path / "invalid.toml"
invalid_toml_path.write_text("key = 'value\n[broken")

with pytest.raises(InvalidConfError) as excinfo:
read_single_config_file(invalid_toml_path)

assert f"Invalid TOML syntax in {invalid_toml_path}" in str(excinfo.value)


def test_invalid_toml_in_parent_dir(tmp_path, capsys):

config_path = tmp_path / ".mdformat.toml"
config_path.write_text("]invalid TOML[")

subdir_path = tmp_path / "subdir"
subdir_path.mkdir()
file_path = subdir_path / "test_markdown.md"
file_path.write_text("# Test Markdown")

assert run((str(file_path),), cache_toml=False) == 1
captured = capsys.readouterr()
assert "Invalid TOML syntax" in captured.err