trlcdoc 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
trlcdoc/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """trlcdoc - configurable documentation tooling for TRLC based projects.
2
+
3
+ """
4
+
5
+ # trlcdoc - A utility for producing project documentation from TRLC and LOBSTER contents.
6
+ # Copyright (c) 2026 - 2026 Norbert Schulz github@schulznorbert.de
7
+ #
8
+ # This file is part of trlcdoc program.
9
+ #
10
+ # The trlcdoc program is free software: you can redistribute it and/or modify it under
11
+ # the terms of the GNU General Public License as published by the Free Software Foundation,
12
+ # either version 3 of the License, or (at your option) any later version.
13
+ #
14
+ # The trlcdoc program is distributed in the hope that it will be useful, but
15
+ # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
17
+ #
18
+ # You should have received a copy of the GNU General Public License along with pyTRLCConverter.
19
+ # If not, see <https://www.gnu.org/licenses/>.
20
+
21
+ # Imports **********************************************************************
22
+
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ # Variables ********************************************************************
26
+
27
+ # Single source of truth is the version in pyproject.toml.
28
+ try:
29
+ __version__ = version("trlcdoc")
30
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without install
31
+ __version__ = "0.0.0+unknown"
32
+
33
+ # Classes **********************************************************************
34
+
35
+ # Functions ********************************************************************
36
+
37
+ # Main *************************************************************************
trlcdoc/__main__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Entry point for 'python -m trlcdoc'.
2
+
3
+ """
4
+
5
+ # trlcdoc - A utility for producing project documentation from TRLC and LOBSTER contents.
6
+ # Copyright (c) 2026 - 2026 Norbert Schulz github@schulznorbert.de
7
+ #
8
+ # This file is part of trlcdoc program.
9
+ #
10
+ # The trlcdoc program is free software: you can redistribute it and/or modify it under
11
+ # the terms of the GNU General Public License as published by the Free Software Foundation,
12
+ # either version 3 of the License, or (at your option) any later version.
13
+ #
14
+ # The trlcdoc program is distributed in the hope that it will be useful, but
15
+ # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
17
+ #
18
+ # You should have received a copy of the GNU General Public License along with pyTRLCConverter.
19
+ # If not, see <https://www.gnu.org/licenses/>.
20
+
21
+ # Imports **********************************************************************
22
+
23
+ import sys
24
+
25
+ from .cli import main
26
+
27
+ # Variables ********************************************************************
28
+
29
+ # Classes **********************************************************************
30
+
31
+ # Functions ********************************************************************
32
+
33
+ # Main *************************************************************************
34
+
35
+ if __name__ == "__main__":
36
+ sys.exit(main())
trlcdoc/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """Command line interface of trlcdoc.
2
+
3
+ """
4
+
5
+ # trlcdoc - A utility for producing project documentation from TRLC and LOBSTER contents.
6
+ # Copyright (c) 2026 - 2026 Norbert Schulz github@schulznorbert.de
7
+ #
8
+ # This file is part of trlcdoc program.
9
+ #
10
+ # The trlcdoc program is free software: you can redistribute it and/or modify it under
11
+ # the terms of the GNU General Public License as published by the Free Software Foundation,
12
+ # either version 3 of the License, or (at your option) any later version.
13
+ #
14
+ # The trlcdoc program is distributed in the hope that it will be useful, but
15
+ # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
17
+ #
18
+ # You should have received a copy of the GNU General Public License along with pyTRLCConverter.
19
+ # If not, see <https://www.gnu.org/licenses/>.
20
+
21
+ # Imports **********************************************************************
22
+
23
+ import argparse
24
+ from pathlib import Path
25
+ from typing import List, Optional
26
+
27
+ from . import __version__
28
+ from .config import Config, find_config, load_config
29
+ from .convert import convert_documents
30
+ from .html import build_html
31
+ from .plantuml import provide_plantuml
32
+ from .testrun import run_tests
33
+ from .trace import build_trace_report
34
+ from .util import TrlcdocError, remove_output
35
+
36
+ # Variables ********************************************************************
37
+
38
+ # Steps in execution order. 'all' runs every step the configuration needs, except clean.
39
+ STEPS = ["clean", "check", "plantuml", "tests", "convert", "trace", "html"]
40
+
41
+ COPYRIGHT = "Copyright (c) 2026 Norbert Schulz"
42
+ REPOSITORY = "https://github.com/nhjschulz/trlc-doc"
43
+ DESCRIPTION = """Build project documentation for TRLC/LOBSTER based projects: requirements, architecture,
44
+ tests, test results, tracing report and HTML site."""
45
+
46
+ # Classes **********************************************************************
47
+
48
+ # Functions ********************************************************************
49
+
50
+
51
+ def _parse_args(argv: Optional[List[str]]) -> argparse.Namespace:
52
+ # lobster-trace: SwRequirements.sw_req_cli
53
+ # lobster-trace: SwRequirements.sw_req_cli_help
54
+ # lobster-trace: SwRequirements.sw_req_cli_version
55
+ # lobster-trace: SwRequirements.sw_req_steps
56
+ # lobster-trace: SwRequirements.sw_req_config_file
57
+ """Parse the command line arguments.
58
+
59
+ Args:
60
+ argv (Optional[List[str]]): Arguments, defaults to sys.argv[1:].
61
+
62
+ Returns:
63
+ argparse.Namespace: The parsed arguments.
64
+ """
65
+ parser = argparse.ArgumentParser(
66
+ prog="trlcdoc",
67
+ description=DESCRIPTION,
68
+ epilog=f"Version {__version__} - {COPYRIGHT} - GPL-3.0-or-later - see {REPOSITORY} for further information.",
69
+ formatter_class=argparse.RawDescriptionHelpFormatter)
70
+
71
+ parser.add_argument("steps", nargs="*", metavar="step",
72
+ help=f"steps to run: {', '.join(STEPS)} or all (default: all)")
73
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}",
74
+ help="show the trlcdoc version and exit")
75
+ parser.add_argument("-c", "--config", type=Path,
76
+ help="project configuration (default: trlcdoc.yaml in the current folder or a parent)")
77
+ parser.add_argument("--online", action="store_true",
78
+ help="link the tracing report to the repository instead of local files")
79
+ parser.add_argument("--format", choices=["rst", "markdown"], default="rst",
80
+ help="output format of the convert step (default: rst, needed for html)")
81
+ args = parser.parse_args(argv)
82
+
83
+ args.steps = args.steps or ["all"]
84
+ unknown = [step for step in args.steps if step not in (*STEPS, "all")]
85
+ if unknown:
86
+ parser.error(f"unknown step(s) {', '.join(unknown)}, choose from {', '.join(STEPS)}, all")
87
+ return args
88
+
89
+
90
+ def _selected_steps(cfg: Config, requested: List[str]) -> List[str]:
91
+ # lobster-trace: SwRequirements.sw_req_steps
92
+ # lobster-trace: SwRequirements.sw_req_step_order
93
+ """Determine the steps to run, in execution order.
94
+
95
+ Args:
96
+ cfg (Config): Project configuration.
97
+ requested (List[str]): Steps from the command line.
98
+
99
+ Returns:
100
+ List[str]: The steps to run.
101
+ """
102
+ selected = {step for step in STEPS if step in requested}
103
+ if "all" in requested:
104
+ selected.update(step for step in STEPS
105
+ if step != "clean"
106
+ and not (step == "tests" and cfg.tests is None)
107
+ and not (step == "trace" and cfg.trace is None))
108
+ # Converting and rendering need the PlantUML jar.
109
+ if selected & {"convert", "html"}:
110
+ selected.add("plantuml")
111
+ return [step for step in STEPS if step in selected]
112
+
113
+
114
+ def _run_step(step: str, cfg: Config, args: argparse.Namespace) -> bool:
115
+ # lobster-trace: SwRequirements.sw_req_steps
116
+ # lobster-trace: SwRequirements.sw_req_clean
117
+ # lobster-trace: SwRequirements.sw_req_exit_code
118
+ """Run one step.
119
+
120
+ Args:
121
+ step (str): Step name.
122
+ cfg (Config): Project configuration.
123
+ args (argparse.Namespace): Command line arguments.
124
+
125
+ Returns:
126
+ bool: True if the step found no problems in the project content
127
+ (failed tests, missing trace links). Build errors raise TrlcdocError.
128
+
129
+ Raises:
130
+ TrlcdocError: If the step cannot be completed.
131
+ """
132
+ ok = True
133
+ print(f"\n===== trlcdoc {step} =====", flush=True)
134
+ if step == "clean":
135
+ print(f"Removing {cfg.output}" if remove_output(cfg.output) else f"Nothing to clean in {cfg.output}")
136
+ elif step == "check":
137
+ print(f"Configuration {cfg.config_file} is valid, output: {cfg.output}")
138
+ elif step == "plantuml":
139
+ provide_plantuml(cfg)
140
+ elif step == "tests":
141
+ if cfg.tests is None:
142
+ raise TrlcdocError("The configuration has no 'tests' section.")
143
+ ok = run_tests(cfg)
144
+ elif step == "convert":
145
+ convert_documents(cfg, args.format)
146
+ elif step == "trace":
147
+ if cfg.trace is None:
148
+ raise TrlcdocError("The configuration has no 'trace' section.")
149
+ ok = build_trace_report(cfg, args.online) == 0
150
+ else:
151
+ build_html(cfg)
152
+ return ok
153
+
154
+
155
+ def main(argv: Optional[List[str]] = None) -> int:
156
+ # lobster-trace: SwRequirements.sw_req_cli
157
+ # lobster-trace: SwRequirements.sw_req_config_validation
158
+ # lobster-trace: SwRequirements.sw_req_exit_code
159
+ """Run the requested trlcdoc steps.
160
+
161
+ Args:
162
+ argv (Optional[List[str]]): Arguments, defaults to sys.argv[1:].
163
+
164
+ Returns:
165
+ int: 0 if all steps succeeded and the content has no findings,
166
+ 1 on findings (failed tests, missing trace links), 2 on build errors.
167
+ """
168
+ args = _parse_args(argv)
169
+ exit_code = 0
170
+ try:
171
+ cfg = load_config(args.config if args.config is not None else find_config(Path.cwd()))
172
+ findings: List[str] = []
173
+ for step in _selected_steps(cfg, args.steps):
174
+ if not _run_step(step, cfg, args):
175
+ findings.append(step)
176
+ if findings:
177
+ print(f"\ntrlcdoc finished with findings in: {', '.join(findings)}")
178
+ exit_code = 1
179
+ else:
180
+ print("\ntrlcdoc finished successfully.")
181
+ except TrlcdocError as err:
182
+ print(f"\ntrlcdoc error: {err}")
183
+ exit_code = 2
184
+ return exit_code
185
+
186
+ # Main *************************************************************************
trlcdoc/config.py ADDED
@@ -0,0 +1,433 @@
1
+ """Loading, validation and path resolution of the trlcdoc project configuration.
2
+
3
+ """
4
+
5
+ # trlcdoc - A utility for producing project documentation from TRLC and LOBSTER contents.
6
+ # Copyright (c) 2026 - 2026 Norbert Schulz github@schulznorbert.de
7
+ #
8
+ # This file is part of trlcdoc program.
9
+ #
10
+ # The trlcdoc program is free software: you can redistribute it and/or modify it under
11
+ # the terms of the GNU General Public License as published by the Free Software Foundation,
12
+ # either version 3 of the License, or (at your option) any later version.
13
+ #
14
+ # The trlcdoc program is distributed in the hope that it will be useful, but
15
+ # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
17
+ #
18
+ # You should have received a copy of the GNU General Public License along with pyTRLCConverter.
19
+ # If not, see <https://www.gnu.org/licenses/>.
20
+
21
+ # Imports **********************************************************************
22
+
23
+ import json
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ import jsonschema
28
+ import yaml
29
+
30
+ from .util import TrlcdocError
31
+
32
+ # Variables ********************************************************************
33
+
34
+ PACKAGE_DIR = Path(__file__).resolve().parent
35
+ SCHEMA_FILE = PACKAGE_DIR / "schema" / "trlcdoc.schema.json"
36
+ BUILTIN_DIR = PACKAGE_DIR / "converters"
37
+ BUILTIN_PREFIX = "builtin:"
38
+ TESTS_COVERAGE = "tests:coverage"
39
+
40
+ # Document types whose content are TRLC records.
41
+ TRLC_DOCUMENT_TYPES = ("trlc", "test_results")
42
+
43
+ # Classes **********************************************************************
44
+
45
+
46
+ class Config:
47
+ """Validated project configuration with resolved paths."""
48
+
49
+ def __init__(self, data: Dict[str, Any], config_file: Path) -> None:
50
+ # lobster-trace: SwRequirements.sw_req_project_settings
51
+ # lobster-trace: SwRequirements.sw_req_documents
52
+ """Construct the configuration.
53
+
54
+ Args:
55
+ data (Dict[str, Any]): Schema validated configuration data.
56
+ config_file (Path): The configuration file. Its folder is the project root.
57
+ """
58
+ self.data = data
59
+ self.config_file = config_file.resolve()
60
+ self.root = self.config_file.parent
61
+ self.documents: List[Dict[str, Any]] = data["documents"]
62
+ self.tests: Optional[Dict[str, Any]] = data.get("tests")
63
+ self.trace: Optional[Dict[str, Any]] = data.get("trace")
64
+ self.project: Dict[str, Any] = data["project"]
65
+ self.html: Dict[str, Any] = data.get("html", {})
66
+ self.plantuml: Dict[str, Any] = data.get("plantuml", {})
67
+
68
+ # Paths ********************************************************************
69
+
70
+ def path(self, value: str) -> Path:
71
+ # lobster-trace: SwRequirements.sw_req_config_file
72
+ """Resolve a configured path against the project root.
73
+
74
+ Args:
75
+ value (str): Absolute path or path relative to the project root.
76
+
77
+ Returns:
78
+ Path: The absolute path.
79
+ """
80
+ return (self.root / value).resolve()
81
+
82
+ @property
83
+ def output(self) -> Path:
84
+ # lobster-trace: SwRequirements.sw_req_output_folder
85
+ """Folder for all generated files."""
86
+ return self.path(self.data["paths"].get("output", "build/trlcdoc"))
87
+
88
+ @property
89
+ def trlc_model(self) -> Path:
90
+ # lobster-trace: SwRequirements.sw_req_trlc_conversion
91
+ """Folder with the TRLC model files."""
92
+ return self.path(self.data["paths"]["trlc_model"])
93
+
94
+ @property
95
+ def plantuml_dir(self) -> Path:
96
+ # lobster-trace: SwRequirements.sw_req_plantuml
97
+ # lobster-trace: SwRequirements.sw_req_output_folder
98
+ """Folder for the downloaded PlantUML jar."""
99
+ return self.output / "plantuml"
100
+
101
+ def convert_dir(self, fmt: str, doc_id: str) -> Path:
102
+ # lobster-trace: SwRequirements.sw_req_trlc_conversion
103
+ # lobster-trace: SwRequirements.sw_req_output_folder
104
+ """Output folder of a converted document.
105
+
106
+ Args:
107
+ fmt (str): Output format, rst or markdown.
108
+ doc_id (str): Document id.
109
+
110
+ Returns:
111
+ Path: The output folder.
112
+ """
113
+ return self.output / "convert" / fmt / doc_id
114
+
115
+ @property
116
+ def tests_dir(self) -> Path:
117
+ # lobster-trace: SwRequirements.sw_req_test_run
118
+ # lobster-trace: SwRequirements.sw_req_output_folder
119
+ """Output folder of the tests step."""
120
+ return self.output / "tests"
121
+
122
+ @property
123
+ def junit_file(self) -> Path:
124
+ # lobster-trace: SwRequirements.sw_req_test_run
125
+ """JUnit XML file with the test results."""
126
+ result = self.tests_dir / "junit.xml"
127
+ if self.tests is not None and self.tests["runner"] == "command":
128
+ result = self.path(self.tests["junit"])
129
+ return result
130
+
131
+ @property
132
+ def coverage_dir(self) -> Path:
133
+ # lobster-trace: SwRequirements.sw_req_test_run
134
+ """HTML coverage report folder."""
135
+ result = self.tests_dir / "coverage"
136
+ if self.tests is not None and "coverage" in self.tests:
137
+ result = self.path(self.tests["coverage"])
138
+ return result
139
+
140
+ @property
141
+ def test_results_dir(self) -> Path:
142
+ # lobster-trace: SwRequirements.sw_req_test_results
143
+ """Folder with the TRLC test result records generated from the JUnit XML."""
144
+ return self.tests_dir / "results"
145
+
146
+ @property
147
+ def trace_dir(self) -> Path:
148
+ # lobster-trace: SwRequirements.sw_req_trace_report
149
+ # lobster-trace: SwRequirements.sw_req_output_folder
150
+ """Output folder of the trace step."""
151
+ return self.output / "trace"
152
+
153
+ @property
154
+ def trace_report_html(self) -> Path:
155
+ # lobster-trace: SwRequirements.sw_req_trace_report
156
+ """The HTML tracing report."""
157
+ return self.trace_dir / "tracing_report.html"
158
+
159
+ @property
160
+ def sphinx_dir(self) -> Path:
161
+ # lobster-trace: SwRequirements.sw_req_html_site
162
+ # lobster-trace: SwRequirements.sw_req_output_folder
163
+ """Generated Sphinx source folder."""
164
+ return self.output / "sphinx"
165
+
166
+ @property
167
+ def html_dir(self) -> Path:
168
+ # lobster-trace: SwRequirements.sw_req_html_site
169
+ # lobster-trace: SwRequirements.sw_req_output_folder
170
+ """Generated HTML site."""
171
+ return self.output / "html"
172
+
173
+ # Documents ****************************************************************
174
+
175
+ def document(self, doc_id: str) -> Dict[str, Any]:
176
+ # lobster-trace: SwRequirements.sw_req_documents
177
+ """Get a document by its id.
178
+
179
+ Args:
180
+ doc_id (str): Document id.
181
+
182
+ Returns:
183
+ Dict[str, Any]: The document configuration.
184
+ """
185
+ return next(doc for doc in self.documents if doc["id"] == doc_id)
186
+
187
+ def trlc_sources(self, doc: Dict[str, Any]) -> List[Path]:
188
+ # lobster-trace: SwRequirements.sw_req_trlc_conversion
189
+ """Get the TRLC source folders of a TRLC document.
190
+
191
+ Args:
192
+ doc (Dict[str, Any]): Document of type trlc or test_results.
193
+
194
+ Returns:
195
+ List[Path]: The source folders.
196
+ """
197
+ result = [self.test_results_dir]
198
+ if doc["type"] == "trlc":
199
+ result = [self.path(doc["source"])]
200
+ return result
201
+
202
+ def included_documents(self, doc: Dict[str, Any]) -> List[Dict[str, Any]]:
203
+ # lobster-trace: SwRequirements.sw_req_trlc_conversion
204
+ """Get all documents included by a document, transitively.
205
+
206
+ Args:
207
+ doc (Dict[str, Any]): The including document.
208
+
209
+ Returns:
210
+ List[Dict[str, Any]]: Included documents, without the document itself.
211
+ """
212
+ result: List[Dict[str, Any]] = []
213
+ pending = list(doc.get("include", []))
214
+ while pending:
215
+ doc_id = pending.pop(0)
216
+ if doc_id != doc["id"] and all(inc["id"] != doc_id for inc in result):
217
+ included = self.document(doc_id)
218
+ result.append(included)
219
+ pending.extend(included.get("include", []))
220
+ return result
221
+
222
+ def included_sources(self, doc: Dict[str, Any]) -> List[Path]:
223
+ # lobster-trace: SwRequirements.sw_req_trlc_conversion
224
+ """Get the TRLC source folders of all documents included by a document.
225
+
226
+ Args:
227
+ doc (Dict[str, Any]): The including document.
228
+
229
+ Returns:
230
+ List[Path]: The source folders.
231
+ """
232
+ result: List[Path] = []
233
+ for included in self.included_documents(doc):
234
+ result.extend(self.trlc_sources(included))
235
+ return result
236
+
237
+ def file_reference(self, value: str) -> Path:
238
+ # lobster-trace: SwRequirements.sw_req_trlc_conversion
239
+ # lobster-trace: SwRequirements.sw_req_builtin_converters
240
+ """Resolve a converter, translation or render configuration file reference.
241
+
242
+ Args:
243
+ value (str): 'builtin:<file>' or a path relative to the project root.
244
+
245
+ Returns:
246
+ Path: The absolute file path.
247
+ """
248
+ result = self.path(value)
249
+ if value.startswith(BUILTIN_PREFIX):
250
+ result = BUILTIN_DIR / value[len(BUILTIN_PREFIX):]
251
+ return result
252
+
253
+ def html_source(self, doc: Dict[str, Any]) -> Path:
254
+ # lobster-trace: SwRequirements.sw_req_document_types
255
+ """Resolve the HTML folder of a document of type html or command.
256
+
257
+ Args:
258
+ doc (Dict[str, Any]): Document of type html or command.
259
+
260
+ Returns:
261
+ Path: The HTML folder.
262
+ """
263
+ result = self.coverage_dir
264
+ if doc["type"] == "command":
265
+ result = self.path(doc["output"])
266
+ elif doc["source"] != TESTS_COVERAGE:
267
+ result = self.path(doc["source"])
268
+ return result
269
+
270
+ # Project ******************************************************************
271
+
272
+ @property
273
+ def repository(self) -> str:
274
+ # lobster-trace: SwRequirements.sw_req_project_settings
275
+ # lobster-trace: SwRequirements.sw_req_trace_report
276
+ """Configured repository URL or 'auto'."""
277
+ return self.project.get("repository", "auto")
278
+
279
+ # Functions ********************************************************************
280
+
281
+
282
+ def _check_references(cfg: Config) -> List[str]:
283
+ # lobster-trace: SwRequirements.sw_req_config_validation
284
+ # lobster-trace: SwRequirements.sw_req_output_folder
285
+ """Check the cross references, which the JSON schema cannot express.
286
+
287
+ Args:
288
+ cfg (Config): Schema validated configuration.
289
+
290
+ Returns:
291
+ List[str]: Error messages, empty if all references are valid.
292
+ """
293
+ errors: List[str] = []
294
+ doc_ids = [doc["id"] for doc in cfg.documents]
295
+ doc_types = {doc["id"]: doc["type"] for doc in cfg.documents}
296
+
297
+ for doc_id in sorted({doc_id for doc_id in doc_ids if doc_ids.count(doc_id) > 1}):
298
+ errors.append(f"documents: id '{doc_id}' is used more than once.")
299
+
300
+ for doc in cfg.documents:
301
+ errors.extend(_check_document(cfg, doc, doc_types))
302
+
303
+ if cfg.trace is not None:
304
+ errors.extend(_check_levels(cfg.trace["levels"], doc_types))
305
+
306
+ for key in ("conf", "index", "page_header"):
307
+ if key in cfg.html and not cfg.path(cfg.html[key]).is_file():
308
+ errors.append(f"html.{key}: file '{cfg.html[key]}' not found.")
309
+
310
+ if cfg.output.is_relative_to(PACKAGE_DIR):
311
+ errors.append("paths.output: must not be inside the trlcdoc package folder.")
312
+ # The clean step removes the output folder, so it must never contain the project.
313
+ if cfg.root.is_relative_to(cfg.output):
314
+ errors.append("paths.output: must not be the project root or one of its parents.")
315
+
316
+ return errors
317
+
318
+
319
+ def _check_document(cfg: Config, doc: Dict[str, Any], doc_types: Dict[str, str]) -> List[str]:
320
+ # lobster-trace: SwRequirements.sw_req_config_validation
321
+ """Check the references of one document.
322
+
323
+ Args:
324
+ cfg (Config): Schema validated configuration.
325
+ doc (Dict[str, Any]): The document.
326
+ doc_types (Dict[str, str]): Document type per document id.
327
+
328
+ Returns:
329
+ List[str]: Error messages, empty if all references are valid.
330
+ """
331
+ errors: List[str] = []
332
+ where = f"documents[{doc['id']}]"
333
+
334
+ for inc in doc.get("include", []):
335
+ if doc_types.get(inc) not in TRLC_DOCUMENT_TYPES:
336
+ errors.append(f"{where}.include: '{inc}' is no document of type trlc or test_results.")
337
+
338
+ options = doc.get("pytrlcconverter", {})
339
+ files = {f"pytrlcconverter.converter.{fmt}": value for fmt, value in options.get("converter", {}).items()}
340
+ files.update({f"pytrlcconverter.{key}": options[key] for key in ("translation", "render_cfg") if key in options})
341
+ for key, value in files.items():
342
+ if not cfg.file_reference(value).is_file():
343
+ errors.append(f"{where}.{key}: file '{value}' not found.")
344
+
345
+ needs_tests = doc["type"] == "test_results" or doc.get("source") == TESTS_COVERAGE
346
+ if needs_tests and cfg.tests is None:
347
+ errors.append(f"{where}: needs a 'tests' section.")
348
+ if doc["type"] == "trace_report" and cfg.trace is None:
349
+ errors.append(f"{where}: type trace_report needs a 'trace' section.")
350
+ return errors
351
+
352
+
353
+ def _check_levels(levels: List[Dict[str, Any]], doc_types: Dict[str, str]) -> List[str]:
354
+ # lobster-trace: SwRequirements.sw_req_config_validation
355
+ # lobster-trace: SwRequirements.sw_req_trace_levels
356
+ """Check the trace level references.
357
+
358
+ Args:
359
+ levels (List[Dict[str, Any]]): Configured trace levels.
360
+ doc_types (Dict[str, str]): Document type per document id.
361
+
362
+ Returns:
363
+ List[str]: Error messages, empty if all references are valid.
364
+ """
365
+ errors: List[str] = []
366
+ names = [level["name"] for level in levels]
367
+
368
+ for name in sorted({name for name in names if names.count(name) > 1}):
369
+ errors.append(f"trace.levels: name '{name}' is used more than once.")
370
+
371
+ for level in levels:
372
+ where = f"trace.levels[{level['name']}]"
373
+ if "document" in level and doc_types.get(level["document"]) not in TRLC_DOCUMENT_TYPES:
374
+ errors.append(f"{where}.document: '{level['document']}' is no document of type trlc or test_results.")
375
+ for key in ("trace_to", "requires"):
376
+ for target in level.get(key, []):
377
+ if target not in names:
378
+ errors.append(f"{where}.{key}: level '{target}' is not defined.")
379
+ return errors
380
+
381
+
382
+ def load_config(config_file: Path) -> Config:
383
+ # lobster-trace: SwRequirements.sw_req_config_file
384
+ # lobster-trace: SwRequirements.sw_req_config_validation
385
+ """Load and validate the project configuration.
386
+
387
+ Args:
388
+ config_file (Path): The trlcdoc YAML configuration file.
389
+
390
+ Returns:
391
+ Config: The validated configuration.
392
+
393
+ Raises:
394
+ TrlcdocError: If the file is missing or invalid.
395
+ """
396
+ if not config_file.is_file():
397
+ raise TrlcdocError(f"Configuration file {config_file} not found.")
398
+
399
+ with open(config_file, encoding="utf-8") as file:
400
+ data = yaml.safe_load(file)
401
+
402
+ with open(SCHEMA_FILE, encoding="utf-8") as file:
403
+ schema = json.load(file)
404
+
405
+ validator = jsonschema.Draft202012Validator(schema)
406
+ schema_errors = sorted(validator.iter_errors(data), key=lambda err: list(err.absolute_path))
407
+ if schema_errors:
408
+ messages = [f"{'/'.join(str(p) for p in err.absolute_path) or '<root>'}: {err.message}"
409
+ for err in schema_errors]
410
+ raise TrlcdocError(f"{config_file} does not match the schema:\n " + "\n ".join(messages))
411
+
412
+ cfg = Config(data, config_file)
413
+ reference_errors = _check_references(cfg)
414
+ if reference_errors:
415
+ raise TrlcdocError(f"{config_file} has invalid settings:\n " + "\n ".join(reference_errors))
416
+
417
+ return cfg
418
+
419
+
420
+ def find_config(start: Path) -> Path:
421
+ # lobster-trace: SwRequirements.sw_req_config_file
422
+ """Find the default configuration file trlcdoc.yaml in a folder or its parents.
423
+
424
+ Args:
425
+ start (Path): Folder to start the search in.
426
+
427
+ Returns:
428
+ Path: The configuration file, or start/trlcdoc.yaml if none was found.
429
+ """
430
+ candidates = [folder / "trlcdoc.yaml" for folder in (start, *start.parents)]
431
+ return next((file for file in candidates if file.is_file()), start / "trlcdoc.yaml")
432
+
433
+ # Main *************************************************************************