epub2md/tests/test_codeblocks.py
dschlueter 2526ee6789 feat: Sprachlabels für Codeblöcke aus Inhalt und Buchkontext (R1)
- Gedruckte Zeilennummern ("00  #include …") werden für die Erkennung
  ignoriert, bleiben aber im Block stehen.
- Python zusätzlich per ast.parse (Kontrollstrukturen, def/class, Import
  mit Code) und an >>>-Sitzungen erkannt; YAML zählt nicht.
- Vordurchlauf bestimmt die Sprache des Buches aus den sicher erkannten
  Blöcken; passt sie, reichen für kurze Schnipsel schwächere Signale
  (detected_by: "book-context"). Programmausgaben bleiben ohne Label.

Ohne Label: C-Buch 518 -> 132 von 574, Python-Buch 492 -> 326 von 747.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:46:50 +02:00

174 lines
8 KiB
Python

import re
from conftest import Book, Doc
PY = """def read_book(path):
if path:
return path.read_text(encoding="utf-8") # &lt;utf-8&gt;
\treturn None"""
def fenced_blocks(text: str) -> list[tuple[str, str]]:
return re.findall(r"^(`{3,})([\w+-]*)\n(.*?)\n\1$", text, re.M | re.S)
def test_python_indentation_and_special_chars(convert):
body = f'<h1>Code</h1><pre><code class="language-python">{PY}</code></pre>'
run = convert(Book(docs=[Doc("c.xhtml", body)]))
blocks = fenced_blocks(run.chapter("001"))
assert len(blocks) == 1
fence, lang, code = blocks[0]
assert lang == "python"
assert code == PY.replace("&lt;", "<").replace("&gt;", ">")
assert "&lt;" not in code
def test_shell_code_with_pipes_backslashes_and_dollar(convert):
shell = "$ cat datei.txt | grep -v '^#' \\\n | sort &amp;&amp; echo \"$HOME\" > out.txt"
body = f"<h1>Shell</h1><pre>{shell}</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(_, lang, code), = fenced_blocks(run.chapter("001"))
assert code == "$ cat datei.txt | grep -v '^#' \\\n | sort && echo \"$HOME\" > out.txt"
assert lang == "" # no reliable language signal -> no label
def test_backticks_in_code_get_longer_fence(convert):
body = "<h1>T</h1><pre>echo ```nested``` and `x`</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(fence, _, code), = fenced_blocks(run.chapter("001"))
assert fence == "````"
assert code == "echo ```nested``` and `x`"
def test_br_lines_and_highlight_spans_in_pre(convert):
body = ('<h1>C</h1><div class="listing"><pre><span class="kw">#include</span> &lt;stdio.h&gt;<br/><br/>'
'int main(void) {<br/> printf("Hallo\\n");<br/>} </pre></div>')
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(_, lang, code), = fenced_blocks(run.chapter("001"))
assert lang == "c"
assert code == '#include <stdio.h>\n\nint main(void) {\n printf("Hallo\\n");\n} '
def test_listing_div_with_span_pre(convert):
body = ('<h1>K</h1><div class="divUnProgramlisting"><span class="pre"><strong>def</strong> f(x):\n'
' <strong>return</strong> 2 * x\n\n<strong>def</strong> g(x):\n <strong>return</strong> x</span></div>')
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(_, lang, code), = fenced_blocks(run.chapter("001"))
assert lang == "python"
assert code == "def f(x):\n return 2 * x\n\ndef g(x):\n return x"
def test_line_numbers_are_removed(convert):
body = '<h1>L</h1><pre><span class="lineno">1</span>a = 1<br/><span class="lineno">2</span>b = 2</pre>'
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(_, _, code), = fenced_blocks(run.chapter("001"))
assert code == "a = 1\nb = 2"
def test_inline_code_variants(convert):
body = ('<h1>I</h1><p>Nutze <code>a*b_c</code>, <span class="tt">x | y</span> und <code>`tick`</code> '
'sowie <kbd>Strg</kbd>.</p>')
run = convert(Book(docs=[Doc("c.xhtml", body)]))
text = run.chapter("001")
assert "`a*b_c`" in text
assert "`x | y`" in text
assert "`` `tick` ``" in text
assert "<kbd>Strg</kbd>" in text
def test_prose_is_not_forced_into_code(convert):
body = "<h1>P</h1><p>Ein normaler Absatz mit Leerzeichen.</p>"
run = convert(Book(docs=[Doc("c.xhtml", body)]))
assert "```" not in run.chapter("001")
assert "Ein normaler Absatz mit Leerzeichen." in run.chapter("001")
def test_unlabeled_code_is_reported(convert):
run = convert(Book(docs=[Doc("c.xhtml", "<h1>U</h1><pre>foo bar</pre>")]))
assert run.report["code_blocks"]["languages"] == {"(keine)": 1}
def test_code_language_option_fills_only_unlabeled_blocks(convert):
body = "<h1>C</h1><pre>x = compute()</pre><pre>#include &lt;stdio.h&gt;\nint main(void) { return 0; }</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--code-language", "py")
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))]
assert langs == ["python", "c"]
by = {item["language"]: item["detected_by"] for item in run.report["code_blocks"]["items"]}
assert by == {"python": "option", "c": "content"}
def test_code_language_option_rejects_unknown_values(convert):
run = convert(Book(docs=[Doc("c.xhtml", "<h1>C</h1><pre>x</pre>")]), "--code-language", "quatsch")
assert run.rc == 1
assert "quatsch" in run.stderr
def test_print_wrap_markers_are_joined(convert):
body = ("<h1>W</h1><pre> return (s * (s -\n ↪ distance(a, b)) *\n s)\n"
"x = ['a', 'b',\n↪ 'c']</pre>")
run = convert(Book(docs=[Doc("c.xhtml", body)]))
(_, _, code), = fenced_blocks(run.chapter("001"))
assert code == " return (s * (s - distance(a, b)) *\n s)\nx = ['a', 'b', 'c']"
item = run.report["code_blocks"]["items"][0]
assert item["wrap_lines_joined"] == 2
def test_print_wrap_markers_can_be_kept(convert):
body = "<h1>W</h1><pre>a = (1,\n↪ 2)</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-wrap-markers")
(_, _, code), = fenced_blocks(run.chapter("001"))
assert code == "a = (1,\n↪ 2)"
def test_printed_line_numbers_do_not_hide_the_language(convert):
listing = ("00 // Kapitel1/hallo.c\n01 #include &lt;stdio.h&gt;\n \n02 int main(void) {\n"
"03 printf(\"Hallo\\n\");\n04 return 0;\n05 }")
run = convert(Book(docs=[Doc("c.xhtml", f"<h1>N</h1><pre>{listing}</pre>")]))
(_, lang, code), = fenced_blocks(run.chapter("001"))
assert lang == "c"
assert code.startswith("00 // Kapitel1/hallo.c\n01 #include <stdio.h>") # numbers stay in the block
def test_python_is_recognized_by_parsing(convert):
blocks = ["from collections import ChainMap\n\nchain = ChainMap({}, {})\nprint(chain)",
"zahl = -1\nif zahl &lt; 0:\n print(\"negativ\")",
"&gt;&gt;&gt; 2 + 3\n5",
"name: epub2md\nversion: 1", # YAML is valid Python syntax, but no Python code
"[1, 4, 9]", # program output
"x = compute()"] # Python, but too weak without book context
body = "<h1>P</h1>" + "".join(f"<pre>{b}</pre>" for b in blocks)
run = convert(Book(docs=[Doc("c.xhtml", body)]))
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))]
assert langs == ["python", "python", "python", "", "", ""]
def test_weak_signals_follow_the_book_language(convert):
c_listing = "#include &lt;stdio.h&gt;\nint main(void) { return 0; }"
snippets = ["char ch = 65; // 'A'", "if (a || b) {\n x();\n}", "int remove(const char *pathname);",
"struct node {\n int value;\n};", "Ergebnis: 12345", "|00003.14|", "gcc -Wall -o prog prog.c",
"Dezimal: 65; Zeichen: A"]
body = "<h1>C</h1>" + "".join(f"<pre>{c_listing}</pre>" for _ in range(5))
body += "".join(f"<pre>{s}</pre>" for s in snippets)
run = convert(Book(docs=[Doc("c.xhtml", body)]))
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))][5:]
assert langs == ["c", "c", "c", "c", "", "", "", ""]
by = [item["detected_by"] for item in run.report["code_blocks"]["items"]][5:9]
assert by == ["book-context"] * 4
def test_python_book_labels_assignments_but_not_output(convert):
py = "def f(x):\n return x"
snippets = ["foo = decorate(foo)", "random.sample(population, k)", "[x * x for x in range(5)]",
"True", "('A', 'B', 'C')", "Hallo Welt", "4\n3\nnohtyP", "Jane\n42\nPerson(name='Jane')"]
body = "<h1>P</h1>" + "".join(f"<pre>{py}</pre>" for _ in range(5))
body += "".join(f"<pre>{s}</pre>" for s in snippets)
run = convert(Book(docs=[Doc("c.xhtml", body)]))
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))][5:]
assert langs == ["python", "python", "python", "", "", "", "", ""]
def test_mixed_book_gets_no_context_labels(convert):
body = "<h1>M</h1>" + "<pre>def f(x):\n return x</pre>" * 3
body += "<pre>#include &lt;stdio.h&gt;\nint main(void) { return 0; }</pre>" * 3 + "<pre>x = compute();</pre>"
run = convert(Book(docs=[Doc("c.xhtml", body)]))
assert fenced_blocks(run.chapter("001"))[-1][1] == ""