From 620ec66182dd0f84600258408720779822615085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Fri, 27 Jan 2023 12:34:39 +0100 Subject: [PATCH] feat: Allow expecting specific exit codes Issue #10: https://github.com/pawamoy/markdown-exec/issues/10 --- docs/usage/index.md | 12 +++++++----- docs/usage/shell.md | 26 ++++++++++++++++++++++++++ src/markdown_exec/__init__.py | 2 ++ src/markdown_exec/formatters/base.py | 22 +++++++++++++++++++--- src/markdown_exec/formatters/bash.py | 18 +++++++++++------- src/markdown_exec/formatters/python.py | 4 ++-- src/markdown_exec/formatters/sh.py | 18 +++++++++++------- tests/test_python.py | 2 +- tests/test_shell.py | 23 ++++++++++++++++++++++- 9 files changed, 101 insertions(+), 26 deletions(-) diff --git a/docs/usage/index.md b/docs/usage/index.md index 9f030fc..7bbbf45 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -198,10 +198,12 @@ Example: Code blocks execution can fail. For example, your Python code may raise exceptions, -or your shell code may return a non-zero exit code. +or your shell code may return a non-zero exit code +(for shell commands that are expected to return non-zero, +see [Expecting a non-zero exit code](shell/#expecting-a-non-zero-exit-code)). In these cases, the exception and traceback (Python), -or the standard error message (shell) will be rendered +or the current output (shell) will be rendered instead of the result, and a warning will be logged. Example of failing code: @@ -214,7 +216,7 @@ assert 1 + 1 == 11 ```` ```text title="MkDocs output" -WARNING - markdown_exec: Execution of python code block exited with non-zero status +WARNING - markdown_exec: Execution of python code block exited with errors ``` ```python title="Rendered traceback" @@ -239,7 +241,7 @@ assert 1 + 1 == 11 ```` ```text title="MkDocs output" -WARNING - markdown_exec: Execution of python code block 'print hello' exited with non-zero status +WARNING - markdown_exec: Execution of python code block 'print hello' exited with errors ``` > TIP: **Titles act as IDs as well!** @@ -254,7 +256,7 @@ WARNING - markdown_exec: Execution of python code block 'print hello' exited w > ```` > > ```text title="MkDocs output" -> WARNING - markdown_exec: Execution of python code block 'print world' exited with non-zero status +> WARNING - markdown_exec: Execution of python code block 'print world' exited with errors > ``` ## Literate Markdown diff --git a/docs/usage/shell.md b/docs/usage/shell.md index 016004e..c31e95a 100644 --- a/docs/usage/shell.md +++ b/docs/usage/shell.md @@ -27,3 +27,29 @@ $ mkdocs --help echo Markdown is **cool** ``` ```` + +## Expecting a non-zero exit code + +You will sometimes want to run a command +that returns a non-zero exit code, +for example to show how errors look to your users. + +You can tell Markdown Exec to expect +a particular exit code with the `returncode` option: + +````md +```bash exec="true" returncode="1" +echo Not in the mood today +exit 1 +``` +```` + +In that case, the executed code won't be considered +to have failed, its output will be rendered normally, +and no warning will be logged in the MkDocs output, +allowing your strict builds to pass. + +If the exit code is different than the one specified +with `returncode`, it will be considered a failure, +its output will be renderer anyway (stdout and stderr combined), +and a warning will be logged in the MkDocs output. diff --git a/src/markdown_exec/__init__.py b/src/markdown_exec/__init__.py index 5aba292..ad40580 100644 --- a/src/markdown_exec/__init__.py +++ b/src/markdown_exec/__init__.py @@ -64,12 +64,14 @@ def validator( html_value = _to_bool(inputs.pop("html", "no")) source_value = inputs.pop("source", "") result_value = inputs.pop("result", "") + returncode_value = int(inputs.pop("returncode", "0")) tabs_value = inputs.pop("tabs", "|".join(default_tabs)) tabs = tuple(_tabs_re.split(tabs_value, maxsplit=1)) options["id"] = id_value options["html"] = html_value options["source"] = source_value options["result"] = result_value + options["returncode"] = returncode_value options["tabs"] = tabs options["extra"] = inputs return True diff --git a/src/markdown_exec/formatters/base.py b/src/markdown_exec/formatters/base.py index 737f648..cfa242f 100644 --- a/src/markdown_exec/formatters/base.py +++ b/src/markdown_exec/formatters/base.py @@ -15,6 +15,19 @@ default_tabs = ("Source", "Result") +class ExecutionError(Exception): + """Exception raised for errors during execution of a code block. + + Attributes: + message: The exception message. + returncode: The code returned by the execution of the code block. + """ + + def __init__(self, message: str, returncode: int | None = None) -> None: # noqa: D107 + super().__init__(message) + self.returncode = returncode + + def base_format( # noqa: WPS231 *, language: str, @@ -26,6 +39,7 @@ def base_format( # noqa: WPS231 result: str = "", tabs: tuple[str, str] = default_tabs, id: str = "", # noqa: A002,VNE003 + returncode: int = 0, transform_source: Callable[[str], tuple[str, str]] | None = None, **options: Any, ) -> Markup: @@ -41,6 +55,7 @@ def base_format( # noqa: WPS231 result: If provided, use as language to format result in a code block. tabs: Titles of tabs (if used). id: An optional ID for the code block (useful when warning about errors). + returncode: The expected exit code. transform_source: An optional callable that returns transformed versions of the source. The input source is the one that is ran, the output source is the one that is rendered (when the source option is enabled). @@ -59,11 +74,12 @@ def base_format( # noqa: WPS231 source_output = code try: - output = run(source_input, **extra) - except RuntimeError as error: + output = run(source_input, returncode=returncode, **extra) + except ExecutionError as error: identifier = id or extra.get("title", "") identifier = identifier and f"'{identifier}' " - logger.warning(f"Execution of {language} code block {identifier}exited with non-zero status") + exit_message = "errors" if error.returncode is None else f"unexpected code {error.returncode}" + logger.warning(f"Execution of {language} code block {identifier}exited with {exit_message}") return markdown.convert(str(error)) if html: diff --git a/src/markdown_exec/formatters/bash.py b/src/markdown_exec/formatters/bash.py index 608d4d8..48922ae 100644 --- a/src/markdown_exec/formatters/bash.py +++ b/src/markdown_exec/formatters/bash.py @@ -4,16 +4,20 @@ import subprocess # noqa: S404 -from markdown_exec.formatters.base import base_format +from markdown_exec.formatters.base import ExecutionError, base_format from markdown_exec.rendering import code_block -def _run_bash(code: str, **extra: str) -> str: - try: - output = subprocess.check_output(["bash", "-c", code], stderr=subprocess.STDOUT).decode() # noqa: S603,S607 - except subprocess.CalledProcessError as error: - raise RuntimeError(code_block("bash", error.output, **extra)) - return output +def _run_bash(code: str, *, returncode: int = 0, **extra: str) -> str: + process = subprocess.run( # noqa: S603,S607 + ["bash", "-c", code], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if process.returncode != returncode: + raise ExecutionError(code_block("sh", process.stdout, **extra), process.returncode) + return process.stdout def _format_bash(**kwargs) -> str: diff --git a/src/markdown_exec/formatters/python.py b/src/markdown_exec/formatters/python.py index 3c12b5e..ab6c658 100644 --- a/src/markdown_exec/formatters/python.py +++ b/src/markdown_exec/formatters/python.py @@ -7,7 +7,7 @@ from io import StringIO from typing import Any -from markdown_exec.formatters.base import base_format +from markdown_exec.formatters.base import ExecutionError, base_format from markdown_exec.rendering import code_block @@ -27,7 +27,7 @@ def _run_python(code: str, **extra: str) -> str: if frame.filename == "": frame.filename = "" frame._line = code.split("\n")[frame.lineno - 1] # type: ignore[attr-defined,operator] # noqa: WPS437 - raise RuntimeError(code_block("python", "".join(trace.format()), **extra)) + raise ExecutionError(code_block("python", "".join(trace.format()), **extra)) return buffer.getvalue() diff --git a/src/markdown_exec/formatters/sh.py b/src/markdown_exec/formatters/sh.py index 9a2da35..301b3c2 100644 --- a/src/markdown_exec/formatters/sh.py +++ b/src/markdown_exec/formatters/sh.py @@ -4,16 +4,20 @@ import subprocess # noqa: S404 -from markdown_exec.formatters.base import base_format +from markdown_exec.formatters.base import ExecutionError, base_format from markdown_exec.rendering import code_block -def _run_sh(code: str, **extra: str) -> str: - try: - output = subprocess.check_output(["sh", "-c", code], stderr=subprocess.STDOUT).decode() # noqa: S603,S607 - except subprocess.CalledProcessError as error: - raise RuntimeError(code_block("sh", error.output, **extra)) - return output +def _run_sh(code: str, *, returncode: int = 0, **extra: str) -> str: + process = subprocess.run( # noqa: S603,S607 + ["sh", "-c", code], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if process.returncode != returncode: + raise ExecutionError(code_block("sh", process.stdout, **extra), process.returncode) + return process.stdout def _format_sh(**kwargs) -> str: diff --git a/tests/test_python.py b/tests/test_python.py index 923f4fe..fbd8b3d 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -60,7 +60,7 @@ def test_error_raised(md: Markdown, caplog) -> None: assert "Traceback" in html assert "ValueError" in html assert "oh no!" in html - assert "Execution of python code block exited with non-zero status" in caplog.text + assert "Execution of python code block exited with errors" in caplog.text def test_can_print_non_string_objects(md: Markdown) -> None: diff --git a/tests/test_shell.py b/tests/test_shell.py index ac05184..b1f638f 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -58,4 +58,25 @@ def test_error_raised(md: Markdown, caplog) -> None: ) ) assert "error" in html - assert "Execution of sh code block exited with non-zero status" in caplog.text + assert "Execution of sh code block exited with unexpected code 2" in caplog.text + + +def test_return_code(md: Markdown, caplog) -> None: + """Assert return code is used correctly. + + Parameters: + md: A Markdown instance (fixture). + caplog: Pytest fixture to capture logs. + """ + html = md.convert( + dedent( + """ + ```sh exec="yes" returncode="1" + echo Not in the mood + exit 1 + ``` + """ + ) + ) + assert "Not in the mood" in html + assert "exited with" not in caplog.text