diff --git a/README.md b/README.md index 53e6088b..f61e111a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,12 @@ reformatted my_notebook.md All done! ✨ 🍰 ✨ 1 files reformatted. ``` +Quarto format markdown ``.qmd`` files are also supported via [quarto-cli](https://github.com/quarto-dev/quarto-cli) (requires `jupytext` and `quarto-cli` to be installed): + +```console +$ nbqa "ruff check --select=I --fix" my_notebook.qmd +Found 1 error (1 fixed, 0 remaining). +``` See [command-line examples](https://nbqa.readthedocs.io/en/latest/examples.html) for examples involving [doctest](https://docs.python.org/3/library/doctest.html), [flake8](https://flake8.pycqa.org/en/latest/), [mypy](http://mypy-lang.org/), [pylint](https://github.com/PyCQA/pylint), [autopep8](https://github.com/hhatto/autopep8), [pydocstyle](http://www.pydocstyle.org/en/stable/), [yapf](https://github.com/google/yapf), and [ruff](https://github.com/charliermarsh/ruff/). diff --git a/nbqa/__main__.py b/nbqa/__main__.py index 087eb1d3..06b3006c 100644 --- a/nbqa/__main__.py +++ b/nbqa/__main__.py @@ -126,7 +126,7 @@ def _get_notebooks(root_dir: str) -> Iterator[Path]: jupytext_installed = True if os.path.isfile(root_dir): _, ext = os.path.splitext(root_dir) - if (jupytext_installed and ext in (".ipynb", ".md")) or ( + if (jupytext_installed and ext in (".ipynb", ".md", ".qmd")) or ( not jupytext_installed and ext == ".ipynb" ): return iter((Path(root_dir),)) @@ -138,7 +138,9 @@ def _get_notebooks(root_dir: str) -> Iterator[Path]: if jupytext_installed: iterable = itertools.chain( - Path(root_dir).rglob("*.ipynb"), Path(root_dir).rglob("*.md") + Path(root_dir).rglob("*.ipynb"), + Path(root_dir).rglob("*.md"), + Path(root_dir).rglob("*.qmd"), ) else: # pragma: nocover iterable = itertools.chain(Path(root_dir).rglob("*.ipynb")) diff --git a/nbqa/path_utils.py b/nbqa/path_utils.py index 3a5b37d8..de58f1bf 100644 --- a/nbqa/path_utils.py +++ b/nbqa/path_utils.py @@ -88,7 +88,7 @@ def read_notebook(notebook: str) -> Tuple[Optional[Dict[str, Any]], Optional[boo if ext == ".ipynb": trailing_newline = content.endswith("\n") return json.loads(content), trailing_newline - assert ext == ".md" + assert ext in (".md", ".qmd") try: import jupytext # pylint: disable=import-outside-toplevel from markdown_it import MarkdownIt # pylint: disable=import-outside-toplevel @@ -133,8 +133,14 @@ def read_notebook(notebook: str) -> Tuple[Optional[Dict[str, Any]], Optional[boo parsed = MarkdownIt("commonmark").disable("inline", True).parse(content) lexer = None for token in parsed: - if token.type == "fence" and token.info.startswith("{code-cell}"): - lexer = remove_prefix(token.info, "{code-cell}").strip() + if token.type == "fence" and ( + token.info.startswith("{code-cell}") or token.info.startswith("{python}") + ): + lexer = ( + remove_prefix(token.info, "{code-cell}").strip() + if not token.info.startswith("{python}") + else "python" + ) md_content["metadata"]["language_info"] = {"pygments_lexer": lexer} break diff --git a/nbqa/replace_source.py b/nbqa/replace_source.py index 94588f63..639c9a81 100644 --- a/nbqa/replace_source.py +++ b/nbqa/replace_source.py @@ -216,7 +216,7 @@ def _write_notebook( f"{json.dumps(notebook_json, indent=1, ensure_ascii=False)}" ) else: - assert ext == ".md" + assert ext in (".md", ".qmd") import jupytext # pylint: disable=import-outside-toplevel from jupytext.config import ( # pylint: disable=import-outside-toplevel load_jupytext_config, @@ -326,7 +326,7 @@ def _print_diff(code_cell_number: int, cell_diff: Iterator[str]) -> bool: if line_changes: header = f"Cell {code_cell_number}" - headers = [f"{BOLD}{header}{RESET}\n", f"{'-'*len(header)}\n"] + headers = [f"{BOLD}{header}{RESET}\n", f"{'-' * len(header)}\n"] sys.stdout.writelines(headers + line_changes + ["\n"]) return True return False diff --git a/requirements-dev.txt b/requirements-dev.txt index 45632468..96b1aafb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,5 +16,6 @@ pytest pytest-cov pytest-randomly pyupgrade +quarto-cli ruff yapf diff --git a/setup.cfg b/setup.cfg index f7be9f59..02bea3c3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ toolchain = mypy pylint pyupgrade + quarto-cli ruff [flake8] diff --git a/tests/data/notebook_for_testing.qmd b/tests/data/notebook_for_testing.qmd new file mode 100644 index 00000000..d3b5bedf --- /dev/null +++ b/tests/data/notebook_for_testing.qmd @@ -0,0 +1,44 @@ +--- +title: Quarto Basics +format: + html: + code-fold: true +jupyter: + jupytext: + text_representation: + extension: .qmd + format_name: quarto + format_version: '1.0' + jupytext_version: 1.16.7 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +```{python} +#| label: fig-polar +# | fig-cap: A line plot on a polar axis +#| Additional content +# This is a comment that stops cell options +#| fig-subcap: A line plot on a polar axis +# | Additional content + +import numpy as np +import matplotlib.pyplot as plt + +r = np.arange(0, 2, 0.01) +theta = 2 * np.pi * r +fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) +ax.plot(theta, r) +ax.set_rticks([0.5, 1, 1.5, 2]) +ax.grid(True) +plt.show() +``` + +# Other Markdown +This content should not change in any way. +#| cell option like line +#comment like line +code that won't get formatted +ax.set_rticks([0.5,1,1.5,2]) diff --git a/tests/test_jupytext.py b/tests/test_jupytext.py index 7702ea85..cdb6591b 100644 --- a/tests/test_jupytext.py +++ b/tests/test_jupytext.py @@ -198,6 +198,71 @@ def test_md(tmp_test_data: Path) -> None: assert result == expected +def test_qmd(tmp_test_data: Path) -> None: + """ + Notebook in qmd format. + + Parameters + ---------- + tmp_test_data + Temporary copy of test data. + """ + notebook = tmp_test_data / "notebook_for_testing.qmd" + + main(["black", str(notebook)]) + + with open(notebook, encoding="utf-8") as fd: + result = fd.read() + expected = ( + """---\n""" + """title: Quarto Basics\n""" + """format:\n""" + """ html:\n""" + """ code-fold: true\n""" + """jupyter:\n""" + """ jupytext:\n""" + """ text_representation:\n""" + """ extension: .qmd\n""" + """ format_name: quarto\n""" + """ format_version: '1.0'\n""" + f" jupytext_version: {jupytext.__version__}\n" + """ kernelspec:\n""" + """ display_name: Python 3\n""" + """ language: python\n""" + """ name: python3\n""" + """---\n""" + """\n""" + """```{python}\n""" + """#| label: fig-polar\n""" + """#| fig-cap: |-\n""" + """#| A line plot on a polar axis\n""" + """#| Additional content\n""" + """# This is a comment that stops cell options\n""" + """# | fig-subcap: A line plot on a polar axis\n""" + """# | Additional content\n""" + """\n""" + """import numpy as np\n""" + """import matplotlib.pyplot as plt\n""" + """\n""" + """r = np.arange(0, 2, 0.01)\n""" + """theta = 2 * np.pi * r\n""" + """fig, ax = plt.subplots(subplot_kw={"projection": "polar"})\n""" + """ax.plot(theta, r)\n""" + """ax.set_rticks([0.5, 1, 1.5, 2])\n""" + """ax.grid(True)\n""" + """plt.show()\n""" + """```\n""" + """\n""" + """# Other Markdown\n""" + """This content should not change in any way.\n""" + """#| cell option like line\n""" + """#comment like line\n""" + """code that won't get formatted\n""" + """ax.set_rticks([0.5,1,1.5,2])\n""" + ) + assert result == expected + + def test_non_jupytext_md() -> None: """Check non-Python markdown will be ignored.""" ret = main(["black", "README.md"]) @@ -296,11 +361,11 @@ def test_jupytext_on_folder(capsys: "CaptureFixture") -> None: ) out, _ = capsys.readouterr() expected = ( - f'{os.path.join(path, "invalid_syntax.ipynb")}:cell_1:0 at module level:\n' + f"{os.path.join(path, 'invalid_syntax.ipynb')}:cell_1:0 at module level:\n" " D100: Missing docstring in public module\n" - f'{os.path.join(path, "assignment_to_literal.ipynb")}:cell_1:0 at module level:\n' + f"{os.path.join(path, 'assignment_to_literal.ipynb')}:cell_1:0 at module level:\n" " D100: Missing docstring in public module\n" - f'{os.path.join(path, "automagic.ipynb")}:cell_1:0 at module level:\n' + f"{os.path.join(path, 'automagic.ipynb')}:cell_1:0 at module level:\n" " D100: Missing docstring in public module\n" ) assert "\n".join(sorted(out.splitlines())) == "\n".join(