diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7578e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +venv-* +env/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Misc +.DS_Store +.env +.coverage +htmlcov/ diff --git a/markdown_code_runner.py b/markdown_code_runner.py index 901426e..b375652 100644 --- a/markdown_code_runner.py +++ b/markdown_code_runner.py @@ -34,7 +34,7 @@ ```bash markdown-code-runner echo "Hello, world!" ``` -Which will similarly print the output of the code block between next to the output markers. +Which will similarly print the output of the code block between the next output markers. """ @@ -163,22 +163,17 @@ def _bold(text: str) -> str: def _extract_backtick_options(line: str) -> dict[str, str]: """Extract extra information from a line.""" - if "```" not in line: + match = re.search(r"```(?P\w+)", line) + if not match: return {} - language_pattern = r"```(?P\w+) markdown-code-runner" - extra_pattern = r"(?P\w+)=(?P\S+)" - language_match = re.search(language_pattern, line) - assert language_match is not None - language = language_match.group("language") - result = {"language": language} + result = {"language": match.group("language")} - extra_str = line[language_match.end() :] - extra_matches = re.finditer(extra_pattern, extra_str) - - for match in extra_matches: - key, value = match.group("key"), match.group("value") - result[key] = value + # Extract options after markdown-code-runner + if "markdown-code-runner" in line: + extra_str = line[match.end() :] + for option_match in re.finditer(r"(?P\w+)=(?P\S+)", extra_str): + result[option_match.group("key")] = option_match.group("value") return result @@ -203,6 +198,7 @@ class ProcessingState: output: list[str] | None = None new_lines: list[str] = field(default_factory=list) backtick_options: dict[str, Any] = field(default_factory=dict) + backtick_standardize: bool = True def process_line(self, line: str, *, verbose: bool = False) -> None: """Process a line of the Markdown file.""" @@ -219,19 +215,34 @@ def process_line(self, line: str, *, verbose: bool = False) -> None: elif self.section == "output": self.original_output.append(line) else: - self._process_start_markers(line) + processed_line = self._process_start_markers(line, verbose=verbose) + if processed_line is not None: + line = processed_line if self.section != "output": self.new_lines.append(line) - def _process_start_markers(self, line: str) -> None: - for marker in MARKERS: - if marker.endswith(":start") and is_marker(line, marker): + def _process_start_markers( + self, + line: str, + verbose: bool = False, # noqa: FBT001, FBT002, ARG002 + ) -> str | None: + for marker_name in MARKERS: + if marker_name.endswith(":start") and is_marker(line, marker_name): # reset output in case previous output wasn't displayed self.output = None self.backtick_options = _extract_backtick_options(line) - self.section, _ = marker.rsplit(":", 1) # type: ignore[assignment] - return + self.section, _ = marker_name.rsplit(":", 1) # type: ignore[assignment] + + # Standardize backticks if needed + if ( + marker_name == "code:backticks:start" + and self.backtick_standardize + and "markdown-code-runner" in line + ): + return re.sub(r"\smarkdown-code-runner.*", "", line) + return line + return None def _process_output_start(self, line: str) -> None: self.section = "output" @@ -292,7 +303,12 @@ def _process_backtick_code(self, line: str, *, verbose: bool) -> None: self._process_code(line, "code:backticks:end", language, verbose=verbose) -def process_markdown(content: list[str], *, verbose: bool = False) -> list[str]: +def process_markdown( + content: list[str], + *, + verbose: bool = False, + backtick_standardize: bool = True, +) -> list[str]: """Executes code blocks in a list of Markdown-formatted strings and returns the modified list. Parameters @@ -301,6 +317,8 @@ def process_markdown(content: list[str], *, verbose: bool = False) -> list[str]: A list of Markdown-formatted strings. verbose If True, print every line that is processed. + backtick_standardize + If True, clean up markdown-code-runner string from backtick code blocks. Returns ------- @@ -309,14 +327,13 @@ def process_markdown(content: list[str], *, verbose: bool = False) -> list[str]: """ assert isinstance(content, list), "Input must be a list" - state = ProcessingState() + state = ProcessingState(backtick_standardize=backtick_standardize) for i, line in enumerate(content): if verbose: nr = _bold(f"line {i:4d}") print(f"{nr}: {line}") state.process_line(line, verbose=verbose) - return state.new_lines @@ -325,15 +342,33 @@ def update_markdown_file( output_filepath: Path | str | None = None, *, verbose: bool = False, + backtick_standardize: bool = True, ) -> None: - """Rewrite a Markdown file by executing and updating code blocks.""" + """Rewrite a Markdown file by executing and updating code blocks. + + Parameters + ---------- + input_filepath : Path | str + Path to the input Markdown file. + output_filepath : Path | str | None + Path to the output Markdown file. If None, overwrites input file. + verbose : bool + If True, print every line that is processed. + backtick_standardize : bool + If True, clean up markdown-code-runner string from backtick code blocks. + + """ if isinstance(input_filepath, str): # pragma: no cover input_filepath = Path(input_filepath) with input_filepath.open() as f: original_lines = [line.rstrip("\n") for line in f.readlines()] if verbose: print(f"Processing input file: {input_filepath}") - new_lines = process_markdown(original_lines, verbose=verbose) + new_lines = process_markdown( + original_lines, + verbose=verbose, + backtick_standardize=backtick_standardize, + ) updated_content = "\n".join(new_lines).rstrip() + "\n" if verbose: print(f"Writing output to: {output_filepath}") @@ -375,12 +410,29 @@ def main() -> None: action="version", version=f"%(prog)s {__version__}", ) + parser.add_argument( + "--no-backtick-standardize", + action="store_true", + help="Disable backtick standardization (default: enabled for separate output files, disabled for in-place)", + default=False, + ) args = parser.parse_args() input_filepath = Path(args.input) output_filepath = Path(args.output) if args.output is not None else input_filepath - update_markdown_file(input_filepath, output_filepath, verbose=args.verbose) + + # Determine backtick standardization + backtick_standardize = ( + False if args.no_backtick_standardize else args.output is not None + ) + + update_markdown_file( + input_filepath, + output_filepath, + verbose=args.verbose, + backtick_standardize=backtick_standardize, + ) if __name__ == "__main__": diff --git a/tests/test_backtick_stdz.md b/tests/test_backtick_stdz.md new file mode 100644 index 0000000..54dd171 --- /dev/null +++ b/tests/test_backtick_stdz.md @@ -0,0 +1,48 @@ +# Backtick Standardization Test + +This file tests various backtick code block scenarios. + +## Basic Code Block +A simple Python code block with markdown-code-runner: +Currently no options are supported for backtick code blocks. May be used in the future. + +```python markdown-code-runner filename=test1.py +print("Basic test") +``` + +## Code Block with Multiple Options +Testing multiple options in the backtick header: + +```javascript markdown-code-runner filename=test2.js debug=true skip=false +console.log("Multiple options test") +``` + +## Language-only Block +This block should remain unchanged during standardization: + +```rust +fn main() { + println!("No markdown-code-runner"); +} +``` + +## Complex Options Block +Testing complex options and spacing: + +```python markdown-code-runner filename=test3.py debug=true skip=false +print("Testing spaces in options") +``` + +## Empty Language Block +Testing block with no language: + +```markdown-code-runner filename=test4.txt +Just some plain text +``` + +## Mixed Content Block +Testing with additional content after options: + +```python markdown-code-runner filename=test5.py some random text here +print("Mixed content test") +``` diff --git a/tests/test_backtick_stdz.py b/tests/test_backtick_stdz.py new file mode 100644 index 0000000..b296263 --- /dev/null +++ b/tests/test_backtick_stdz.py @@ -0,0 +1,163 @@ +"""Test the standardization functionality of markdown-code-runner.""" + +from pathlib import Path + +from markdown_code_runner import ( + ProcessingState, + _extract_backtick_options, + process_markdown, + update_markdown_file, +) + + +def test_extract_backtick_options_without_markdown_code_runner() -> None: + """Test _extract_backtick_options with basic language extraction.""" + # Test simple language extraction + assert _extract_backtick_options("```python") == {"language": "python"} + assert _extract_backtick_options("```javascript") == {"language": "javascript"} + + # Test with spaces and other content + assert _extract_backtick_options("```python some other text") == { + "language": "python", + } + assert _extract_backtick_options("```rust ") == {"language": "rust"} + + # Test invalid/empty cases + assert _extract_backtick_options("```") == {} + assert _extract_backtick_options("some random text") == {} + + +def test_extract_backtick_options_with_markdown_code_runner() -> None: + """Test _extract_backtick_options with markdown-code-runner.""" + # Test with markdown-code-runner and options + assert _extract_backtick_options( + "```python markdown-code-runner filename=test.py", + ) == { + "language": "python", + "filename": "test.py", + } + + # Test with multiple options + assert _extract_backtick_options( + "```javascript markdown-code-runner filename=test.js debug=true", + ) == { + "language": "javascript", + "filename": "test.js", + "debug": "true", + } + + +def test_process_markdown_standardization() -> None: + """Test process_markdown with standardization enabled/disabled.""" + input_lines = [ + "# Test markdown", + "```python markdown-code-runner filename=test.py", + "print('hello')", + "```", + "Some text", + "```javascript", + "console.log('hi')", + "```", + ] + + # Test with standardization enabled (default) + output = process_markdown(input_lines) + assert output[1] == "```python" # markdown-code-runner should be removed + assert output[5] == "```javascript" # unchanged + + # Test with standardization disabled + output = process_markdown(input_lines, backtick_standardize=False) + assert output[1] == "```python markdown-code-runner filename=test.py" # preserved + assert output[5] == "```javascript" # unchanged + + +def test_process_markdown_mixed_blocks() -> None: + """Test process_markdown with mixed block types.""" + input_lines = [ + "# Mixed blocks", + "```python markdown-code-runner filename=test.py debug=true", + "x = 1", + "```", + "Normal block:", + "```python", + "y = 2", + "```", + "Another runner block:", + "```javascript markdown-code-runner filename=test.js", + "let z = 3;", + "```", + ] + + # With standardization + output = process_markdown(input_lines) + assert output[1] == "```python" + assert output[5] == "```python" + assert output[9] == "```javascript" + + # Without standardization + output = process_markdown(input_lines, backtick_standardize=False) + assert output[1] == "```python markdown-code-runner filename=test.py debug=true" + assert output[5] == "```python" + assert output[9] == "```javascript markdown-code-runner filename=test.js" + + +def test_update_markdown_file_standardization(tmp_path: Path) -> None: + """Test update_markdown_file with standardization options.""" + input_file = tmp_path / "test.md" + output_file = tmp_path / "test_output.md" + + content = """# Test +```python markdown-code-runner filename=test.py +print('hello') +``` +Some text +```javascript +console.log('hi') +```""" + + input_file.write_text(content) + + # Test with output file (standardization enabled by default) + update_markdown_file(input_file, output_file) + output_content = output_file.read_text() + assert "markdown-code-runner" not in output_content + assert "```python\n" in output_content + + # Test with output file (standardization disabled) + update_markdown_file(input_file, output_file, backtick_standardize=False) + output_content = output_file.read_text() + assert "markdown-code-runner" in output_content + + # Test in-place editing (should not standardize by default) + update_markdown_file(input_file, backtick_standardize=False) + input_content = input_file.read_text() + assert "markdown-code-runner" in input_content + + +def test_process_backticks_start() -> None: + """Test the backtick standardization logic via _process_start_markers.""" + # Test with standardization enabled + state = ProcessingState(backtick_standardize=True) + + # Should remove markdown-code-runner and options + line = "```python markdown-code-runner filename=test.py" + result = state._process_start_markers(line) + assert result == "```python" + + # Should preserve non-markdown-code-runner content + line = "```javascript some other content" + result = state._process_start_markers(line) + assert result is None # Not a marker, so returns None + + # Test with standardization disabled + state = ProcessingState(backtick_standardize=False) + + # Should preserve everything + line = "```python markdown-code-runner filename=test.py" + result = state._process_start_markers(line) + assert result == line + + # Non-marker line should return None + line = "```javascript some other content" + result = state._process_start_markers(line) + assert result is None diff --git a/tests/test_backtick_stdz_expected_output.md b/tests/test_backtick_stdz_expected_output.md new file mode 100644 index 0000000..90ccd8b --- /dev/null +++ b/tests/test_backtick_stdz_expected_output.md @@ -0,0 +1,47 @@ +# Backtick Standardization Test + +This file tests various backtick code block scenarios. + +## Basic Code Block +A simple Python code block with markdown-code-runner: + +```python +print("Basic test") +``` + +## Code Block with Multiple Options +Testing multiple options in the backtick header: + +```javascript +console.log("Multiple options test") +``` + +## Language-only Block +This block should remain unchanged during standardization: + +```rust +fn main() { + println!("No markdown-code-runner"); +} +``` + +## Complex Options Block +Testing complex options and spacing: + +```python +print("Testing spaces in options") +``` + +## Empty Language Block +Testing block with no language: + +``` +Just some plain text +``` + +## Mixed Content Block +Testing with additional content after options: + +```python +print("Mixed content test") +``` diff --git a/tests/test.md b/tests/test_main_app.md similarity index 60% rename from tests/test.md rename to tests/test_main_app.md index a64154b..b104475 100644 --- a/tests/test.md +++ b/tests/test_main_app.md @@ -28,4 +28,20 @@ This is another code block! +## Code Block 3 + +Here's a code block using backticks: +Where the `markdown-code-runner` is removed if `backtick_standardize` is enabled and a separate output file is specified. + +```python markdown-code-runner +x = 10 +y = 20 +print(f'The sum of {x} and {y} is {x + y}') +``` + + +The sum of 10 and 20 is 30 + + + That's it for this test file! diff --git a/tests/test_app.py b/tests/test_main_app.py similarity index 90% rename from tests/test_app.py rename to tests/test_main_app.py index 53efd3c..3941a22 100644 --- a/tests/test_app.py +++ b/tests/test_main_app.py @@ -23,9 +23,18 @@ TEST_FOLDER = Path(__file__).parent -def assert_process(input_lines: list[str], expected_output: list[str]) -> None: +def assert_process( + input_lines: list[str], + expected_output: list[str], + *, + backtick_standardize: bool = False, +) -> None: """Assert that the process_markdown function returns the expected output.""" - output = process_markdown(input_lines, verbose=True) + output = process_markdown( + input_lines, + verbose=True, + backtick_standardize=backtick_standardize, + ) assert output == expected_output, f"Expected\n{expected_output}\ngot\n{output}" @@ -56,7 +65,7 @@ def test_process_markdown() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 2: Two code blocks input_lines = [ @@ -95,7 +104,7 @@ def test_process_markdown() -> None: "", MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 3: No code blocks input_lines = [ @@ -106,7 +115,7 @@ def test_process_markdown() -> None: "Some text", "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 4: Single code block with skip marker input_lines = [ @@ -121,7 +130,7 @@ def test_process_markdown() -> None: "More text", ] expected_output = input_lines - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 5: Skip marker at first code block, execute second code block input_lines = [ @@ -160,7 +169,7 @@ def test_process_markdown() -> None: "", MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_remove_md_comment() -> None: @@ -186,9 +195,9 @@ def test_execute_code_block() -> None: def test_main_no_arguments(tmp_path: Path) -> None: """Test the main function with no arguments.""" - test_filepath = TEST_FOLDER / "test.md" + test_filepath = TEST_FOLDER / "test_main_app.md" output_filepath = tmp_path / "output.md" - expected_output_filepath = TEST_FOLDER / "test_expected_output.md" + expected_output_filepath = TEST_FOLDER / "test_main_app_expected_output.md" with patch( "argparse.ArgumentParser.parse_args", @@ -196,6 +205,7 @@ def test_main_no_arguments(tmp_path: Path) -> None: input=test_filepath, output=None, verbose=False, + no_backtick_standardize=True, ), ): main() @@ -206,9 +216,9 @@ def test_main_no_arguments(tmp_path: Path) -> None: def test_main_filepath_argument(tmp_path: Path) -> None: """Test the main function with a filepath argument.""" - test_filepath = TEST_FOLDER / "test.md" + test_filepath = TEST_FOLDER / "test_main_app.md" output_filepath = tmp_path / "output.md" - expected_output_filepath = TEST_FOLDER / "test_expected_output.md" + expected_output_filepath = TEST_FOLDER / "test_main_app_expected_output.md" with patch( "argparse.ArgumentParser.parse_args", @@ -216,6 +226,7 @@ def test_main_filepath_argument(tmp_path: Path) -> None: input=test_filepath, output=str(output_filepath), verbose=False, + no_backtick_standardize=True, ), ): main() @@ -226,9 +237,9 @@ def test_main_filepath_argument(tmp_path: Path) -> None: def test_main_debug_mode(capfd: pytest.CaptureFixture, tmp_path: Path) -> None: """Test the main function with verbose mode enabled.""" - test_filepath = TEST_FOLDER / "test.md" + test_filepath = TEST_FOLDER / "test_main_app.md" output_filepath = tmp_path / "output.md" - expected_output_filepath = TEST_FOLDER / "test_expected_output.md" + expected_output_filepath = TEST_FOLDER / "test_main_app_expected_output.md" with patch( "argparse.ArgumentParser.parse_args", @@ -236,6 +247,7 @@ def test_main_debug_mode(capfd: pytest.CaptureFixture, tmp_path: Path) -> None: input=test_filepath, output=str(output_filepath), verbose=True, + no_backtick_standardize=True, ), ): main() @@ -273,7 +285,7 @@ def test_triple_backticks() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 2: Two code blocks input_lines = [ @@ -312,7 +324,7 @@ def test_triple_backticks() -> None: "", MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 3: No code blocks input_lines = [ @@ -323,7 +335,7 @@ def test_triple_backticks() -> None: "Some text", "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 4: Single code block with skip marker input_lines = [ @@ -338,7 +350,7 @@ def test_triple_backticks() -> None: "More text", ] expected_output = input_lines - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 5: Skip marker at first code block, execute second code block input_lines = [ @@ -377,7 +389,7 @@ def test_triple_backticks() -> None: "", MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_mix_md_and_triple_backticks() -> None: @@ -460,7 +472,7 @@ def test_mix_md_and_triple_backticks() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_preserve_variables_between_code_blocks() -> None: @@ -503,7 +515,7 @@ def test_preserve_variables_between_code_blocks() -> None: "", MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_two_code_blocks_but_first_without_output() -> None: @@ -538,7 +550,7 @@ def test_two_code_blocks_but_first_without_output() -> None: "", MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_bash() -> None: @@ -566,7 +578,7 @@ def test_bash() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 1 (hidden): Single code block input_lines = [ @@ -591,7 +603,7 @@ def test_bash() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_bash_variables() -> None: @@ -621,7 +633,7 @@ def test_bash_variables() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) def test_write_to_file() -> None: @@ -650,7 +662,7 @@ def test_write_to_file() -> None: MARKERS["output:end"], "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test case 2: test without output block input_lines = [ @@ -669,7 +681,7 @@ def test_write_to_file() -> None: "```", "More text", ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) # Test missing filename input_lines = [ @@ -710,7 +722,7 @@ def test_python_code_in_backticks_and_filename(tmp_path: Path) -> None: MARKERS["warning"], MARKERS["output:end"], ] - assert_process(input_lines, expected_output) + assert_process(input_lines, expected_output, backtick_standardize=False) with fname.open("r") as f: assert f.read() == "\n".join(input_lines[2:4]) diff --git a/tests/test_expected_output.md b/tests/test_main_app_expected_output.md similarity index 60% rename from tests/test_expected_output.md rename to tests/test_main_app_expected_output.md index a64154b..b104475 100644 --- a/tests/test_expected_output.md +++ b/tests/test_main_app_expected_output.md @@ -28,4 +28,20 @@ This is another code block! +## Code Block 3 + +Here's a code block using backticks: +Where the `markdown-code-runner` is removed if `backtick_standardize` is enabled and a separate output file is specified. + +```python markdown-code-runner +x = 10 +y = 20 +print(f'The sum of {x} and {y} is {x + y}') +``` + + +The sum of 10 and 20 is 30 + + + That's it for this test file!