Files
infix/test/spec/generate_spec.py
Richard Alpe b388e080ad test: fix image paths in test specifications (again)
{topdoc} is needed for asciidoctor to find the image during test
report generation.

This is the same functional change as 02d8288863 but with the
generation script actually updated. This was forgotten in the last
commit.

Signed-off-by: Richard Alpe <richard@bit42.se>
2025-03-03 14:11:44 +01:00

256 lines
9.6 KiB
Python
Executable File

#!/bin/env python3
import os
import ast
import argparse
import io
import sys
import re
import yaml
from pathlib import Path
import graphviz
def replace_image_tag(text, test_dir):
"""
Convert images added in the description and replace with the required ifdefs to work
generating the test specifcation as well.
"""
pattern = r"image::(?P<image_name>\S+)\[(?P<label>[^\]]*)\]"
def repl(match):
image_name = match.group("image_name")
label = match.group("label")
return f"""ifdef::topdoc[]
image::{{topdoc}}../../{test_dir}/{image_name}[{label}]
endif::topdoc[]
ifndef::topdoc[]
ifdef::testgroup[]
image::{'/'.join(Path(test_dir).parts[3:])}/{image_name}[{label}]
endif::testgroup[]
ifndef::testgroup[]
image::{image_name}[{label}]
endif::testgroup[]
endif::topdoc[]"""
return re.sub(pattern, repl, text)
class TestStepVisitor(ast.NodeVisitor):
"""
A custom test step visitor to grab the test description (docstring)
and the test steps test.step(.....) for the test case.
"""
def __init__(self):
self.test_steps=[]
self.name = ""
self.description = ""
def visit_Module(self, node):
"""Extract docstring from a test."""
docstring = ast.get_docstring(node)
if docstring:
lines = docstring.splitlines()
newline_parsed=False
for line in lines:
if self.name == "":
self.name=line.strip()
elif newline_parsed is False and line == "":
newline_parsed = True
continue # Skip mandatory newline
else:
self.description=f"{self.description}\n{line}"
self.description=self.description.strip()
self.generic_visit(node)
def _rebuild_fstring(self, node: ast.JoinedStr) -> str:
"""
Rebuild an f-string (JoinedStr) by concatenating literal pieces and placeholders.
"""
parts = []
for value in node.values:
if isinstance(value, ast.Constant):
parts.append(value.value)
elif isinstance(value, ast.FormattedValue):
expression = ast.unparse(value.value) if hasattr(ast, "unparse") else ast.dump(value.value)
parts.append(f"{{{expression}}}")
return "".join(parts)
# Check for test.step() for the actual test steps
def visit_Call(self, node):
"""Extract test.step from test"""
if isinstance(node.func, ast.Attribute) and node.func.attr == 'step':
arg = node.args[0]
if isinstance(node.func.value, ast.Name) and node.func.value.id == 'test':
if node.args:
if isinstance(arg, ast.Constant):
self.test_steps.append(node.args[0].value)
elif isinstance(arg, ast.JoinedStr):
fstring_value = self._rebuild_fstring(arg)
self.test_steps.append(fstring_value)
self.generic_visit(node) # Continue visiting other nodes
def replace_in_steps(steps, variables):
result = []
for step in steps:
new_step = step
for k, v in variables.items():
new_step = new_step.replace("{" + k + "}", v)
result.append(new_step)
return result
class TestCase:
"""All test specifcation resources for a test case"""
def __init__(self, directory, rootdir=None):
self.test_dir = Path(directory)
if rootdir:
rootdir = Path(f"{rootdir}")
self.test_dir = self.test_dir.relative_to(rootdir)
self.topology_dot = f"{directory}/topology.dot"
self.topology_image = f"{directory}/topology"
self.test_case = f"{directory}/test.py"
self.specification = f"{directory}/Readme.adoc"
def generate_topology(self):
"""Generate SVG file from the topology.dot file"""
with open(self.topology_dot, 'r', encoding='utf-8') as dot_file:
svg_file = f"{self.topology_image}.svg"
try:
dot_graph = dot_file.read()
graph = graphviz.Source(dot_graph)
graph.render(self.topology_image, format='svg', cleanup=True,
quiet=True)
content = ""
with open(svg_file, 'r', encoding='utf-8') as f:
content = f.read()
pattern = r'<!--\s*Generated by graphviz.*?-->'
mod_content = re.sub(pattern, '', content, flags=re.DOTALL)
with open(svg_file, 'w', encoding='utf-8') as f:
f.write(mod_content)
except graphviz.backend.execute.CalledProcessError as e:
msg = e.stderr.decode()
print(f"Failed rendering SVG from {self.topology_dot}: {msg}")
except UnboundLocalError:
print(f"Failed cleaning {svg_file}, empty or missing.")
def generate_specification(self, name, case_path, spec_path, variables):
"""Generate a ASCIIDOC specification for the test case"""
with open(case_path, 'r', encoding='utf-8') as file:
script_content = file.read()
parsed_script = ast.parse(script_content)
visitor = TestStepVisitor()
visitor.visit(parsed_script)
description = visitor.description if visitor.description != "" else "Undefined"
test_steps = visitor.test_steps
if variables is not None:
for k, v in variables.items():
if "{" + k + "}" in description:
description = description.replace("{" + k + "}", v)
test_steps=replace_in_steps(test_steps, variables)
if name is None:
name = visitor.name
self.generate_topology()
description = replace_image_tag(description, self.test_dir)
with open(spec_path, "w") as spec:
spec.write(f"=== {name}\n")
spec.write("==== Description\n")
spec.write(description + "\n\n")
spec.write("==== Topology\n")
spec.write("ifdef::topdoc[]\n")
spec.write(f"image::{{topdoc}}../../{self.test_dir}/topology.svg[{name} topology]\n")
spec.write("endif::topdoc[]\n")
spec.write("ifndef::topdoc[]\n")
spec.write("ifdef::testgroup[]\n")
spec.write(f"image::{Path(*self.test_dir.parts[3:])}/topology.svg[{name} topology]\n")
spec.write("endif::testgroup[]\n")
spec.write("ifndef::testgroup[]\n")
spec.write(f"image::topology.svg[{name} topology]\n")
spec.write("endif::testgroup[]\n")
spec.write("endif::topdoc[]\n")
spec.write("==== Test sequence\n")
spec.writelines([f". {step}\n" for step in test_steps])
spec.write("\n\n<<<\n\n") # need empty lines to pagebreak
def parse_suite(directory, root, suitefile):
"""Parse 9pm .yaml suite file"""
test_spec = None
readme = None
with open(suitefile, "r") as f:
data = yaml.safe_load(f)
for entry in data:
if entry.get("suite"):
path = os.path.dirname(entry["suite"])
parse_suite(f"{directory}/{path}", root, f"{directory}/{entry['suite']}")
elif entry.get("case"):
variables=None
if not entry.get("infamy"):
test_title = None # Set from the docstring in the test case
else:
specification = entry["infamy"].get("specification", True);
if not specification:
continue
test_title = entry["infamy"]["title"]
path = os.path.dirname(entry["case"])
if readme is None:
readme = open(f"{directory}/{path}/Readme.adoc.tmp", "w")
test_case_id = entry["name"]
if "opts" in entry:
variables = dict((k[2:], v) for k, v in zip(entry["opts"][::2], entry["opts"][1::2]))
test_file = f"{directory}/{entry['case']}"
test_spec = f"{directory}/{path}/{test_case_id}.adoc"
test_case = TestCase(f"{directory}/{path}", root)
test_case.generate_specification(test_title, test_file, test_spec, variables)
readme.write(f"include::{path}{test_case_id}.adoc[]\n\n")
if path != "":
lnk = f"{directory}/{path}/Readme.adoc"
readme.close()
os.unlink(f"{directory}/{path}/Readme.adoc.tmp")
readme = None
if os.path.exists(lnk):
os.unlink(lnk)
os.symlink(f"{test_case_id}.adoc", lnk)
if readme is not None:
readme.close()
os.rename(f"{directory}/Readme.adoc.tmp", f"{directory}/Readme.adoc")
parser = argparse.ArgumentParser(description="Generate a test specification for a subtree.")
parser.add_argument("-s", "--suite", required=True, help="The suite file to parse")
parser.add_argument("-r", "--root-dir", required=True, help="Path that all paths should be relative to")
parser.add_argument("-d", "--debug", help="Show debug output", action="store_true")
args = parser.parse_args()
error_string = ""
# This is hacky, graphviz output error only to stdout and return successful(always).
# If everything goes well, output shall be empty, fail on any output
if not args.debug:
output_capture = io.StringIO()
sys.stderr = output_capture
output_capture.truncate(0)
output_capture.seek(0)
parse_suite(os.path.dirname(args.suite), args.root_dir, args.suite)
if not args.debug:
sys.stdout = sys.__stdout__
if len(error_string) > 0:
print(error_string)
exit(1)
exit(0)