Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ cat

# for doc tests
src/test/results
.coverage
src/test/.coverage
6 changes: 5 additions & 1 deletion docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,12 @@ CAT3_PAGES = $(patsubst $(SRC3_DIR)/%.md,$(CAT3_DIR)/%.3,$(MAN3_FILES))
# Default target
all: doc web cat

# Run unit tests with coverage (requires pytest and pytest-cov)
test:
python3 -m pytest

# Target to do symlinks and pandoc-compatible conversion
preprocess:
preprocess: test
./src/scripts/link_readmes.sh && python3 src/scripts/md_roff_compat.py

# Target to generate all man pages
Expand Down
4 changes: 4 additions & 0 deletions docs/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
testpaths = src/test
python_files = test_*.py
addopts = --cov=src/scripts --cov-report=term-missing
2 changes: 2 additions & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
git+https://github.com/executablebooks/sphinx-external-toc.git@v0.3.1
pytest
pytest-cov
sphinx
sphinx-autobuild
myst-parser
Expand Down
16 changes: 13 additions & 3 deletions docs/src/scripts/extract_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,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
Expand All @@ -56,7 +60,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]
Comment thread
oharboe marked this conversation as resolved.

# This will disambiguate cases where different level headers share the same name.
second = [rf"### ({level3[-1]})(.*?)## ({level2[closest_level2_idx]})"]
Expand All @@ -66,7 +75,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([])
Expand Down
47 changes: 27 additions & 20 deletions docs/src/scripts/md_roff_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -112,7 +113,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)
Expand Down Expand Up @@ -142,19 +143,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: {counts}\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()
Expand Down Expand Up @@ -207,7 +215,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 = (
Expand All @@ -219,8 +227,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
Expand Down
49 changes: 49 additions & 0 deletions docs/src/test/TESTING.md
Original file line number Diff line number Diff line change
@@ -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 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.
8 changes: 8 additions & 0 deletions docs/src/test/conftest.py
Original file line number Diff line number Diff line change
@@ -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__))
Loading
Loading