universalisos/tools/doorstop-integration/doors_export.py

214 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""
doors_export.py — Export Doorstop requirements to DOORS-compatible XML.
Generates XML matching the rqtrace-2.1.xsd structure for round-trip
compatibility with IBM Rational DOORS.
Usage:
python3 doors_export.py --doorstop reqs/ --output doors_import.xml
python3 doors_export.py --doorstop reqs/ --output doors_import.xml --module "UniversalisOS"
"""
from __future__ import annotations
import argparse
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
# Allow running from the script's directory
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
def read_doorstop_yaml(yaml_path: Path) -> dict:
"""Read a Doorstop YAML file and return parsed fields.
Uses a simple parser to avoid requiring PyYAML.
"""
content = yaml_path.read_text()
result = {
"uid": "",
"level": 0,
"active": True,
"derived": False,
"normative": True,
"text": "",
"parent": "",
"ref": "",
"attrs": {},
}
current_section = None
current_list_key = None
for line in content.split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
# Top-level fields
if ":" in stripped and not stripped.startswith("-") and not stripped.startswith(" "):
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key == "attrs":
current_section = "attrs"
current_list_key = None
continue
current_section = None
current_list_key = None
if key == "uid":
result["uid"] = value
elif key == "level":
result["level"] = int(value) if value.isdigit() else 0
elif key == "active":
result["active"] = value.lower() in ("true", "yes", "1")
elif key == "derived":
result["derived"] = value.lower() in ("true", "yes", "1")
elif key == "normative":
result["normative"] = value.lower() in ("true", "yes", "1")
elif key == "text":
result["text"] = value
elif key == "parent":
result["parent"] = value
elif key == "ref":
result["ref"] = value
elif current_section == "attrs":
if stripped.startswith("- ") and current_list_key:
# List item
item = stripped[2:].strip().strip('"').strip("'")
result["attrs"].setdefault(current_list_key, []).append(item)
elif ":" in stripped and not stripped.startswith("-"):
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
if value:
result["attrs"][key] = value
else:
# Start of a list
current_list_key = key
result["attrs"][key] = []
else:
current_list_key = None
return result
def build_doors_xml(requirements: list[dict], module_name: str) -> ET.Element:
"""Build a DOORS-compatible XML tree (rqtrace-2.1.xsd structure)."""
root = ET.Element("rqDocument")
# Module element
module = ET.SubElement(root, "module")
module.set("name", module_name)
# Linkset for parent-child relationships
linkset = ET.SubElement(root, "linkset")
for req in requirements:
rq = ET.SubElement(module, "rq")
rq.set("uid", req["uid"])
rq.set("heading", req.get("text", "")[:80]) # heading = truncated text
rq.set("text", req.get("text", ""))
rq.set("level", str(req.get("level", 0)))
# Add attributes
attrs = req.get("attrs", {})
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, list):
# List attributes
attr_el = ET.SubElement(rq, "attr")
attr_el.set("name", attr_name)
attr_el.text = ", ".join(str(v) for v in attr_value)
else:
attr_el = ET.SubElement(rq, "attr")
attr_el.set("name", attr_name)
attr_el.text = str(attr_value)
# Add parent link
parent = req.get("parent", "")
if parent:
link = ET.SubElement(linkset, "link")
link.set("from", req["uid"])
link.set("to", parent)
link.set("type", "parent")
# Add linked_tests as links
for test_id in req.get("linked_tests", []):
link = ET.SubElement(linkset, "link")
link.set("from", req["uid"])
link.set("to", test_id)
link.set("type", "trace")
return root
def cmd_export(args):
"""Export Doorstop YAML to DOORS XML."""
doorstop_dir = Path(args.doorstop)
output_path = Path(args.output)
module_name = args.module
print(f"Reading Doorstop requirements from: {doorstop_dir}")
# Find all YAML files
yaml_files = sorted(doorstop_dir.glob("*.yml"))
if not yaml_files:
print("No .yml files found in directory.")
return 1
requirements = []
for yf in yaml_files:
req = read_doorstop_yaml(yf)
requirements.append(req)
print(f" Read: {yf.name} -> {req['uid']}")
print(f"Building DOORS XML ({len(requirements)} requirements)...")
root = build_doors_xml(requirements, module_name)
# Write XML
output_path.parent.mkdir(parents=True, exist_ok=True)
tree = ET.ElementTree(root)
ET.indent(tree, space=" ")
tree.write(str(output_path), encoding="unicode", xml_declaration=True)
print(f"Exported to: {output_path}")
return 0
def main():
parser = argparse.ArgumentParser(
description="Export Doorstop requirements to DOORS-compatible XML",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 doors_export.py --doorstop reqs/ --output doors_import.xml
python3 doors_export.py --doorstop reqs/ --output doors_import.xml --module "MyModule"
""",
)
parser.add_argument(
"--doorstop", required=True,
help="Directory containing Doorstop YAML requirement files",
)
parser.add_argument(
"--output", "-o", required=True,
help="Output XML file path",
)
parser.add_argument(
"--module", default="UniversalisOS",
help="DOORS module name (default: UniversalisOS)",
)
args = parser.parse_args()
sys.exit(cmd_export(args))
if __name__ == "__main__":
sys.exit(main())