|
1 | 1 | """Main plugin module.""" |
2 | 2 |
|
3 | | -import pathlib |
| 3 | +from __future__ import annotations |
| 4 | + |
4 | 5 | import sys |
5 | | -from typing import TYPE_CHECKING, MutableMapping, Optional, Sequence, Tuple, Union |
| 6 | +from functools import cache |
| 7 | +from pathlib import Path |
6 | 8 |
|
7 | 9 | import markdown_it |
8 | 10 | import mdformat |
9 | 11 |
|
| 12 | +TYPE_CHECKING = False |
10 | 13 | if TYPE_CHECKING: |
| 14 | + from collections.abc import MutableMapping, Sequence |
| 15 | + |
11 | 16 | from mdformat.renderer.typing import Render |
12 | 17 |
|
| 18 | + _ConfigOptions = MutableMapping[str, int | str | Sequence[str]] |
| 19 | + |
| 20 | + |
13 | 21 | if sys.version_info >= (3, 11): |
14 | 22 | import tomllib |
15 | 23 | else: |
16 | 24 | import tomli as tomllib |
17 | 25 |
|
18 | | -if sys.version_info >= (3, 9): |
19 | | - from functools import cache |
20 | | -else: |
21 | | - from functools import lru_cache |
22 | | - |
23 | | - cache = lru_cache() |
24 | 26 |
|
| 27 | +@cache |
| 28 | +def _search_config_file(search_path: Path) -> Path | None: |
| 29 | + """Search the first ``.mdformat.toml`` or ``pyproject.toml`` file in the provided |
| 30 | + ``search_path`` folder or its parents. |
25 | 31 |
|
26 | | -_ConfigOptions = MutableMapping[str, Union[int, str, Sequence[str]]] |
| 32 | + The search is done ascending through the folders tree until a ``.mdformat.toml`` or a |
| 33 | + ``pyproject.toml`` file is found. If the root ``/`` is reached, ``None`` is returned. |
27 | 34 |
|
| 35 | + ``.mdformat.toml`` takes precedence over ``pyproject.toml`` if both are present in the |
| 36 | + same folder. |
28 | 37 |
|
29 | | -@cache |
30 | | -def _find_pyproject_toml_path(search_path: pathlib.Path) -> Optional[pathlib.Path]: |
31 | | - """Find the pyproject.toml file that applies to the search path. |
| 38 | + ``pyproject.toml`` files without a ``[tool.mdformat]`` section are ignored. |
32 | 39 |
|
33 | | - The search is done ascending through the folders tree until a pyproject.toml |
34 | | - file is found in the same folder. If the root '/' is reached, None is returned. |
| 40 | + This behavior mimics the one from Ruff, as described in `issues#17 |
| 41 | + <https://github.com/csala/mdformat-pyproject/issues/17>`_. |
35 | 42 | """ |
36 | 43 | if search_path.is_file(): |
37 | 44 | search_path = search_path.parent |
38 | 45 |
|
39 | 46 | for parent in (search_path, *search_path.parents): |
40 | | - candidate = parent / "pyproject.toml" |
41 | | - if candidate.is_file(): |
42 | | - return candidate |
| 47 | + for filename in (".mdformat.toml", "pyproject.toml"): |
| 48 | + candidate = parent / filename |
| 49 | + if candidate.is_file(): |
| 50 | + # If we found a pyproject.toml, only return it if it contains |
| 51 | + # a [tool.mdformat] section. |
| 52 | + if candidate.name == "pyproject.toml": |
| 53 | + options = _parse_pyproject(candidate) |
| 54 | + if options is None: |
| 55 | + continue |
| 56 | + |
| 57 | + return candidate |
43 | 58 |
|
44 | 59 | return None |
45 | 60 |
|
46 | 61 |
|
47 | 62 | @cache |
48 | | -def _parse_pyproject(pyproject_path: pathlib.Path) -> Optional[_ConfigOptions]: |
49 | | - """Extract and validate the mdformat options from the pyproject.toml file. |
| 63 | +def _parse_pyproject(pyproject_path: Path) -> _ConfigOptions | None: |
| 64 | + """Extract and validate the mdformat options from the ``pyproject.toml`` file. |
| 65 | +
|
| 66 | + The options are searched inside a ``[tool.mdformat]`` section within the TOML file, |
| 67 | + and they are validated using the default functions from ``mdformat._conf``. |
50 | 68 |
|
51 | | - The options are searched inside a [tool.mdformat] key within the toml file, |
52 | | - and they are validated using the default functions from `mdformat._conf`. |
| 69 | + If no ``[tool.mdformat]`` section is found, ``None`` is returned. |
53 | 70 | """ |
54 | 71 | with pyproject_path.open(mode="rb") as pyproject_file: |
55 | | - content = tomllib.load(pyproject_file) |
| 72 | + try: |
| 73 | + content = tomllib.load(pyproject_file) |
| 74 | + except tomllib.TOMLDecodeError as e: |
| 75 | + raise mdformat._conf.InvalidConfError(f"Invalid TOML syntax: {e}") |
56 | 76 |
|
57 | 77 | options = content.get("tool", {}).get("mdformat") |
58 | | - if options is not None: |
59 | | - mdformat._conf._validate_keys(options, pyproject_path) |
60 | | - mdformat._conf._validate_values(options, pyproject_path) |
| 78 | + if options is None: |
| 79 | + return None |
| 80 | + |
| 81 | + mdformat._conf._validate_keys(options, pyproject_path) |
| 82 | + mdformat._conf._validate_values(options, pyproject_path) |
61 | 83 |
|
62 | 84 | return options |
63 | 85 |
|
64 | 86 |
|
| 87 | +_orig_read_toml_opts = mdformat._conf.read_toml_opts |
| 88 | + |
| 89 | + |
65 | 90 | @cache |
66 | | -def read_toml_opts(conf_dir: pathlib.Path) -> Tuple[MutableMapping, Optional[pathlib.Path]]: |
67 | | - """Alternative read_toml_opts that reads from pyproject.toml instead of .mdformat.toml. |
| 91 | +def patched_read_toml_opts(conf_dir: Path) -> tuple[MutableMapping, Path | None]: |
| 92 | + """Patched version of ``mdformat._conf.read_toml_opts``. |
68 | 93 |
|
69 | | - Notice that if `.mdformat.toml` exists it is ignored. |
| 94 | + Tries to read options from ``pyproject.toml`` first before falling back to |
| 95 | + ``.mdformat.toml``. |
70 | 96 | """ |
71 | | - pyproject_path = _find_pyproject_toml_path(conf_dir) |
72 | | - if pyproject_path: |
73 | | - pyproject_opts = _parse_pyproject(pyproject_path) |
74 | | - else: |
75 | | - pyproject_opts = {} |
| 97 | + config_file = _search_config_file(conf_dir) |
| 98 | + |
| 99 | + if config_file: |
| 100 | + # We found a .mdformat.toml, use the original function directly. |
| 101 | + if config_file.name == ".mdformat.toml": |
| 102 | + return _orig_read_toml_opts(config_file.parent) |
| 103 | + |
| 104 | + # Otherwise, we found a pyproject.toml, try to parse it. |
| 105 | + options = _parse_pyproject(config_file) |
| 106 | + if options: |
| 107 | + return options, config_file |
76 | 108 |
|
77 | | - return pyproject_opts, pyproject_path |
| 109 | + # No config file found, return empty options. |
| 110 | + return {}, None |
78 | 111 |
|
79 | 112 |
|
80 | 113 | def update_mdit(mdit: markdown_it.MarkdownIt) -> None: |
81 | | - """No-op, since this plugin only monkey patches and does not modify mdit.""" |
| 114 | + """No-op, since this plugin only monkey patches and does not modify ``mdit``.""" |
82 | 115 | pass |
83 | 116 |
|
84 | 117 |
|
85 | | -RENDERERS: MutableMapping[str, "Render"] = {} |
| 118 | +RENDERERS: MutableMapping[str, Render] = {} |
86 | 119 |
|
87 | | -# Monkey patch mdformat._conf to use our own read_toml_opts version |
88 | | -mdformat._conf.read_toml_opts = read_toml_opts |
| 120 | +# Monkey patch mdformat._conf to use our own read_toml_opts version. |
| 121 | +mdformat._conf.read_toml_opts = patched_read_toml_opts |
0 commit comments