diff --git a/.github/workflows/tests.yml b/.github/workflows/ci.yml similarity index 51% rename from .github/workflows/tests.yml rename to .github/workflows/ci.yml index e4862eb..d5fccfb 100644 --- a/.github/workflows/tests.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 @@ -26,7 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.7, 3.8, 3.9, '3.10', 3.11] + python-version: [3.9, '3.10', 3.11, 3.12, 3.13] os: [ubuntu-latest, windows-latest] steps: @@ -48,16 +48,48 @@ jobs: run: | pytest --cov=mdformat_pyproject --cov-report=xml --cov-report=term-missing - - name: Upload to Codecov - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11 - uses: codecov/codecov-action@v4 + - name: Store PR number and commit SHA + 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 + + echo "Storing commit SHA ${{ github.event.pull_request.head.sha }}" + echo "${{ github.event.pull_request.head.sha }}" > commit_sha.txt + + # Workaround for codecov tokenless upload errors on external PRs + # Copied and ajusted from the workarounds suggested in the link below: + # https://github.com/codecov/feedback/issues/301#issuecomment-2009355183 + # 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.13 + uses: actions/upload-artifact@v4 with: - name: pytests-py3.11 - flags: pytests - file: ./coverage.xml - fail_ci_if_error: true - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + name: pr_number + path: pr_number.txt + + - name: Store commit SHA + if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.13 + uses: actions/upload-artifact@v4 + with: + name: commit_sha + path: commit_sha.txt + + # This stores the coverage report in artifacts. The actual upload to Codecov + # 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.13 + uses: actions/upload-artifact@v4 + with: + name: codecov_report + path: ./coverage.xml + + - 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.13 pre-commit-hook: runs-on: ubuntu-latest @@ -67,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: | @@ -86,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/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..9b6ac4f --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,112 @@ +name: CodeCov Report Upload + +on: + # This workflow is triggered after every successfull execution + # of `ci` workflow. + workflow_run: + workflows: ["CI"] + types: + - completed + +jobs: + coverage: + name: CodeCov Report Upload + runs-on: ubuntu-latest + steps: + + - name: 'Download existing coverage report' + id: prepare_report + uses: actions/github-script@v7 + with: + script: | + var fs = require('fs'); + + // List artifacts of the workflow run that triggered this workflow + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + let codecovReport = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "codecov_report"; + }); + + if (codecovReport.length != 1) { + throw new Error("Unexpected number of {codecov_report} artifacts: " + codecovReport.length); + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: codecovReport[0].id, + archive_format: 'zip', + }); + fs.writeFileSync('codecov_report.zip', Buffer.from(download.data)); + + let prNumber = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr_number"; + }); + + if (prNumber.length != 1) { + throw new Error("Unexpected number of {pr_number} artifacts: " + prNumber.length); + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: prNumber[0].id, + archive_format: 'zip', + }); + fs.writeFileSync('pr_number.zip', Buffer.from(download.data)); + + let commitSha = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "commit_sha"; + }); + + if (commitSha.length != 1) { + throw new Error("Unexpected number of {commit_sha} artifacts: " + commitSha.length); + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: commitSha[0].id, + archive_format: 'zip', + }); + fs.writeFileSync('commit_sha.zip', Buffer.from(download.data)); + + - id: parse_previous_artifacts + run: | + unzip codecov_report.zip + unzip pr_number.zip + unzip commit_sha.zip + + echo "Detected PR is: $(> "$GITHUB_OUTPUT" + echo "override_commit=$(> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ steps.parse_previous_artifacts.outputs.override_commit || '' }} + path: repo_root + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ github.workspace }}/coverage.xml + fail_ci_if_error: true + # Manual overrides for these parameters are needed because automatic detection + # in codecov-action does not work for non-`pull_request` workflows. + # In `main` branch push, these default to empty strings since we want to run + # the analysis on HEAD. + override_commit: ${{ steps.parse_previous_artifacts.outputs.override_commit || '' }} + override_pr: ${{ steps.parse_previous_artifacts.outputs.override_pr || '' }} + working-directory: ${{ github.workspace }}/repo_root + # Location where coverage report files are searched for + directory: ${{ github.workspace }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1b15e7..dc13a26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,10 +28,9 @@ repos: - flake8-bugbear - flake8-builtins - flake8-comprehensions -- repo: local +- repo: https://github.com/hukkin/mdformat + rev: 0.7.21 hooks: - id: mdformat - name: mdformat - entry: mdformat - language: python - types: [markdown] + additional_dependencies: + - mdformat-pyproject diff --git a/README.md b/README.md index e7895ef..aef46fb 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ An [mdformat] plugin to read configuration from `pyproject.toml`. +> ⚠️ WARNING: it has been detected that some native `mdformat` options like `end_of_line` or +> `exclude` are currently being ignored when included in the `pyproject.toml` file. The issue is +> being worked on right now. You can read more here in +> [issue #4](https://github.com/csala/mdformat-pyproject/issues/4) + ## Install Install with: @@ -38,8 +43,8 @@ Add the following to your `.pre-commit-config.yaml`: - mdformat-pyproject ``` -[ci-badge]: https://github.com/csala/mdformat-pyproject/workflows/CI/badge.svg?branch=master -[ci-link]: https://github.com/csala/mdformat/actions?query=workflow%3ACI+branch%3Amaster+event%3Apush +[ci-badge]: https://github.com/csala/mdformat-pyproject/actions/workflows/ci.yml/badge.svg +[ci-link]: https://github.com/csala/mdformat-pyproject/actions/workflows/ci.yml [cov-badge]: https://codecov.io/gh/csala/mdformat-pyproject/branch/master/graph/badge.svg [cov-link]: https://codecov.io/gh/csala/mdformat-pyproject [mdformat]: https://github.com/executablebooks/mdformat diff --git a/mdformat_pyproject/plugin.py b/mdformat_pyproject/plugin.py index 108d127..ba018eb 100644 --- a/mdformat_pyproject/plugin.py +++ b/mdformat_pyproject/plugin.py @@ -1,34 +1,40 @@ """Main plugin module.""" -import functools import pathlib import sys -from typing import Mapping, NoReturn, Optional +from typing import TYPE_CHECKING, MutableMapping, Optional, Sequence, Tuple, Union import markdown_it import mdformat -from mdformat.renderer.typing import Render + +if TYPE_CHECKING: + from mdformat.renderer.typing import Render if sys.version_info >= (3, 11): import tomllib else: import tomli as tomllib +if sys.version_info >= (3, 9): + from functools import cache +else: + from functools import lru_cache + + cache = lru_cache() + -@functools.lru_cache() -def _find_pyproject_toml_path(search_path: str) -> Optional[pathlib.Path]: - """Find the pyproject.toml file that corresponds to the search path. +_ConfigOptions = MutableMapping[str, Union[int, str, Sequence[str]]] + + +@cache +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" @@ -38,66 +44,45 @@ def _find_pyproject_toml_path(search_path: str) -> Optional[pathlib.Path]: return None -@functools.lru_cache() -def _parse_pyproject(pyproject_path: pathlib.Path) -> Optional[Mapping]: +@cache +def _parse_pyproject(pyproject_path: pathlib.Path) -> Optional[_ConfigOptions]: """Extract and validate the mdformat options from the pyproject.toml file. The options are searched inside a [tool.mdformat] key within the toml file, and they are validated using the default functions from `mdformat._conf`. """ - content = tomllib.loads(pyproject_path.read_text()) + with pyproject_path.open(mode="rb") as pyproject_file: + content = tomllib.load(pyproject_file) + options = content.get("tool", {}).get("mdformat") if options is not None: mdformat._conf._validate_keys(options, pyproject_path) mdformat._conf._validate_values(options, pyproject_path) - return options + return options -@functools.lru_cache() -def _reload_cli_opts() -> Mapping: - """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. +@cache +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 pyproject_opts, pyproject_path - return {key: value for key, value in vars(args).items() if value is not None} +def update_mdit(mdit: markdown_it.MarkdownIt) -> None: + """No-op, since this plugin only monkey patches and does not modify mdit.""" + pass -def update_mdit(mdit: markdown_it.MarkdownIt) -> NoReturn: - """Read the pyproject.toml file and re-create the mdformat options.""" - mdformat_options = 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() - new_options: Mapping = {**pyproject_opts, **cli_opts} - mdformat_options.update(new_options) +RENDERERS: MutableMapping[str, "Render"] = {} -RENDERERS: Mapping[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/mdformat_pyproject/py.typed b/mdformat_pyproject/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 91c6f55..a8f3947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] keywords = ["mdformat", "markdown", "markdown-it"] -requires-python = ">=3.7" +requires-python = ">=3.9" dependencies = [ "mdformat ~=0.7", "tomli ~=2.0; python_version < '3.11'" @@ -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 e1ea5f1..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.loads", 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.loads to return an invalid pyproject.toml file. - - Also ensure that the loads 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 diff --git a/tox.ini b/tox.ini index be12d45..2806cb4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] -envlist = py3{7,8,9,10,11}, coverage, pre-commit, hook +envlist = py3{9,10,11,12,13}, coverage, pre-commit, hook isolated_build = True -[testenv:py3{7,8,9,10,11}] +[testenv:py3{9,10,11,12,13}] extras = test commands = pytest {posargs} @@ -19,7 +19,7 @@ extras = dev commands = pre-commit run --config .pre-commit-test.yaml {posargs:--all-files --verbose --show-diff-on-failure} [flake8] -max-line-length = 88 +max-line-length = 99 max-complexity = 10 # These checks violate PEP8 so let's ignore them extend-ignore = E203