214 lines
6.7 KiB
Python
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())
|