diff --git a/README.md b/README.md index b94729f8..82d218b3 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,12 @@ Render markdown to HTML with markdown-it-py from the command-line: ```console -usage: markdown-it [-h] [-v] [filenames [filenames ...]] +usage: markdown-it [-h] [-v] [--stdin|filenames [filenames ...]] Parse one or more markdown files, convert each to HTML, and print to stdout positional arguments: + --stdin read source Markdown file from standard input filenames specify an optional list of files to convert optional arguments: diff --git a/markdown_it/cli/parse.py b/markdown_it/cli/parse.py index fe346b2f..5de738b2 100644 --- a/markdown_it/cli/parse.py +++ b/markdown_it/cli/parse.py @@ -21,6 +21,8 @@ def main(args: Sequence[str] | None = None) -> int: namespace = parse_args(args) if namespace.filenames: convert(namespace.filenames) + elif namespace.stdin: + convert_stdin() else: interactive() return 0 @@ -31,6 +33,18 @@ def convert(filenames: Iterable[str]) -> None: convert_file(filename) +def convert_stdin() -> None: + """ + Parse a Markdown file and dump the output to stdout. + """ + try: + rendered = MarkdownIt().render(sys.stdin.read()) + print(rendered, end="") + except OSError: + sys.stderr.write("Cannot parse Markdown from the standard input.\n") + sys.exit(1) + + def convert_file(filename: str) -> None: """ Parse a Markdown file and dump the output to stdout. @@ -94,6 +108,9 @@ def parse_args(args: Sequence[str] | None) -> argparse.Namespace: formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("-v", "--version", action="version", version=version_str) + parser.add_argument( + "--stdin", action="store_true", help="read Markdown from standard input" + ) parser.add_argument( "filenames", nargs="*", help="specify an optional list of files to convert" ) diff --git a/tests/test_cli.py b/tests/test_cli.py index ed8d8205..a2fe51d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,5 @@ +from contextlib import redirect_stdout +import io import pathlib import tempfile from unittest.mock import patch @@ -40,3 +42,56 @@ def mock_input(prompt): with patch("builtins.print") as patched, patch("builtins.input", mock_input): parse.interactive() patched.assert_called() + + +def test_main_no_args_is_interactive(): + with patch("markdown_it.cli.parse.interactive") as mock_interactive: + assert parse.main([]) == 0 + mock_interactive.assert_called_once() + + +def test_parse_output(): + with tempfile.TemporaryDirectory() as tempdir: + path = pathlib.Path(tempdir).joinpath("test.md") + path.write_text("# a b c") + string_io = io.StringIO() + with redirect_stdout(string_io): + assert parse.main([str(path)]) == 0 + assert string_io.getvalue() == "

a b c

\n" + + +def test_stdin(): + with patch("sys.stdin", io.StringIO("# a b c")): + string_io = io.StringIO() + with redirect_stdout(string_io): + assert parse.main(["--stdin"]) == 0 + assert string_io.getvalue() == "

a b c

\n" + + +def test_multiple_files(): + with tempfile.TemporaryDirectory() as tempdir: + path1 = pathlib.Path(tempdir).joinpath("test1.md") + path1.write_text("# file 1") + path2 = pathlib.Path(tempdir).joinpath("test2.md") + path2.write_text("* file 2") + string_io = io.StringIO() + with redirect_stdout(string_io): + assert parse.main([str(path1), str(path2)]) == 0 + assert string_io.getvalue() == "

file 1

\n\n" + + +def test_interactive_render(): + # Simulate user typing '# hello', pressing Ctrl-D (renders), then Ctrl-C (exits) + # This is needed to break the infinite loop in interactive mode on EOF. + mock_input = patch( + "builtins.input", side_effect=["# hello", EOFError, KeyboardInterrupt] + ) + string_io = io.StringIO() + with redirect_stdout(string_io), mock_input: + parse.interactive() + + output = string_io.getvalue() + assert "markdown-it-py" in output # from print_heading + # The rendered output is prefixed by a newline + assert "\n

hello

\n" in output + assert "Exiting" in output