|
| 1 | +# ******************************************************************************* |
| 2 | +# Copyright (c) 2026 Contributors to the Eclipse Foundation |
| 3 | +# |
| 4 | +# See the NOTICE file(s) distributed with this work for additional |
| 5 | +# information regarding copyright ownership. |
| 6 | +# |
| 7 | +# This program and the accompanying materials are made available under the |
| 8 | +# terms of the Apache License Version 2.0 which is available at |
| 9 | +# https://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# SPDX-License-Identifier: Apache-2.0 |
| 12 | +# ******************************************************************************* |
| 13 | + |
| 14 | +"""Convert a sphinx-needs ``needs.json`` into a LOBSTER ``lobster-req-trace`` file. |
| 15 | +
|
| 16 | +Data flow:: |
| 17 | +
|
| 18 | + docs build -> sphinx-needs writes needs.json (single source of truth) |
| 19 | + this tool -> needs.json -> <module>.lobster (lobster-req-trace, v3) |
| 20 | + LOBSTER CLI -> lobster-report / lobster-ci-report over all *.lobster |
| 21 | +
|
| 22 | +The converter reads the already metamodel-validated ``needs.json`` and emits the |
| 23 | +LOBSTER common interchange format so ``lobster-report`` can aggregate the |
| 24 | +sphinx-needs requirements with the code/test ``.lobster`` artifacts produced by |
| 25 | +``rules_score``. Authoring (RST) and rendering (sphinx-needs dashboards) are |
| 26 | +untouched -- LOBSTER only sits downstream of ``needs.json``. |
| 27 | +
|
| 28 | +Licensing note: LOBSTER is AGPLv3. This converter deliberately produces the |
| 29 | +plain ``.lobster`` JSON via the documented schema and never imports the LOBSTER |
| 30 | +Python packages, keeping the Apache-2.0 code at arm's length from the AGPL tool. |
| 31 | +The emitted files are consumed by the LOBSTER CLI as an external process. |
| 32 | +
|
| 33 | +Schema reference: LOBSTER ``lobster-req-trace``, item version 3 |
| 34 | +(``tag``, ``location``, ``name``, ``refs``, ``just_up``/``just_down``/ |
| 35 | +``just_global``, plus requirement ``framework``/``kind``/``text``/``status``). |
| 36 | +""" |
| 37 | + |
| 38 | +from __future__ import annotations |
| 39 | + |
| 40 | +import argparse |
| 41 | +import json |
| 42 | +import sys |
| 43 | +from pathlib import Path |
| 44 | +from typing import Any |
| 45 | + |
| 46 | +# LOBSTER lobster-req-trace item schema version we emit (see documentation/schemas.md). |
| 47 | +_LOBSTER_SCHEMA = "lobster-req-trace" |
| 48 | +_LOBSTER_VERSION = 3 |
| 49 | +_GENERATOR = "needs_to_lobster" |
| 50 | + |
| 51 | +# sphinx-needs link fields treated as up-traces (child -> parent) by default. |
| 52 | +# ``satisfies`` is how tool_req__* needs reference their process gd_req__* parents. |
| 53 | +_DEFAULT_UP_LINKS = ("satisfies",) |
| 54 | + |
| 55 | +# Need fields that may already carry LOBSTER-style justifications, if authored. |
| 56 | +_JUST_FIELDS = ("just_up", "just_down", "just_global") |
| 57 | + |
| 58 | + |
| 59 | +def _select_version(needs_data: dict[str, Any], version: str | None) -> dict[str, Any]: |
| 60 | + """Return the ``versions`` entry to convert. |
| 61 | +
|
| 62 | + Uses ``version`` when given, else ``current_version``, else the last key. |
| 63 | + """ |
| 64 | + versions = needs_data.get("versions") |
| 65 | + if not versions: |
| 66 | + raise ValueError("needs.json contains no 'versions' object") |
| 67 | + if version is not None: |
| 68 | + if version not in versions: |
| 69 | + raise ValueError(f"version {version!r} not found in needs.json") |
| 70 | + return versions[version] |
| 71 | + current = needs_data.get("current_version") |
| 72 | + if current and current in versions: |
| 73 | + return versions[current] |
| 74 | + return versions[list(versions)[-1]] |
| 75 | + |
| 76 | + |
| 77 | +def _as_id_list(value: Any) -> list[str]: |
| 78 | + """Normalise a sphinx-needs link field into a list of target ids. |
| 79 | +
|
| 80 | + Link fields may be a list, a comma-separated string, or a single id. |
| 81 | + """ |
| 82 | + if value is None: |
| 83 | + return [] |
| 84 | + if isinstance(value, list): |
| 85 | + return [str(item) for item in value if item] |
| 86 | + if isinstance(value, str): |
| 87 | + return [part.strip() for part in value.split(",") if part.strip()] |
| 88 | + return [str(value)] |
| 89 | + |
| 90 | + |
| 91 | +def _location(need: dict[str, Any]) -> dict[str, Any]: |
| 92 | + """Build a LOBSTER 'file' source reference from a need's docname/lineno.""" |
| 93 | + docname = need.get("docname") |
| 94 | + filename = f"{docname}.rst" if docname else "unknown" |
| 95 | + lineno = need.get("lineno") |
| 96 | + return { |
| 97 | + "kind": "file", |
| 98 | + "file": filename, |
| 99 | + "line": int(lineno) if isinstance(lineno, int) else None, |
| 100 | + "column": None, |
| 101 | + } |
| 102 | + |
| 103 | + |
| 104 | +def _refs(need: dict[str, Any], namespace: str, up_links: tuple[str, ...]) -> list[str]: |
| 105 | + """Collect up-trace targets from the configured link fields as LOBSTER tags.""" |
| 106 | + refs: list[str] = [] |
| 107 | + seen: set[str] = set() |
| 108 | + for field in up_links: |
| 109 | + for target in _as_id_list(need.get(field)): |
| 110 | + tag = f"{namespace} {target}" |
| 111 | + if tag not in seen: |
| 112 | + seen.add(tag) |
| 113 | + refs.append(tag) |
| 114 | + return refs |
| 115 | + |
| 116 | + |
| 117 | +def _text(need: dict[str, Any]) -> str | None: |
| 118 | + """Return the need body text, tolerating sphinx-needs field naming.""" |
| 119 | + for field in ("content", "description"): |
| 120 | + value = need.get(field) |
| 121 | + if isinstance(value, str) and value.strip(): |
| 122 | + return value |
| 123 | + return None |
| 124 | + |
| 125 | + |
| 126 | +def need_to_item( |
| 127 | + need: dict[str, Any], |
| 128 | + namespace: str, |
| 129 | + up_links: tuple[str, ...], |
| 130 | +) -> dict[str, Any]: |
| 131 | + """Convert a single sphinx-needs need into a LOBSTER requirement item.""" |
| 132 | + need_id = str(need["id"]) |
| 133 | + item: dict[str, Any] = { |
| 134 | + "tag": f"{namespace} {need_id}", |
| 135 | + "location": _location(need), |
| 136 | + "name": need.get("title") or need_id, |
| 137 | + "messages": [], |
| 138 | + "just_up": _as_id_list(need.get("just_up")), |
| 139 | + "just_down": _as_id_list(need.get("just_down")), |
| 140 | + "just_global": _as_id_list(need.get("just_global")), |
| 141 | + "refs": _refs(need, namespace, up_links), |
| 142 | + "framework": "sphinx-needs", |
| 143 | + "kind": need.get("type") or "need", |
| 144 | + "text": _text(need), |
| 145 | + "status": need.get("status"), |
| 146 | + } |
| 147 | + return item |
| 148 | + |
| 149 | + |
| 150 | +def convert( |
| 151 | + needs_data: dict[str, Any], |
| 152 | + namespace: str = "req", |
| 153 | + up_links: tuple[str, ...] = _DEFAULT_UP_LINKS, |
| 154 | + include_types: set[str] | None = None, |
| 155 | + version: str | None = None, |
| 156 | +) -> dict[str, Any]: |
| 157 | + """Convert a parsed ``needs.json`` document into a lobster-req-trace document.""" |
| 158 | + version_data = _select_version(needs_data, version) |
| 159 | + needs = version_data.get("needs", {}) |
| 160 | + items = [ |
| 161 | + need_to_item(need, namespace, up_links) |
| 162 | + for need in needs.values() |
| 163 | + if include_types is None or need.get("type") in include_types |
| 164 | + ] |
| 165 | + return { |
| 166 | + "data": items, |
| 167 | + "generator": _GENERATOR, |
| 168 | + "schema": _LOBSTER_SCHEMA, |
| 169 | + "version": _LOBSTER_VERSION, |
| 170 | + } |
| 171 | + |
| 172 | + |
| 173 | +def _parse_csv(value: str | None) -> tuple[str, ...]: |
| 174 | + if not value: |
| 175 | + return () |
| 176 | + return tuple(part.strip() for part in value.split(",") if part.strip()) |
| 177 | + |
| 178 | + |
| 179 | +def _build_parser() -> argparse.ArgumentParser: |
| 180 | + parser = argparse.ArgumentParser( |
| 181 | + description=( |
| 182 | + "Convert a sphinx-needs needs.json into a LOBSTER " |
| 183 | + "lobster-req-trace (.lobster) file." |
| 184 | + ) |
| 185 | + ) |
| 186 | + parser.add_argument( |
| 187 | + "--needs-json", |
| 188 | + required=True, |
| 189 | + type=Path, |
| 190 | + help="Path to the input needs.json produced by the docs build.", |
| 191 | + ) |
| 192 | + parser.add_argument( |
| 193 | + "--output", |
| 194 | + type=Path, |
| 195 | + default=None, |
| 196 | + help="Path to the output .lobster file (default: stdout).", |
| 197 | + ) |
| 198 | + parser.add_argument( |
| 199 | + "--namespace", |
| 200 | + default="req", |
| 201 | + help="LOBSTER tag namespace for the emitted items (default: 'req').", |
| 202 | + ) |
| 203 | + parser.add_argument( |
| 204 | + "--up-links", |
| 205 | + default=",".join(_DEFAULT_UP_LINKS), |
| 206 | + help=( |
| 207 | + "Comma-separated sphinx-needs link fields to emit as up-trace refs " |
| 208 | + "(default: 'satisfies')." |
| 209 | + ), |
| 210 | + ) |
| 211 | + parser.add_argument( |
| 212 | + "--types", |
| 213 | + default=None, |
| 214 | + help=( |
| 215 | + "Comma-separated need types to include (default: all types). " |
| 216 | + "Example: 'tool_req' or 'gd_req'." |
| 217 | + ), |
| 218 | + ) |
| 219 | + parser.add_argument( |
| 220 | + "--version", |
| 221 | + default=None, |
| 222 | + help="needs.json version key to convert (default: current_version).", |
| 223 | + ) |
| 224 | + return parser |
| 225 | + |
| 226 | + |
| 227 | +def main(argv: list[str] | None = None) -> int: |
| 228 | + args = _build_parser().parse_args(argv) |
| 229 | + |
| 230 | + needs_data = json.loads(args.needs_json.read_text(encoding="utf-8")) |
| 231 | + include_types = set(_parse_csv(args.types)) or None |
| 232 | + report = convert( |
| 233 | + needs_data, |
| 234 | + namespace=args.namespace, |
| 235 | + up_links=_parse_csv(args.up_links) or _DEFAULT_UP_LINKS, |
| 236 | + include_types=include_types, |
| 237 | + version=args.version, |
| 238 | + ) |
| 239 | + |
| 240 | + payload = json.dumps(report, indent=2, sort_keys=True) |
| 241 | + if args.output is None: |
| 242 | + print(payload) |
| 243 | + else: |
| 244 | + args.output.write_text(payload + "\n", encoding="utf-8") |
| 245 | + print( |
| 246 | + f"Wrote {len(report['data'])} requirement item(s) to {args.output}", |
| 247 | + file=sys.stderr, |
| 248 | + ) |
| 249 | + return 0 |
| 250 | + |
| 251 | + |
| 252 | +if __name__ == "__main__": |
| 253 | + raise SystemExit(main()) |
0 commit comments