Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 4 additions & 6 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ jobs:
- name: Install deps
run: |
python -m pip install --upgrade pip setuptools
pip install mkdocs
pip install -r mkdocs_plugin/requirements.txt
pip install -r requirements-docs.txt

- name: Install current plugin
- name: Generate test docs
run: |
pip install mkdocs_plugin/
pip freeze
python generate_test_docs.py

- name: Build docs
run: |
python -m mkdocs build --clean --site-dir html --config-file mkdocs.yml
zensical build -c
14 changes: 7 additions & 7 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ version: 2
build:
os: ubuntu-22.04
tools:
python: "3.7"
mkdocs:
configuration: mkdocs.yml
formats: all
python:
install:
- requirements: mkdocs_plugin/requirements.txt
python: "3"
commands:
- pip install -r requirements-docs.txt
- python generate_test_docs.py
- zensical build -c
- mkdir -p $READTHEDOCS_OUTPUT/html
- cp -r site/* $READTHEDOCS_OUTPUT/html/
48 changes: 28 additions & 20 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,32 +90,40 @@ More details on test-runner usage in [test-runner docs](test-runner.md)
## Generating documentation

[Full docs](https://test-definitions.readthedocs.io) are generated from existing
YAML files. Resulting markdown files are not stored in the repository. In order
to generate documentation locally one needs to follow the steps below:
YAML files. Resulting markdown files are not stored in the repository.

1. create and activate virtualenv
### Using uv

uv run --with pyyaml --with zensical -- python generate_test_docs.py
uv run --with zensical -- zensical serve

### Using pip

1. create and activate virtualenv
```
virtualenv -p python3 venv
python3 -m venv venv
source venv/bin/activate
```
2. install requirements
2. install requirements
```
pip install -r requirements-docs.txt
```
3. generate test docs from YAML definitions
```
python generate_test_docs.py
```
pip install -r mkdocs_plugin/requirements.txt
4. run zensical
```
3. run mkdocs
* local http server
```
mkdocs serve
```
This will start small http server on http://127.0.0.1:8000

* build static docs
```
mkdocs build
```
This will convert all generated markdown files to HTML files. By default
files are stored in 'site' directory. See [mkdocs documentation](https://www.mkdocs.org/#building-the-site)
for more details.
zensical serve
```

This will start a local http server on http://127.0.0.1:8000.

To build static HTML instead:

zensical build

Files are stored in the 'site' directory.

## Contributing

Expand Down
3 changes: 0 additions & 3 deletions docs/tags.md

This file was deleted.

240 changes: 240 additions & 0 deletions generate_test_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you forget copyright?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed.

# SPDX-License-Identifier: GPL-2.0-only
# Copyright (C) 2026 Linaro Ltd.
"""Generate markdown documentation from YAML test definitions.

Walks the test directories, reads YAML files with metadata sections,
and generates markdown pages, an index table, and a tags page.
Run this before building the docs.
"""

import argparse
import logging
import os

import yaml

TABLE_DIRS = ["automated/linux", "automated/android", "manual"]
TABLE_FILENAME = "tests_table"
DOCS_DIR = "docs"

log = logging.getLogger(__name__)


def tag_anchor(tag):
"""Convert a tag name to a URL anchor."""
return tag.lower().replace(" ", "-").replace("/", "")


def parse_test_definition(filepath):
"""Parse a YAML test definition file.

Returns a dict with name, description, scope, os, devices, maintainer,
and steps. Returns None if the file has no metadata section.
"""
try:
with open(filepath, "r") as f:
content = yaml.safe_load(f)
except FileNotFoundError:
return None
except yaml.YAMLError as e:
log.warning("%s: invalid YAML: %s", filepath, e)
return None

if not isinstance(content, dict) or "metadata" not in content:
return None

metadata = content["metadata"]
if "name" not in metadata:
log.warning("%s: metadata missing 'name'", filepath)
return None

try:
steps = content["run"]["steps"]
except (KeyError, TypeError):
log.warning("%s: missing run.steps", filepath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this happen with a valid test definition?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I fix this in a follow up PR where we also fix validate.py script to check for missing run.steps ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the best way forward. At the moment there is no check that prevents adding a test definition file without steps.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right we need to add that into validate.py...

this can happen if the file is badly formatted I think

return None

return {
"name": metadata["name"],
"description": metadata.get("description", ""),
"scope": metadata.get("scope", []),
"os": metadata.get("os", []),
"devices": metadata.get("devices", []),
"maintainer": metadata.get("maintainer", []),
"steps": steps,
}


def build_frontmatter(name, scope_list):
"""Build YAML frontmatter for a markdown page."""
lines = ["---", "title: %s" % name]
if scope_list:
lines.append("tags:")
for item in scope_list:
lines.append(" - %s" % item)
lines.append("---")
return "\n".join(lines)


def build_md_list(header, items):
"""Build a markdown section with a header and bullet list."""
lines = ["\n## %s\n" % header]
for item in items:
lines.append(" * %s" % item)
return "\n".join(lines)


def build_test_page(rel_path, definition):
"""Build the full markdown content for a test page."""
parts = [build_frontmatter(definition["name"], definition["scope"])]
parts.append("\n# %s\n" % rel_path)

parts.append("\n## Description\n")
parts.append(definition["description"])

if definition["maintainer"]:
parts.append(build_md_list("Maintainer", definition["maintainer"]))
else:
parts.append("\n## Maintainer\n")

parts.append(build_md_list("OS", definition["os"]))
parts.append(build_md_list("Scope", definition["scope"]))
parts.append(build_md_list("Devices", definition["devices"]))

parts.append("\n## Steps to reproduce\n")
for line in definition["steps"]:
text = str(line)
if text.startswith("#"):
parts.append(" * \\%s" % text)
else:
parts.append(" * %s" % text)

return "\n".join(parts) + "\n"


def write_test_page(filepath, docs_dir, definition):
"""Write a markdown page for a single test definition.

Returns the relative path (without docs_dir prefix) on success.
"""
# strip .yaml extension
rel_path = filepath.rsplit(".", 1)[0]

out_path = os.path.join(docs_dir, rel_path + ".md")
os.makedirs(os.path.dirname(out_path), exist_ok=True)

content = build_test_page(rel_path, definition)
with open(out_path, "w") as f:
f.write(content)

return rel_path


def collect_tags(tags, definition, rel_path):
"""Add tags from a test definition to the tags dict."""
for scope in definition["scope"]:
anchor = tag_anchor(scope)
if anchor not in tags:
tags[anchor] = {"label": scope, "pages": []}
tags[anchor]["pages"].append(
{"name": definition["name"], "path": rel_path + ".md"}
)


def collect_table_row(test_tables, table_dirs, definition, rel_path):
"""Add a row to the appropriate index table."""
for table_name in table_dirs:
if rel_path.startswith(table_name):
scope_links = ", ".join(
"[%s](tags.md#%s)" % (s, tag_anchor(s)) for s in definition["scope"]
)
test_tables[table_name].append(
{
"name": "[%s](%s.md)" % (definition["name"], rel_path),
"description": definition["description"],
"scope": scope_links,
}
)
break


def generate_tags_page(docs_dir, tags):
"""Generate the tags index page."""
path = os.path.join(docs_dir, "tags.md")
lines = ["# Tags\n"]
for anchor in sorted(tags):
entry = tags[anchor]
lines.append('<h2 id="%s">%s</h2>\n' % (anchor, entry["label"]))
for page in sorted(entry["pages"], key=lambda p: p["name"]):
lines.append("- [%s](%s)\n" % (page["name"], page["path"]))
lines.append("")
with open(path, "w") as f:
f.write("\n".join(lines))


def generate_index(docs_dir, table_dirs, test_tables, table_filename):
"""Generate the tests index table."""
path = os.path.join(docs_dir, table_filename + ".md")
lines = ["# Tests index\n"]
for table_name in table_dirs:
test_table = test_tables[table_name]
lines.append('\n## <span class="tag">%s</span>\n' % table_name)
lines.append("| Name | Description | Scope |")
lines.append("| --- | --- | --- |")
for row in sorted(test_table, key=lambda r: r["name"]):
desc = row["description"].replace("\n", "")
lines.append("| %s | %s | %s |" % (row["name"], desc, row["scope"]))
lines.append("")
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")


def main():
parser = argparse.ArgumentParser(
description="Generate markdown docs from YAML test definitions"
)
parser.add_argument(
"--docs-dir", default=DOCS_DIR, help="Output directory (default: docs)"
)
parser.add_argument(
"--table-dirs",
nargs="+",
default=TABLE_DIRS,
help="Directories to scan for YAML files",
)
parser.add_argument(
"--table-file",
default=TABLE_FILENAME,
help="Name of the index table file (without .md)",
)
parser.add_argument("-v", "--verbose", action="store_true", help="Show warnings")
args = parser.parse_args()

logging.basicConfig(level=logging.WARNING if args.verbose else logging.ERROR)

test_tables = {name: [] for name in args.table_dirs}
tags = {}
generated = 0

for table_dir in args.table_dirs:
for root, dirs, filenames in os.walk(table_dir):
for filename in filenames:
if not filename.endswith(".yaml"):
continue
filepath = os.path.join(root, filename)
definition = parse_test_definition(filepath)
if definition is None:
continue
rel_path = write_test_page(filepath, args.docs_dir, definition)
collect_tags(tags, definition, rel_path)
collect_table_row(test_tables, args.table_dirs, definition, rel_path)
generated += 1

generate_index(args.docs_dir, args.table_dirs, test_tables, args.table_file)
generate_tags_page(args.docs_dir, tags)
print("Generated %d test doc(s) + index + tags (%d tags)" % (generated, len(tags)))


if __name__ == "__main__":
main()
18 changes: 0 additions & 18 deletions mkdocs.yml

This file was deleted.

Empty file removed mkdocs_plugin/__init__.py
Empty file.
30 changes: 0 additions & 30 deletions mkdocs_plugin/pyproject.toml

This file was deleted.

5 changes: 0 additions & 5 deletions mkdocs_plugin/requirements.txt

This file was deleted.

Loading
Loading