From 6c1ed57995fe099e1d44ea77f671d2abe922e78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 21:15:48 +0100 Subject: [PATCH 01/17] docs: apply black formatting to md_roff_compat.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index f0c420a943f..d21007d99e8 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -134,11 +134,13 @@ def man2_translate(doc, path): global_see_also = extract_global_see_also(text) print(f"{os.path.basename(doc)}") - print(f"""Names: {len(func_names)},\ + print( + f"""Names: {len(func_names)},\ Desc: {len(func_descs)},\ Syn: {len(func_synopsis)},\ Options: {len(func_options)},\ - Args: {len(func_args)}""") + Args: {len(func_args)}""" + ) print(f"Global Examples: {'Found' if global_examples else 'None'}") print(f"Global See Also: {'Found' if global_see_also else 'None'}") From 1109a51a9ae8b57eb26f3ef85e5ace312584e65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:40:27 +0100 Subject: [PATCH 02/17] docs: replace cryptic assert with descriptive ValueError in md_roff_compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion error gave no hint as to what caused the mismatch or how to fix it. Replace with a ValueError that names the file, labels each count with what it measures, highlights only the mismatched entries, and lists the two common root causes. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 33 ++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index d21007d99e8..5988354699b 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -144,19 +144,26 @@ def man2_translate(doc, path): print(f"Global Examples: {'Found' if global_examples else 'None'}") print(f"Global See Also: {'Found' if global_see_also else 'None'}") - assert ( - len(func_names) - == len(func_descs) - == len(func_synopsis) - == len(func_options) - == len(func_args) - ), f"""Counts for all 5 categories must match up.\n - Names: {len(func_names)}\n - Descs: {len(func_descs)}\n - Synopsis: {len(func_synopsis)}\n - Options: {len(func_options)}\n - Args: {len(func_args)}\n - """ + counts = { + "Names (```tcl first word)": len(func_names), + "Descs (### header to ```tcl)": len(func_descs), + "Synopsis (```tcl blocks)": len(func_synopsis), + "Options (### section count)": len(func_options), + "Args (### section count)": len(func_args), + } + expected = len(func_synopsis) + mismatches = {k: v for k, v in counts.items() if v != expected} + if mismatches: + raise ValueError( + f"Documentation parse error in {os.path.basename(doc)}: " + f"all 5 categories must have the same count.\n" + f" Counts: { {k: v for k, v in counts.items()} }\n" + f" Mismatches: {mismatches}\n" + f"Common causes:\n" + f" - Options/Args > others: a '###' section heading has no ```tcl block " + f"(e.g. a feature description added as a ### section instead of ####)\n" + f" - Names/Synopsis > others: a ```tcl block exists outside a ### section" + ) for func_id in range(len(func_synopsis)): manpage = ManPage() From 831b342baff13600913e69ca845f05b521221b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:44:43 +0100 Subject: [PATCH 03/17] docs: replace bare exit() with ValueError in man3_translate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exit() inside a library function bypasses all callers and produces no error message. Raise ValueError with context instead. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index 5988354699b..b39609e7491 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -228,8 +228,7 @@ def man3_translate(doc, path): manpage = ManPage() manpage.name = f"{module}-{num}" if "with-total" in manpage.name: - print(parts) - exit() + raise ValueError(f"Unexpected 'with-total' token in {doc}: {parts}") manpage.synopsis = "N/A." manpage.desc = f"Type: {level}\n\n{message}" # man3 messages typically don't have examples or see also From cc916d1b0f555b49467effaf4bcdd69991135ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:45:00 +0100 Subject: [PATCH 04/17] docs: replace assert with ValueError in extract_headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assert is disabled by python -O and raises AssertionError, which gives no context. Use an explicit ValueError with the bad value included. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/extract_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/scripts/extract_utils.py b/docs/src/scripts/extract_utils.py index 39c2503ead3..a81e3d7d4e6 100644 --- a/docs/src/scripts/extract_utils.py +++ b/docs/src/scripts/extract_utils.py @@ -7,7 +7,8 @@ def extract_headers(text, level=1): - assert isinstance(level, int) and level >= 1 + if not isinstance(level, int) or level < 1: + raise ValueError(f"level must be a positive integer, got {level!r}") pattern = r"^#{%d}\s+(.*)$" % level headers = re.findall(pattern, text, flags=re.MULTILINE) # TODO: Handle developer commands From 117f1ea65ef8c10960c43b3be919b89003e889a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:45:17 +0100 Subject: [PATCH 05/17] docs: open files with explicit utf-8 encoding in md_roff_compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open() without encoding uses the platform default, which differs across locales and OSes. Force utf-8 for reproducible behaviour. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index b39609e7491..74d83a4a92c 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -112,7 +112,7 @@ def man2(path=DEST_DIR2): def man2_translate(doc, path): - with open(doc) as f: + with open(doc, encoding="utf-8") as f: text = f.read() # new function names (reading tcl synopsis + convert gui:: to gui_) func_names = extract_tcl_command(text) @@ -216,7 +216,7 @@ def man3(path=DEST_DIR3): def man3_translate(doc, path): - with open(doc) as f: + with open(doc, encoding="utf-8") as f: for line in f: parts = line.split() module, num, message, level = ( From 23f83e91b4ccc1f32fcf49b61bbd800ace5fae68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:45:35 +0100 Subject: [PATCH 06/17] docs: fix silent str.find('#') == -1 in extract_arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a section has no '#' character, find() returns -1 and a[-1:] silently slices the last character instead of producing an empty string. Make the no-match case explicit. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/extract_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/scripts/extract_utils.py b/docs/src/scripts/extract_utils.py index a81e3d7d4e6..75db56a7bd1 100644 --- a/docs/src/scripts/extract_utils.py +++ b/docs/src/scripts/extract_utils.py @@ -67,7 +67,8 @@ def extract_arguments(text): # print(regex) # get text until the next header a = match[0][1] - a = a[a.find("#") :] + hash_pos = a.find("#") + a = a[hash_pos:] if hash_pos != -1 else "" options = a.split("####")[1:] if not options: final_options.append([]) From 5187aec9d5baa936bbe7daaf89ed54d6201250c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:45:51 +0100 Subject: [PATCH 07/17] docs: guard against empty candidate list in extract_arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If no level-2 header follows the last level-3 header the list comprehension is empty and [0] raises an IndexError with no context. Raise a ValueError with the offending header name instead. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/extract_utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/src/scripts/extract_utils.py b/docs/src/scripts/extract_utils.py index 75db56a7bd1..a67d28cbbd2 100644 --- a/docs/src/scripts/extract_utils.py +++ b/docs/src/scripts/extract_utils.py @@ -57,7 +57,12 @@ def extract_arguments(text): closest_level2 = [ text.find(f"## {x}") - text.find(f"### {level3[-1]}") for x in level2 ] - closest_level2_idx = [idx for idx, x in enumerate(closest_level2) if x > 0][0] + candidates = [idx for idx, x in enumerate(closest_level2) if x > 0] + if not candidates: + raise ValueError( + f"No level-2 header found after the last level-3 header '{level3[-1]}'" + ) + closest_level2_idx = candidates[0] # This will disambiguate cases where different level headers share the same name. second = [rf"### ({level3[-1]})(.*?)## ({level2[closest_level2_idx]})"] From ad58f5ea1d6577ecd9fbb4619a0785eed029ab16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:46:06 +0100 Subject: [PATCH 08/17] docs: avoid O(n^2) string concatenation in extract_global_examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeated str += in a loop copies the growing string on every iteration. Collect lines into a list and join once at the end. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index 74d83a4a92c..b74c404071f 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -18,7 +18,7 @@ def extract_global_examples(text): Returns the examples text or None if not found. """ lines = text.split("\n") - examples_text = "" + collected = [] in_examples = False for line in lines: @@ -28,9 +28,10 @@ def extract_global_examples(text): elif line.strip().startswith("####") and in_examples: break elif in_examples: - examples_text += line + "\n" + collected.append(line) - return examples_text.strip() if examples_text.strip() else None + examples_text = "\n".join(collected).strip() + return examples_text if examples_text else None def extract_global_see_also(text): From dfd07030f39431bb1f29641c9bd36c7013da1852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:58:20 +0100 Subject: [PATCH 09/17] docs: add pytest unit tests for extract_utils and md_roff_compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 44 tests covering extract_headers, extract_tcl_command/code, extract_description, extract_arguments, extract_tables, parse_switch, check_function_signatures, man2_translate, and man3_translate. man2/man3 tests use unittest.mock to avoid filesystem side-effects; the full suite runs in under 0.1 s. Coverage: extract_utils 86%, md_roff_compat 80%. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/test/conftest.py | 8 + docs/src/test/test_extract_utils.py | 293 +++++++++++++++++++++++++++ docs/src/test/test_md_roff_compat.py | 152 ++++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 docs/src/test/conftest.py create mode 100644 docs/src/test/test_extract_utils.py create mode 100644 docs/src/test/test_md_roff_compat.py diff --git a/docs/src/test/conftest.py b/docs/src/test/conftest.py new file mode 100644 index 00000000000..30aa4e239d5 --- /dev/null +++ b/docs/src/test/conftest.py @@ -0,0 +1,8 @@ +## SPDX-License-Identifier: BSD-3-Clause +## Copyright (c) 2024-2026, The OpenROAD Authors + +import os +import sys + +# Make the symlinked scripts importable when pytest is invoked from any directory. +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/docs/src/test/test_extract_utils.py b/docs/src/test/test_extract_utils.py new file mode 100644 index 00000000000..c24912f202a --- /dev/null +++ b/docs/src/test/test_extract_utils.py @@ -0,0 +1,293 @@ +## SPDX-License-Identifier: BSD-3-Clause +## Copyright (c) 2024-2026, The OpenROAD Authors + +import pytest +from extract_utils import ( + check_function_signatures, + extract_arguments, + extract_description, + extract_headers, + extract_tables, + extract_tcl_code, + extract_tcl_command, + parse_switch, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +MINIMAL_MD = """\ +## Commands + +### Foo Command + +Foo description. + +```tcl +foo_cmd + [-opt val] +``` + +#### Options + +| Switch Name | Description | +| ---- | ---- | +| `-opt` | An option. | + +## License + +BSD 3-Clause. +""" + +TWO_SECTION_MD = """\ +## Commands + +### First Command + +First description. + +```tcl +first_command + [-a val] +``` + +#### Options + +| Switch Name | Description | +| ---- | ---- | +| `-a` | Option a. | + +#### Extra Args + +| Switch Name | Description | +| ---- | ---- | +| `pos` | Positional arg. | + +### Second Command + +Second description. + +```tcl +second_command + [-b val] +``` + +#### Options + +| Switch Name | Description | +| ---- | ---- | +| `-b` | Option b. | + +## License + +BSD 3-Clause. +""" + + +# --------------------------------------------------------------------------- +# extract_headers +# --------------------------------------------------------------------------- + + +class TestExtractHeaders: + def test_level1(self): + assert extract_headers("# Hello\n## World\n", 1) == ["Hello"] + + def test_level2(self): + assert extract_headers("# Hello\n## World\n", 2) == ["World"] + + def test_level3_multiple(self): + assert extract_headers("### Foo\n### Bar\n", 3) == ["Foo", "Bar"] + + def test_empty_text(self): + assert extract_headers("no headers", 2) == [] + + def test_invalid_level_zero(self): + with pytest.raises(ValueError): + extract_headers("# Hi", 0) + + def test_invalid_level_negative(self): + with pytest.raises(ValueError): + extract_headers("# Hi", -1) + + def test_invalid_level_string(self): + with pytest.raises(ValueError): + extract_headers("# Hi", "1") + + +# --------------------------------------------------------------------------- +# extract_tcl_command +# --------------------------------------------------------------------------- + + +class TestExtractTclCommand: + def test_single(self): + md = "```tcl\nfoo_bar\n [-opt]\n```\n" + assert extract_tcl_command(md) == ["foo_bar"] + + def test_multiple(self): + md = "```tcl\ncmd_one\n```\n```tcl\ncmd_two\n```\n" + assert extract_tcl_command(md) == ["cmd_one", "cmd_two"] + + def test_no_tcl_blocks(self): + assert extract_tcl_command("No TCL here.") == [] + + def test_namespace_command(self): + md = "```tcl\ngui::show\n```\n" + assert extract_tcl_command(md) == ["gui::show"] + + +# --------------------------------------------------------------------------- +# extract_tcl_code +# --------------------------------------------------------------------------- + + +class TestExtractTclCode: + def test_single_block(self): + md = "```tcl\nfoo_bar\n [-opt]\n```\n" + result = extract_tcl_code(md) + assert len(result) == 1 + assert "foo_bar" in result[0] + + def test_filters_gcd_script(self): + md = "```tcl\n./test/gcd.tcl\n```\n```tcl\nreal_cmd\n```\n" + result = extract_tcl_code(md) + assert len(result) == 1 + assert "real_cmd" in result[0] + + def test_multiple_blocks(self): + md = "```tcl\ncmd1\n```\n```tcl\ncmd2\n```\n" + assert len(extract_tcl_code(md)) == 2 + + def test_no_blocks(self): + assert extract_tcl_code("plain text") == [] + + +# --------------------------------------------------------------------------- +# extract_description +# --------------------------------------------------------------------------- + + +class TestExtractDescription: + def test_single_section(self): + descs = extract_description(MINIMAL_MD) + assert len(descs) == 1 + assert "Foo description" in descs[0] + + def test_two_sections(self): + descs = extract_description(TWO_SECTION_MD) + assert len(descs) == 2 + assert "First description" in descs[0] + assert "Second description" in descs[1] + + def test_strips_whitespace(self): + descs = extract_description(MINIMAL_MD) + assert descs[0] == descs[0].strip() + + +# --------------------------------------------------------------------------- +# extract_arguments +# --------------------------------------------------------------------------- + + +class TestExtractArguments: + def test_minimal_has_options(self): + options, _ = extract_arguments(MINIMAL_MD) + assert len(options) == 1 + assert any("-opt" in row for row in options[0]) + + def test_minimal_no_args(self): + _, args = extract_arguments(MINIMAL_MD) + assert args[0] == [] + + def test_two_sections_counts_match(self): + options, args = extract_arguments(TWO_SECTION_MD) + assert len(options) == 2 + assert len(args) == 2 + + def test_extra_args_table_captured(self): + _, args = extract_arguments(TWO_SECTION_MD) + assert any("pos" in row for row in args[0]) + + def test_section_without_options(self): + md = ( + "## Commands\n\n" + "### No Options Command\n\n" + "Just a description.\n\n" + "```tcl\nno_opts_cmd\n```\n\n" + "## License\n" + ) + options, args = extract_arguments(md) + assert options[0] == [] + assert args[0] == [] + + def test_no_level2_after_last_level3_raises(self): + md = "## Commands\n\n### Only Section\n\nDesc.\n\n```tcl\ncmd\n```\n" + with pytest.raises(ValueError, match="No level-2 header found"): + extract_arguments(md) + + +# --------------------------------------------------------------------------- +# extract_tables +# --------------------------------------------------------------------------- + + +class TestExtractTables: + def test_basic_row(self): + text = "#### Options\n| `-opt` | An option. |\n" + assert any("-opt" in r for r in extract_tables(text)) + + def test_skips_header_row(self): + text = "| Switch Name | Description |\n| ---- | ---- |\n| `-opt` | desc |\n" + rows = extract_tables(text) + assert not any("Switch Name" in r for r in rows) + assert not any("---" in r for r in rows) + + def test_skips_html(self): + text = "| `opt` | desc |\n| `-real` | real |\n" + rows = extract_tables(text) + assert not any("" in r for r in rows) + assert any("-real" in r for r in rows) + + def test_empty_text(self): + assert extract_tables("no table here") == [] + + +# --------------------------------------------------------------------------- +# parse_switch +# --------------------------------------------------------------------------- + + +class TestParseSwitch: + def test_simple(self): + key, val = parse_switch("| `-opt` | An option. |") + assert key == "-opt" + assert "An option" in val + + def test_backticks_stripped(self): + key, _ = parse_switch("| `name` | The name. |") + assert key == "name" + + def test_pipe_in_description(self): + # Content containing | — key must still be correct + key, _ = parse_switch("| `-flag` | Either a | b. |") + assert key == "-flag" + + +# --------------------------------------------------------------------------- +# check_function_signatures +# --------------------------------------------------------------------------- + + +class TestCheckFunctionSignatures: + def test_matching(self): + assert check_function_signatures("-a -b -c", "-c -b -a") is True + + def test_mismatch_returns_false(self, capsys): + assert check_function_signatures("-a -b", "-a -c") is False + + def test_mismatch_prints_diff(self, capsys): + check_function_signatures("-a -b", "-a -c") + out = capsys.readouterr().out + assert "-b" in out or "-c" in out diff --git a/docs/src/test/test_md_roff_compat.py b/docs/src/test/test_md_roff_compat.py new file mode 100644 index 00000000000..0f447875169 --- /dev/null +++ b/docs/src/test/test_md_roff_compat.py @@ -0,0 +1,152 @@ +## SPDX-License-Identifier: BSD-3-Clause +## Copyright (c) 2024-2026, The OpenROAD Authors + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from md_roff_compat import man2_translate, man3_translate + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +MINIMAL_MD = """\ +## Commands + +### Foo Command + +Foo description. + +```tcl +foo_cmd + [-opt val] +``` + +#### Options + +| Switch Name | Description | +| ---- | ---- | +| `-opt` | An option. | + +## License + +BSD 3-Clause. +""" + +# Two level-3 headers but only one ```tcl block — triggers the count check. +MISMATCH_MD = """\ +## Commands + +### Real Command + +Real description. + +```tcl +real_cmd +``` + +### Feature Description + +No TCL block here. + +## License + +BSD 3-Clause. +""" + +# Three message lines in the format used by messages.txt files. +MESSAGES_TXT = ( + "GPL 0002 file.cpp:778 DBU: {} INFO https://example.com\n" + "GPL 0003 file.cpp:806 SiteSize: {} {} INFO https://example.com\n" + "GPL 0004 file.cpp:807 CoreAreaLxLy: {} {} INFO https://example.com\n" +) + + +# --------------------------------------------------------------------------- +# man2_translate +# --------------------------------------------------------------------------- + + +class TestMan2Translate: + def test_valid_md_writes_manpage(self, tmp_path): + doc = tmp_path / "test.md" + doc.write_text(MINIMAL_MD, encoding="utf-8") + man2_translate(str(doc), str(tmp_path)) + assert (tmp_path / "foo_cmd.md").exists() + + def test_count_mismatch_raises_value_error(self, tmp_path): + doc = tmp_path / "mismatch.md" + doc.write_text(MISMATCH_MD, encoding="utf-8") + with pytest.raises(ValueError, match="Documentation parse error"): + man2_translate(str(doc), str(tmp_path)) + + def test_error_message_names_the_file(self, tmp_path): + doc = tmp_path / "mymodule.md" + doc.write_text(MISMATCH_MD, encoding="utf-8") + with pytest.raises(ValueError, match="mymodule.md"): + man2_translate(str(doc), str(tmp_path)) + + def test_error_message_shows_common_causes(self, tmp_path): + doc = tmp_path / "mismatch.md" + doc.write_text(MISMATCH_MD, encoding="utf-8") + with pytest.raises(ValueError, match="###"): + man2_translate(str(doc), str(tmp_path)) + + def test_manpage_write_called_for_each_function(self, tmp_path): + doc = tmp_path / "test.md" + doc.write_text(MINIMAL_MD, encoding="utf-8") + with patch("md_roff_compat.ManPage") as MockManPage: + instance = MagicMock() + MockManPage.return_value = instance + man2_translate(str(doc), str(tmp_path)) + instance.write_roff_file.assert_called_once_with(str(tmp_path)) + + def test_translator_sample_parses_cleanly(self, tmp_path): + """Smoke-test: the project's own sample fixture must parse without error.""" + sample = os.path.join(os.path.dirname(__file__), "translator.md") + man2_translate(sample, str(tmp_path)) + + +# --------------------------------------------------------------------------- +# man3_translate +# --------------------------------------------------------------------------- + + +class TestMan3Translate: + def test_valid_messages_produce_manpages(self, tmp_path): + doc = tmp_path / "messages.txt" + doc.write_text(MESSAGES_TXT, encoding="utf-8") + man3_translate(str(doc), str(tmp_path)) + assert (tmp_path / "GPL-0002.md").exists() + assert (tmp_path / "GPL-0003.md").exists() + assert (tmp_path / "GPL-0004.md").exists() + + def test_with_total_in_name_raises(self, tmp_path): + # "with-total" appearing as the num field ends up in manpage.name. + doc = tmp_path / "bad.txt" + doc.write_text( + "GPL with-total file.cpp:1 message INFO https://example.com\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="with-total"): + man3_translate(str(doc), str(tmp_path)) + + def test_error_names_the_file(self, tmp_path): + doc = tmp_path / "badmsgs.txt" + doc.write_text( + "GPL with-total file.cpp:1 message INFO https://example.com\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="badmsgs.txt"): + man3_translate(str(doc), str(tmp_path)) + + def test_manpage_write_called_per_line(self, tmp_path): + doc = tmp_path / "messages.txt" + doc.write_text(MESSAGES_TXT, encoding="utf-8") + with patch("md_roff_compat.ManPage") as MockManPage: + instance = MagicMock() + MockManPage.return_value = instance + man3_translate(str(doc), str(tmp_path)) + assert instance.write_roff_file.call_count == 3 From 75d6bbf18ac2e38248d0300f1966822aa4d8bd11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:58:25 +0100 Subject: [PATCH 10/17] docs: wire pytest into Makefile and requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'make test' target (runs pytest from docs/) and pytest.ini configuring testpaths, python_files, and --cov defaults. Add pytest and pytest-cov to requirements.txt so CI installs them alongside the existing Sphinx dependencies. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/Makefile | 4 ++++ docs/pytest.ini | 4 ++++ docs/requirements.txt | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 docs/pytest.ini diff --git a/docs/Makefile b/docs/Makefile index 7e8db0ed337..413b74cccf9 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -61,6 +61,10 @@ all: doc web cat preprocess: ./src/scripts/link_readmes.sh && python3 src/scripts/md_roff_compat.py +# Run unit tests with coverage (requires pytest and pytest-cov) +test: + python3 -m pytest + # Target to generate all man pages doc: $(MAN1_PAGES) $(MAN2_PAGES) $(MAN3_PAGES) diff --git a/docs/pytest.ini b/docs/pytest.ini new file mode 100644 index 00000000000..70aaafed125 --- /dev/null +++ b/docs/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = src/test +python_files = test_*.py +addopts = --cov=src/scripts --cov-report=term-missing diff --git a/docs/requirements.txt b/docs/requirements.txt index 8ec92aa0063..5f49dac0fad 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,6 @@ git+https://github.com/executablebooks/sphinx-external-toc.git@v0.3.1 +pytest +pytest-cov sphinx sphinx-autobuild myst-parser From ce6905e7a4c9f2946e73e7d3d21a8691a930016c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:58:43 +0100 Subject: [PATCH 11/17] docs: document test suite purpose and CI integration in TESTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains what each test file covers, how to run locally, and how to wire 'make test' into CI before the existing 'make preprocess' step. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/test/TESTING.md | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/src/test/TESTING.md diff --git a/docs/src/test/TESTING.md b/docs/src/test/TESTING.md new file mode 100644 index 00000000000..084cc4b6a8a --- /dev/null +++ b/docs/src/test/TESTING.md @@ -0,0 +1,49 @@ +# Documentation Script Tests + +Unit tests for the Python scripts that convert module `README.md` files +into man-page markdown (`md_roff_compat.py`, `extract_utils.py`). + +## What is tested + +| Test file | Covers | +| --------- | ------ | +| `test_extract_utils.py` | `extract_headers`, `extract_tcl_command`, `extract_tcl_code`, `extract_description`, `extract_arguments`, `extract_tables`, `parse_switch`, `check_function_signatures` | +| `test_md_roff_compat.py` | `man2_translate` (valid input, count mismatch, error message content), `man3_translate` (valid input, `with-total` guard, per-line write calls) | + +`man2_translate` and `man3_translate` use `unittest.mock` to replace +`ManPage.write_roff_file` where needed, so no files outside `tmp_path` +are written. The full suite runs in under 0.1 s. + +## Running locally + +```bash +# from the docs/ directory +pip install pytest pytest-cov # once; already in requirements.txt +make test +``` + +Or directly: + +```bash +cd docs/src/test +python3 -m pytest test_extract_utils.py test_md_roff_compat.py -v \ + --cov=../scripts --cov-report=term-missing +``` + +## CI integration + +The `preprocess` target (called by Jenkins as `make preprocess -C docs`) +runs the integration-level check: it links all module READMEs and runs +`md_roff_compat.py` over every one of them, raising `ValueError` on any +structural mismatch. + +The unit tests are run via `make test` and should be added as a separate +CI step alongside `make preprocess`. To add it to a GitHub Actions +workflow: + +```yaml +- name: Run doc script unit tests + run: | + pip install -r docs/requirements.txt + make -C docs test +``` From 8cbb148af6f35a9bf2807fe951c9f3d7422259a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 20:59:59 +0100 Subject: [PATCH 12/17] docs: run unit tests before preprocess in Makefile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make 'preprocess' depend on 'test' so the unit tests always run first when CI calls 'make preprocess -C docs'. A broken parser will fail fast before attempting to generate man-pages. Update TESTING.md to reflect the new dependency. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 413b74cccf9..604011ff3c1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -57,14 +57,14 @@ CAT3_PAGES = $(patsubst $(SRC3_DIR)/%.md,$(CAT3_DIR)/%.3,$(MAN3_FILES)) # Default target all: doc web cat -# Target to do symlinks and pandoc-compatible conversion -preprocess: - ./src/scripts/link_readmes.sh && python3 src/scripts/md_roff_compat.py - # Run unit tests with coverage (requires pytest and pytest-cov) test: python3 -m pytest +# Target to do symlinks and pandoc-compatible conversion +preprocess: test + ./src/scripts/link_readmes.sh && python3 src/scripts/md_roff_compat.py + # Target to generate all man pages doc: $(MAN1_PAGES) $(MAN2_PAGES) $(MAN3_PAGES) From beedbf8193cfe6ae325ea12f93e75194316eb606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 21:00:10 +0100 Subject: [PATCH 13/17] docs: update TESTING.md to reflect preprocess -> test dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/test/TESTING.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/src/test/TESTING.md b/docs/src/test/TESTING.md index 084cc4b6a8a..c8c5de58852 100644 --- a/docs/src/test/TESTING.md +++ b/docs/src/test/TESTING.md @@ -37,13 +37,13 @@ runs the integration-level check: it links all module READMEs and runs `md_roff_compat.py` over every one of them, raising `ValueError` on any structural mismatch. -The unit tests are run via `make test` and should be added as a separate -CI step alongside `make preprocess`. To add it to a GitHub Actions -workflow: - -```yaml -- name: Run doc script unit tests - run: | - pip install -r docs/requirements.txt - make -C docs test +The unit tests run automatically before the integration check because +`preprocess` depends on `test` in the Makefile: + +```makefile +preprocess: test + ./src/scripts/link_readmes.sh && python3 src/scripts/md_roff_compat.py ``` + +Any CI step calling `make preprocess -C docs` will run the unit tests +first. A broken parser fails fast before attempting to generate man-pages. From f5c2f7453105d0f9b234e38bf5eda9a5b2bfae48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 21:01:37 +0100 Subject: [PATCH 14/17] docs: ignore pytest .coverage files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/.gitignore b/docs/.gitignore index 5f4b7bbeb0b..a33b4348614 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -8,3 +8,5 @@ cat # for doc tests src/test/results +.coverage +src/test/.coverage From 01be1584d873b8a3b2bf7cb6e276ba3f2f409eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 21:22:46 +0100 Subject: [PATCH 15/17] docs: remove redundant dict comprehension in error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {k: v for k, v in counts.items()} is just counts. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index b74c404071f..e250f18a3d7 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -158,7 +158,7 @@ def man2_translate(doc, path): raise ValueError( f"Documentation parse error in {os.path.basename(doc)}: " f"all 5 categories must have the same count.\n" - f" Counts: { {k: v for k, v in counts.items()} }\n" + f" Counts: {counts}\n" f" Mismatches: {mismatches}\n" f"Common causes:\n" f" - Options/Args > others: a '###' section heading has no ```tcl block " From 09d266c11c6ce40d04c0ed27bea54b38b0baea55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 21:23:33 +0100 Subject: [PATCH 16/17] docs: guard extract_arguments against empty level-3 header list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessing level3[-1] in the ValueError message would raise an IndexError if the document has no ### sections. Return [], [] early in that case. Add a test to cover the new branch. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/extract_utils.py | 3 +++ docs/src/test/test_extract_utils.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/docs/src/scripts/extract_utils.py b/docs/src/scripts/extract_utils.py index a67d28cbbd2..42191c6667b 100644 --- a/docs/src/scripts/extract_utils.py +++ b/docs/src/scripts/extract_utils.py @@ -46,6 +46,9 @@ def extract_arguments(text): level2 = extract_headers(text, 2) level3 = extract_headers(text, 3) + if not level3: + return [], [] + # form these 2 regex styles. # ### Header 1 {text} ### Header2; ### Header n-2 {text} ### Header n-1 # ### Header n {text} ## closest_level2_header diff --git a/docs/src/test/test_extract_utils.py b/docs/src/test/test_extract_utils.py index c24912f202a..ef6e0454e59 100644 --- a/docs/src/test/test_extract_utils.py +++ b/docs/src/test/test_extract_utils.py @@ -222,6 +222,12 @@ def test_section_without_options(self): assert options[0] == [] assert args[0] == [] + def test_no_level3_returns_empty(self): + md = "## Commands\n\nNo sections.\n\n## License\n" + options, args = extract_arguments(md) + assert options == [] + assert args == [] + def test_no_level2_after_last_level3_raises(self): md = "## Commands\n\n### Only Section\n\nDesc.\n\n```tcl\ncmd\n```\n" with pytest.raises(ValueError, match="No level-2 header found"): From 894b8bce1ec2df9e4e9f4d30f303ce6f3ff89d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Wed, 11 Mar 2026 21:45:04 +0100 Subject: [PATCH 17/17] docs: reformat md_roff_compat.py with black 26.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI uses psf/black@stable (26.x); locally 25.x was installed, masking a formatting difference. Update to match. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Øyvind Harboe --- docs/src/scripts/md_roff_compat.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/src/scripts/md_roff_compat.py b/docs/src/scripts/md_roff_compat.py index e250f18a3d7..fdfcf57bbaa 100644 --- a/docs/src/scripts/md_roff_compat.py +++ b/docs/src/scripts/md_roff_compat.py @@ -135,13 +135,11 @@ def man2_translate(doc, path): global_see_also = extract_global_see_also(text) print(f"{os.path.basename(doc)}") - print( - f"""Names: {len(func_names)},\ + print(f"""Names: {len(func_names)},\ Desc: {len(func_descs)},\ Syn: {len(func_synopsis)},\ Options: {len(func_options)},\ - Args: {len(func_args)}""" - ) + Args: {len(func_args)}""") print(f"Global Examples: {'Found' if global_examples else 'None'}") print(f"Global See Also: {'Found' if global_see_also else 'None'}")