diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea578f7..d5fccfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.13 - name: Installation (deps and package) run: pip install . - uses: pre-commit/action@v2.0.0 @@ -49,7 +49,7 @@ jobs: pytest --cov=mdformat_pyproject --cov-report=xml --cov-report=term-missing - name: Store PR number and commit SHA - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.13 run: | echo "Storing PR number ${{ github.event.number }}" echo "${{ github.event.number }}" > pr_number.txt @@ -63,14 +63,14 @@ jobs: # Triggered sub-workflow is not able to detect the original commit/PR which is available # in this workflow. - name: Store PR number - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.13 uses: actions/upload-artifact@v4 with: name: pr_number path: pr_number.txt - name: Store commit SHA - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.13 uses: actions/upload-artifact@v4 with: name: commit_sha @@ -80,7 +80,7 @@ jobs: # is executed by a different workflow `coverage-report.yml`. The reason for this # split is because `on.pull_request` workflows don't have access to secrets. - name: Store coverage report in artifacts - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.13 uses: actions/upload-artifact@v4 with: name: codecov_report @@ -89,7 +89,7 @@ jobs: - run: | echo "The coverage report was stored in Github artifacts." echo "It will be uploaded to Codecov using [codecov.yml] workflow shortly." - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.13 pre-commit-hook: runs-on: ubuntu-latest @@ -99,7 +99,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.13 - name: Installation (deps and package) run: | @@ -118,10 +118,10 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v3 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.13 - name: install flit run: | pip install flit~=3.0 diff --git a/mdformat_pyproject/plugin.py b/mdformat_pyproject/plugin.py index e1a58f5..ba018eb 100644 --- a/mdformat_pyproject/plugin.py +++ b/mdformat_pyproject/plugin.py @@ -2,7 +2,7 @@ import pathlib import sys -from typing import TYPE_CHECKING, MutableMapping, Optional, Sequence, Union +from typing import TYPE_CHECKING, MutableMapping, Optional, Sequence, Tuple, Union import markdown_it import mdformat @@ -27,19 +27,14 @@ @cache -def _find_pyproject_toml_path(search_path: str) -> Optional[pathlib.Path]: - """Find the pyproject.toml file that corresponds to the search path. +def _find_pyproject_toml_path(search_path: pathlib.Path) -> Optional[pathlib.Path]: + """Find the pyproject.toml file that applies to the search path. The search is done ascending through the folders tree until a pyproject.toml file is found in the same folder. If the root '/' is reached, None is returned. - - The special path "-" used for stdin inputs is replaced with the current working - directory. """ - if search_path == "-": - search_path = pathlib.Path.cwd() - else: - search_path = pathlib.Path(search_path).resolve() + if search_path.is_file(): + search_path = search_path.parent for parent in (search_path, *search_path.parents): candidate = parent / "pyproject.toml" @@ -68,50 +63,26 @@ def _parse_pyproject(pyproject_path: pathlib.Path) -> Optional[_ConfigOptions]: @cache -def _reload_cli_opts() -> _ConfigOptions: - """Re-parse the sys.argv array to deduce which arguments were used in the CLI. - - If unknown arguments are found, we deduce that mdformat is being used as a - python library and therefore no mdformat command line arguments were passed. +def read_toml_opts(conf_dir: pathlib.Path) -> Tuple[MutableMapping, Optional[pathlib.Path]]: + """Alternative read_toml_opts that reads from pyproject.toml instead of .mdformat.toml. - Notice that the strategy above does not fully close the door to situations - with colliding arguments with different meanings, but the rarity of the - situation and the complexity of a possible solution makes the risk worth taking. + Notice that if `.mdformat.toml` exists it is ignored. """ - import mdformat._cli - - if hasattr(mdformat.plugins, "_PARSER_EXTENSION_DISTS"): - # New API, mdformat>=0.7.19 - arg_parser = mdformat._cli.make_arg_parser( - mdformat.plugins._PARSER_EXTENSION_DISTS, - mdformat.plugins._CODEFORMATTER_DISTS, - mdformat.plugins.PARSER_EXTENSIONS, - ) + pyproject_path = _find_pyproject_toml_path(conf_dir) + if pyproject_path: + pyproject_opts = _parse_pyproject(pyproject_path) else: - # Backwards compatibility, mdformat<0.7.19 - arg_parser = mdformat._cli.make_arg_parser( - mdformat.plugins.PARSER_EXTENSIONS, - mdformat.plugins.CODEFORMATTERS, - ) + pyproject_opts = {} - args, unknown = arg_parser.parse_known_args(sys.argv[1:]) - if unknown: - return {} - - return {key: value for key, value in vars(args).items() if value is not None} + return pyproject_opts, pyproject_path def update_mdit(mdit: markdown_it.MarkdownIt) -> None: - """Read the pyproject.toml file and re-create the mdformat options.""" - mdformat_options: _ConfigOptions = mdit.options["mdformat"] - file_path = mdformat_options.get("filename", "-") - pyproject_path = _find_pyproject_toml_path(file_path) - if pyproject_path: - pyproject_opts = _parse_pyproject(pyproject_path) - if pyproject_opts is not None: - cli_opts = _reload_cli_opts() - mdformat_options.update(**pyproject_opts) - mdformat_options.update(**cli_opts) + """No-op, since this plugin only monkey patches and does not modify mdit.""" + pass RENDERERS: MutableMapping[str, "Render"] = {} + +# Monkey patch mdformat._conf to use our own read_toml_opts version +mdformat._conf.read_toml_opts = read_toml_opts diff --git a/pyproject.toml b/pyproject.toml index cd50dbc..a8f3947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ profile = "black" [tool.mdformat] wrap = 99 number = true +exclude = [".tox/**", ".venv/**"] [tool.coverage.report] exclude_lines = [ diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 682307e..1a2e911 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -6,11 +6,12 @@ import markdown_it import pytest -from mdformat._conf import InvalidConfError from mdformat_pyproject import plugin -THIS_MODULE_PATH = pathlib.Path(__file__).parent +THIS_MODULE_PATH = pathlib.Path(__file__) +THIS_MODULE_PARENT = THIS_MODULE_PATH.parent +PYPROJECT_PATH = THIS_MODULE_PARENT.parent / "pyproject.toml" def setup_function(): @@ -21,29 +22,36 @@ def setup_function(): @pytest.fixture -def fake_filename(): +def nonexistent_path(): fake_parent = "/fake" while pathlib.Path(fake_parent).exists(): fake_parent += "e" - return str(pathlib.Path(fake_parent) / "path" / "to" / "a" / "file.md") + return pathlib.Path(fake_parent) / "path" / "to" / "a" / "file.md" -@unittest.mock.patch("mdformat_pyproject.plugin.pathlib.Path.cwd", lambda: THIS_MODULE_PATH) -def test__find_pyproject_toml_path_cwd(): - """Test _find_pyproject_toml_path when search_path is `-`. +def test__find_pyproject_toml_path_directory_inside_project(): + """Test _find_pyproject_toml_path when search_path points at a directory within the project. - Setup: - - Patch Path.cwd to return the path of this module, to ensure - that the `cwd` points at a subfolder of the project regardless - of where the `pytest` command was executed. Input: - - search_path="-" + - search_path=THIS_MODULE_PATH -> directory is inside the project Expected output: - pyproject.toml of this project. """ - returned = plugin._find_pyproject_toml_path("-") - assert returned == THIS_MODULE_PATH.parent / "pyproject.toml" + returned = plugin._find_pyproject_toml_path(THIS_MODULE_PARENT) + assert returned == PYPROJECT_PATH + + +def test__find_pyproject_toml_path_directory_outside_project(nonexistent_path): + """Test _find_pyproject_toml_path when search_path points at a directory within the project. + + Input: + - search_path=nonexistent_path.parent -> directory is outside the project + Expected output: + - pyproject.toml of this project. + """ + returned = plugin._find_pyproject_toml_path(nonexistent_path.parent) + assert returned is None def test__find_pyproject_toml_path_file_inside_project(): @@ -54,131 +62,80 @@ def test__find_pyproject_toml_path_file_inside_project(): Expected output: - pyproject.toml of this project. """ - returned = plugin._find_pyproject_toml_path(__file__) - assert returned == THIS_MODULE_PATH.parent / "pyproject.toml" + returned = plugin._find_pyproject_toml_path(THIS_MODULE_PATH) + assert returned == PYPROJECT_PATH -def test__find_pyproject_toml_path_file_outside_of_project(fake_filename): +def test__find_pyproject_toml_path_file_outside_of_project(nonexistent_path): """Test _find_pyproject_toml_path when search_path points at a file outside of a project. Input: - - search_path="/fake/folder/path" -> A madeup path to an inexisting folder. + - search_path="/fake/folder/path" -> A madeup path to an nonexistent folder. Expected output: - None """ - returned = plugin._find_pyproject_toml_path(fake_filename) + returned = plugin._find_pyproject_toml_path(nonexistent_path) assert returned is None -def get_mdit(filename, **kwargs): - mdit = unittest.mock.Mock(spec_set=markdown_it.MarkdownIt()) - mdformat_options = { - "check": False, - "end_of_line": "lf", - "filename": str(pathlib.Path(filename).resolve()), - "number": False, - "paths": [filename], - "wrap": 80, - } - mdit.options = {"mdformat": {**mdformat_options, **kwargs}} - return mdit - - -def test_update_mdit_no_config(fake_filename): - """Test update_mdit when there is no pyproject.toml. - - Input: - - mdit with the default opts and a filename located inside a fake folder - Excepted Side Effect: - - mdit options should remain untouched - """ - mdit = get_mdit(fake_filename) - expected_options = copy.deepcopy(mdit.options["mdformat"]) - - plugin.update_mdit(mdit) - - assert mdit.options["mdformat"] == expected_options - - -def test_update_mdit_pyproject(): - """Test update_mdit when there is configuration inside the pyproject.toml file. +def test_read_toml_opts_with_pyproject(): + """Test read_toml_opts when there is a pyproject.toml file. Input: - - mdit with the default opts and a filename located inside the current project. - Excepted Side Effect: - - mdit options should be updated to the pyproject values + - conf_dir pointing to this module's folder + Expected Output: + - Tuple containing: + - Dict with the mdformat options from pyproject.toml + - Path to the pyproject.toml file """ - mdit = get_mdit(__file__) - - plugin.update_mdit(mdit) - - mdformat_options = mdit.options["mdformat"] - assert mdformat_options["wrap"] == 99 - assert mdformat_options["number"] is True - assert mdformat_options["end_of_line"] == "lf" - + # run + opts, path = plugin.read_toml_opts(THIS_MODULE_PATH) -_BROKEN_OPTS = {"tool": {"mdformat": {"invalid": "option"}}} + # assert + assert opts == {"wrap": 99, "number": True, "exclude": [".tox/**", ".venv/**"]} + assert path == PYPROJECT_PATH -@unittest.mock.patch("mdformat_pyproject.plugin.tomllib.load", lambda _: _BROKEN_OPTS) -def test_update_mdit_invalid_pyproject(): - """Test update_mdit when there are invlid options inside the pyproject.toml file. +def test_read_toml_opts_without_pyproject(nonexistent_path): + """Test read_toml_opts when there is no pyproject.toml file. - Setup: - - Mock tomllib.load to return an invalid pyproject.toml file. - - Also ensure that the load cache is clear Input: - - mdit with the default opts and a filename located inside the current project. - Excepted Side Effect: - - _validate_keys should raise an exception. - + - conf_dir pointing to a non-existent folder + Expected Output: + - Tuple containing: + - Empty dict + - None """ - mdit = get_mdit(__file__) + # run + opts, path = plugin.read_toml_opts(nonexistent_path) - with pytest.raises(InvalidConfError): - plugin.update_mdit(mdit) + # assert + assert opts == {} + assert path is None -@unittest.mock.patch("mdformat_pyproject.plugin.sys.argv", ["mdformat", "--wrap", "70", __file__]) -def test_update_mdit_pyproject_and_cli(): - """Test update_mdit when there are conflicting pyproject.toml configuration and cli argumnents. +def test_update_mdit_no_config(): + """Test update_mdit which is now a no-op. - Setup: - - Patch sys.argv to inject cli options different than the pyproject.toml. Input: - - mdit with the default opts and a filename located inside the current project. - Excepted Side Effect: - - mdit options should be updated, with the cli options having priority over the - pyproject ones. + - mdit with arbitrary configuration + Expected Side Effect: + - mdit options should remain untouched """ - mdit = get_mdit(__file__) - expected_options = copy.deepcopy(mdit.options["mdformat"]) - - plugin.update_mdit(mdit) - - expected_options["wrap"] = 70 - expected_options["number"] = True - assert mdit.options["mdformat"] == expected_options - - -@unittest.mock.patch("mdformat_pyproject.plugin.sys.argv", ["fake", "--wrap", "70", "--unknown"]) -def test_update_mdit_unknown_cli_arguments(): - """Test update_mdit when there are unknown arguments passed in the command line. + filename = "/some/file/name.toml" + mdformat_options = { + "check": False, + "end_of_line": "lf", + "filename": filename, + "number": False, + "paths": [filename], + "wrap": 80, + } + mdit = unittest.mock.Mock(spec_set=markdown_it.MarkdownIt()) + mdit.options = {"mdformat": mdformat_options} - Setup: - - Mock sys.argv to inject unknown cli options. - Input: - - mdit with the default opts and a filename located inside the current project. - Excepted Side Effect: - - The CLI arguments are discarded and only the pyproject.toml options are - injected into the mdit options. - """ - mdit = get_mdit(__file__) - expected_options = copy.deepcopy(mdit.options["mdformat"]) + expected_options = copy.deepcopy(mdformat_options) plugin.update_mdit(mdit) - expected_options["wrap"] = 99 # Still from pyproject - expected_options["number"] = True assert mdit.options["mdformat"] == expected_options