StrictDoc Documentation
strictdoc/core/project_config.py
Source file coverage
Path:
strictdoc/core/project_config.py
Lines:
1335
Non-empty lines:
1148
Non-empty lines covered with requirements:
1148 / 1148 (100.0%)
Functions:
51
Functions covered by requirements:
51 / 51 (100.0%)
1
"""
2
@relation(SDOC-SRS-39, scope=file)
3
"""
4
 
5
import datetime
6
import mimetypes
7
import os
8
import re
9
import tempfile
10
import types
11
from dataclasses import dataclass, field
12
from enum import Enum
13
from pathlib import Path
14
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
15
 
16
import toml
17
 
18
from strictdoc import __version__, environment
19
from strictdoc.backend.reqif.sdoc_reqif_fields import ReqIFProfile
20
from strictdoc.backend.sdoc.constants import SDocMarkup
21
from strictdoc.commands.convert_config import ConvertCommandConfig
22
from strictdoc.commands.export_config import ExportCommandConfig
23
from strictdoc.commands.format_config import FormatCommandConfig
24
from strictdoc.commands.manage_autouid_config import ManageAutoUIDCommandConfig
25
from strictdoc.commands.manage_new_config import ManageNewCommandConfig
26
from strictdoc.commands.server_config import ServerCommandConfig
27
from strictdoc.core.environment import SDocRuntimeEnvironment
28
from strictdoc.core.feature import Feature
29
from strictdoc.core.plugin import StrictDocPlugin
30
from strictdoc.helpers.auto_described import auto_described
31
from strictdoc.helpers.deprecation_engine import DEPRECATION_ENGINE
32
from strictdoc.helpers.exception import StrictDocException
33
from strictdoc.helpers.file_modification_time import get_file_modification_time
34
from strictdoc.helpers.md5 import get_md5
35
from strictdoc.helpers.module import import_from_path
36
from strictdoc.helpers.net import is_valid_host
37
from strictdoc.helpers.path_filter import validate_mask
38
 
39
if TYPE_CHECKING:
40
    from strictdoc.core.format import Format
41
 
42
 
43
def parse_relation_tuple(column_name: str) -> Optional[Tuple[str, str]]:
44
    match_result = re.search(
45
        r"^((Parent|Child|File)?)(\[(.{1,32})])?$", column_name
46
    )
47
    if match_result is None:
48
        return None
49
    return match_result.group(1), match_result.group(4)
50
 
51
 
52
@dataclass
53
class SourceNodesEntry:
54
    path: str
55
    uid: str
56
    node_type: str
57
    sdoc_to_source_map: Dict[str, str] = field(default_factory=dict)
58
    full_path: Optional[Path] = None
59
 
60
 
61
class ProjectFeature(str, Enum):
62
    # Stable features.
63
    TABLE_SCREEN = "TABLE_SCREEN"
64
    TRACEABILITY_SCREEN = "TRACEABILITY_SCREEN"
65
    DEEP_TRACEABILITY_SCREEN = "DEEP_TRACEABILITY_SCREEN"
66
 
67
    MATHJAX = "MATHJAX"
68
 
69
    # Experimental features.
70
    SEARCH = "SEARCH"
71
    HTML2PDF = "HTML2PDF"
72
    REQIF = "REQIF"
73
    DIFF = "DIFF"
74
    PROJECT_STATISTICS_SCREEN = "PROJECT_STATISTICS_SCREEN"
75
    TREE_MAP_SCREEN = "TREE_MAP_SCREEN"
76
    TRACEABILITY_MATRIX_SCREEN = "TRACEABILITY_MATRIX_SCREEN"
77
    REQUIREMENT_TO_SOURCE_TRACEABILITY = "REQUIREMENT_TO_SOURCE_TRACEABILITY"
78
    SOURCE_FILE_LANGUAGE_PARSERS = "SOURCE_FILE_LANGUAGE_PARSERS"
79
 
80
    MERMAID = "MERMAID"
81
    NESTOR = "NESTOR"
82
 
83
    ALL_FEATURES = "ALL_FEATURES"
84
 
85
    @staticmethod
86
    def all() -> List[str]:  # noqa: A003
87
        return list(map(lambda c: c.value, ProjectFeature))
88
 
89
 
90
class ProjectConfigDefault:
91
    DEFAULT_PROJECT_TITLE = "Untitled Project"
92
    DEFAULT_DIR_FOR_SDOC_ASSETS = "_static"
93
    DEFAULT_DIR_FOR_OUTPUT = "output"
94
    DEFAULT_DIR_FOR_SDOC_CACHE = "output/_cache"
95
 
96
    DEFAULT_FEATURES: List[str] = [
97
        ProjectFeature.TABLE_SCREEN,
98
        ProjectFeature.TRACEABILITY_SCREEN,
99
        ProjectFeature.DEEP_TRACEABILITY_SCREEN,
100
        ProjectFeature.SEARCH,
101
    ]
102
    DEFAULT_SERVER_HOST = "127.0.0.1"
103
    DEFAULT_SERVER_PORT = 5111
104
    DEFAULT_BUNDLE_DOCUMENT_VERSION = "@GIT_VERSION (Git branch: @GIT_BRANCH)"
105
    DEFAULT_BUNDLE_DOCUMENT_COMMIT_DATE = "@GIT_COMMIT_DATETIME"
106
    DEFAULT_SECTION_BEHAVIOR = "[SECTION]"
107
 
108
 
109
def resolve_favicon_variant(
110
    environment: SDocRuntimeEnvironment, is_running_on_server: bool
111
) -> str:
112
    """Resolve which favicon.svg.jinja variant identifies this process."""
113
    if environment.is_test_env:
114
        return "test"
115
    if environment.is_development_mode:
116
        return "dev"
117
    if not is_running_on_server:
118
        return "export"
119
    return "default"
120
 
121
 
122
@auto_described
123
class ProjectConfig:
124
    """
125
    @relation(SDOC-SRS-119, scope=class)
126
    """
127
 
128
    def __init__(
129
        self,
130
        project_title: str = ProjectConfigDefault.DEFAULT_PROJECT_TITLE,
131
        dir_for_sdoc_assets: str = ProjectConfigDefault.DEFAULT_DIR_FOR_SDOC_ASSETS,
132
        dir_for_sdoc_cache: str = ProjectConfigDefault.DEFAULT_DIR_FOR_SDOC_CACHE,
133
        project_features: Optional[List[Union[str, Feature]]] = None,
134
        server_host: str = ProjectConfigDefault.DEFAULT_SERVER_HOST,
135
        server_port: int = ProjectConfigDefault.DEFAULT_SERVER_PORT,
136
        input_paths: Optional[List[str]] = None,
137
        include_doc_paths: Optional[List[str]] = None,
138
        exclude_doc_paths: Optional[List[str]] = None,
139
        source_root_path: Optional[str] = None,
140
        include_source_paths: Optional[List[str]] = None,
141
        exclude_source_paths: Optional[List[str]] = None,
142
        grammars: Optional[Dict[str, str]] = None,
143
        test_report_root_dict: Optional[Dict[str, str]] = None,
144
        source_nodes: Optional[List[SourceNodesEntry]] = None,
145
        html2pdf_strict: bool = False,
146
        html2pdf_template: Optional[str] = None,
147
        html2pdf_forced_page_break_nodes: Optional[List[str]] = None,
148
        bundle_document_uid: Optional[str] = None,
149
        bundle_document_version: Optional[
150
            str
151
        ] = ProjectConfigDefault.DEFAULT_BUNDLE_DOCUMENT_VERSION,
152
        bundle_document_date: Optional[
153
            str
154
        ] = ProjectConfigDefault.DEFAULT_BUNDLE_DOCUMENT_COMMIT_DATE,
155
        traceability_matrix_relation_columns: Optional[
156
            List[Tuple[str, Optional[str]]]
157
        ] = None,
158
        reqif_profile: str = ReqIFProfile.P01_SDOC,
159
        # FIXME: Change to true by default.
160
        reqif_multiline_is_xhtml: bool = False,
161
        # FIXME: Change to true by default.
162
        reqif_enable_mid: bool = False,
163
        reqif_import_markup: Optional[str] = None,
164
        diff_git_revisions: Optional[str] = None,
165
        diff_dir_revisions: Optional[Tuple[str, str]] = None,
166
        chromedriver: Optional[str] = None,
167
        # FIXME: The section_behavior field will be removed by the end of 2025-Q4.
168
        section_behavior: Optional[
169
            str
170
        ] = ProjectConfigDefault.DEFAULT_SECTION_BEHAVIOR,
171
        statistics_generator: Optional[str] = None,
172
        document_line_width: Optional[int] = None,
173
        # Logo path can be set in the project config to customize the launcher's appearance for a specific project.
174
        launcher_logo_path: Optional[str] = None,
175
        # Favicon path can be set in the project config to customize the
176
        # browser-tab favicon for a project's own (non-dev, non-test) server
177
        # or static export. Ignored for the dev/test favicon variants.
178
        favicon_path: Optional[str] = None,
179
        user_plugin: Optional[StrictDocPlugin] = None,
180
        formats: Optional[List["Format"]] = None,
181
        # Reserved for StrictDoc's internal use.
182
        _config_last_update: Optional[datetime.datetime] = None,
183
    ) -> None:
184
        self.environment: SDocRuntimeEnvironment = environment
185
 
186
        # Settings obtained from the strictdoc.toml config file.
187
        self.project_title: str = project_title
188
        self.dir_for_sdoc_assets: str = dir_for_sdoc_assets
189
 
190
        if env_cache_dir := os.environ.get("STRICTDOC_CACHE_DIR"):
191
            # The only use case for STRICTDOC_CACHE_DIR is to make the cache
192
            # local to an itest folder.
193
            assert env_cache_dir == "Output/_cache", env_cache_dir
194
            dir_for_sdoc_cache = env_cache_dir
195
        elif dir_for_sdoc_cache == "$TMPDIR":
196
            dir_for_sdoc_cache = os.path.join(
197
                tempfile.gettempdir(),
198
                "strictdoc_cache",
199
                get_md5(os.getcwd()),
200
            )
201
 
202
        # Adding a __version__ part to the cache directory improves traceability
203
        # by indicating which StrictDoc version the cache belongs to.
204
        # This helps prevent issues when switching between versions that may use
205
        # incompatible cache schemas.
206
        dir_for_sdoc_cache = os.path.join(dir_for_sdoc_cache, __version__)
207
 
208
        self.dir_for_sdoc_cache: str = dir_for_sdoc_cache
209
 
210
        #
211
        # project_features
212
        #
213
        project_features_: List[Union[str, Feature]] = (
214
            project_features
215
            if project_features is not None
216
            else list(ProjectConfigDefault.DEFAULT_FEATURES)
217
        )
218
 
219
        assert isinstance(project_features_, list), (
220
            f"config: project_features: parameter must be an "
221
            f"array: '{project_features_}'."
222
        )
223
 
224
        for feature in project_features_:
225
            if isinstance(feature, Feature):
226
                continue
227
            assert feature in ProjectFeature.all(), (
228
                f"config: project_features: unknown feature declared: "
229
                f"'{feature}'."
230
            )
231
 
232
        if ProjectFeature.ALL_FEATURES in project_features_:
233
            custom_features = [
234
                feature
235
                for feature in project_features_
236
                if isinstance(feature, Feature)
237
            ]
238
            project_features_ = [*ProjectFeature.all(), *custom_features]
239
 
240
        self.project_features: List[Union[str, Feature]] = project_features_
241
 
242
        #
243
        # server_host and server_port
244
        #
245
        assert is_valid_host(server_host), (
246
            f"config: server_host: invalid host: {server_host}'."
247
        )
248
        self.server_host: str = server_host
249
 
250
        assert isinstance(server_port, int) and 1024 < server_port < 65000, (
251
            f"strictdoc.toml: 'port': invalid port: {server_port}'."
252
        )
253
        self.server_port: int = server_port
254
 
255
        #
256
        # input_paths
257
        #
258
        self.input_paths: Optional[List[str]] = input_paths
259
 
260
        #
261
        # include_doc_paths
262
        #
263
        include_doc_paths = include_doc_paths or []
264
        assert isinstance(include_doc_paths, list), include_doc_paths
265
        for include_doc_path in include_doc_paths:
266
            try:
267
                validate_mask(include_doc_path)
268
            except SyntaxError as exception_:
269
                raise ValueError(
270
                    f"config: include_doc_paths: {exception_}"
271
                ) from exception_
272
        self.include_doc_paths: List[str] = include_doc_paths
273
 
274
        #
275
        # exclude_doc_paths
276
        #
277
        exclude_doc_paths = exclude_doc_paths or []
278
        assert isinstance(exclude_doc_paths, list), exclude_doc_paths
279
        for exclude_doc_path in exclude_doc_paths:
280
            try:
281
                validate_mask(exclude_doc_path)
282
            except SyntaxError as exception_:
283
                raise ValueError(
284
                    f"config: exclude_doc_paths: {exception_}"
285
                ) from exception_
286
        self.exclude_doc_paths: List[str] = exclude_doc_paths
287
 
288
        #
289
        # include_source_paths
290
        #
291
        include_source_paths = include_source_paths or []
292
        assert isinstance(include_source_paths, list), include_source_paths
293
        for include_source_path in include_source_paths:
294
            try:
295
                validate_mask(include_source_path)
296
            except SyntaxError as exception_:
297
                raise ValueError(
298
                    f"config: include_source_paths: {exception_}"
299
                ) from exception_
300
        self.include_source_paths: List[str] = include_source_paths
301
 
302
        #
303
        # exclude_source_paths
304
        #
305
        exclude_source_paths = exclude_source_paths or []
306
        assert isinstance(exclude_source_paths, list), exclude_source_paths
307
        for exclude_source_path in exclude_source_paths:
308
            try:
309
                validate_mask(exclude_source_path)
310
            except SyntaxError as exception_:
311
                raise ValueError(
312
                    f"config: exclude_source_paths: {exception_}"
313
                ) from exception_
314
        self.exclude_source_paths: List[str] = exclude_source_paths
315
 
316
        #
317
        # source_root_path
318
        #
319
        self.source_root_path: Optional[str] = source_root_path
320
 
321
        #
322
        # grammars - Grammar aliases.
323
        #
324
        self.grammars: Dict[str, str] = grammars or {}
325
 
326
        self.test_report_root_dict: Dict[str, str] = (
327
            test_report_root_dict if test_report_root_dict is not None else {}
328
        )
329
        self.source_nodes: List[SourceNodesEntry] = (
330
            source_nodes if source_nodes is not None else []
331
        )
332
 
333
        # Settings derived from the command-line parameters.
334
 
335
        # Common settings.
336
        self.output_dir: str = ProjectConfigDefault.DEFAULT_DIR_FOR_OUTPUT
337
 
338
        # Export action.
339
        self.export_output_html_root: str = os.path.join(
340
            self.output_dir, "html"
341
        )
342
        self.export_formats: Optional[List[str]] = None
343
        self.formats: List[Format] = (
344
            formats if formats is not None else ProjectConfig.default_formats()
345
        )
346
        self.export_included_documents: bool = False
347
        self.generate_bundle_document: bool = False
348
        self.filter_nodes: Optional[str] = None
349
 
350
        self.excel_export_fields: Optional[List[str]] = None
351
 
352
        assert isinstance(html2pdf_strict, bool), (
353
            "config: html2pdf_strict: "
354
            f"must be a True/False value: {html2pdf_strict}."
355
        )
356
        self.html2pdf_strict: bool = html2pdf_strict
357
 
358
        self.html2pdf_template: Optional[str] = html2pdf_template
359
 
360
        if html2pdf_forced_page_break_nodes is not None:
361
            assert isinstance(html2pdf_forced_page_break_nodes, list)
362
            assert len(html2pdf_forced_page_break_nodes) <= 10
363
        self.html2pdf_forced_page_break_nodes: List[str] = (
364
            html2pdf_forced_page_break_nodes or []
365
        )
366
 
367
        self.bundle_document_uid: Optional[str] = bundle_document_uid
368
        self.bundle_document_version: Optional[str] = bundle_document_version
369
        self.bundle_document_date: Optional[str] = bundle_document_date
370
 
371
        self.traceability_matrix_relation_columns: Optional[
372
            List[Tuple[str, Optional[str]]]
373
        ] = traceability_matrix_relation_columns
374
 
375
        #
376
        # ReqIF
377
        #
378
        self.reqif_profile: str = reqif_profile
379
 
380
        assert isinstance(reqif_multiline_is_xhtml, bool), (
381
            reqif_multiline_is_xhtml
382
        )
383
        self.reqif_multiline_is_xhtml: bool = reqif_multiline_is_xhtml
384
 
385
        assert isinstance(reqif_enable_mid, bool), reqif_enable_mid
386
        self.reqif_enable_mid: bool = reqif_enable_mid
387
 
388
        if reqif_import_markup is not None:
389
            assert reqif_import_markup in SDocMarkup.ALL, (
390
                "config: reqif_import_markup: expected a valid markup: "
391
                f"({SDocMarkup.ALL}). Got: "
392
                f"'{reqif_import_markup}'."
393
            )
394
 
395
        self.reqif_import_markup: Optional[str] = reqif_import_markup
396
 
397
        #
398
        # auto_uid_mode: default is False. The True-case is used by the
399
        # manage/auto_uid command: the SDocNodeValidator will
400
        # not raise an exception if it sees a node with a missing UID.
401
        # Important for a special case:
402
        # The Manage UID command auto-generates the UID, so the field presence
403
        # validation has to be relaxed.
404
        # The GitHub issue report:
405
        # manage auto-uid: UID field REQUIRED True leads to an error
406
        # https://github.com/strictdoc-project/strictdoc/issues/1896
407
        #
408
        self.auto_uid_mode = False
409
        self.autouuid_include_sections: bool = False
410
 
411
        self.view: Optional[str] = None
412
 
413
        self.diff_git_revisions: Optional[str] = diff_git_revisions
414
        self.diff_dir_revisions: Optional[Tuple[str, str]] = diff_dir_revisions
415
 
416
        self.chromedriver: Optional[str] = chromedriver
417
        self.section_behavior: Optional[str] = section_behavior
418
 
419
        self.statistics_generator: Optional[str] = statistics_generator
420
 
421
        if document_line_width is not None:
422
            assert isinstance(document_line_width, int), document_line_width
423
            assert document_line_width >= 80, (
424
                f"config: document_line_width: minimum acceptable value is 80, "
425
                f"got: {document_line_width}."
426
            )
427
        self.document_line_width: Optional[int] = document_line_width
428
 
429
        self.user_plugin: Optional[StrictDocPlugin] = user_plugin
430
 
431
        # Optional launcher logo path (absolute or workspace-relative).
432
        self.launcher_logo_path: Optional[str] = launcher_logo_path
433
 
434
        # Optional custom favicon path, project-relative. Validated and
435
        # resolved to an absolute path in validate_and_finalize().
436
        self.favicon_path: Optional[str] = favicon_path
437
 
438
        self.config_last_update: Optional[datetime.datetime] = (
439
            _config_last_update
440
        )
441
        self.is_running_on_server: bool = False
442
        self.watch_enabled: bool = False
443
 
444
    @staticmethod
445
    def default_config() -> "ProjectConfig":
446
        return ProjectConfig()
447
 
448
    @staticmethod
449
    def default_formats() -> List["Format"]:
450
        # Imported locally to avoid a circular import: each Format module
451
        # imports generator/writer classes that, transitively, import
452
        # ProjectConfig itself.
453
        from strictdoc.backend.excel.export.excel_format import (  # noqa: PLC0415
454
            ExcelFormat,
455
        )
456
        from strictdoc.backend.gcov.gcov_format import (  # noqa: PLC0415
457
            GCovJSONFormat,
458
        )
459
        from strictdoc.backend.json.json_format import (  # noqa: PLC0415
460
            JSONFormat,
461
        )
462
        from strictdoc.backend.markdown.markdown_format import (  # noqa: PLC0415
463
            MarkdownFormat,
464
        )
465
        from strictdoc.backend.reqif.reqif_format import (  # noqa: PLC0415
466
            ReqIFFormat,
467
        )
468
        from strictdoc.backend.rst.rst_format import (  # noqa: PLC0415
469
            RSTFormat,
470
        )
471
        from strictdoc.backend.sdoc.sdoc_format import (  # noqa: PLC0415
472
            SDocFormat,
473
        )
474
        from strictdoc.backend.sdoc_source_code.test_reports.junit_xml_format import (  # noqa: PLC0415
475
            JUnitXMLFormat,
476
        )
477
        from strictdoc.backend.sdoc_source_code.test_reports.robot_xml_format import (  # noqa: PLC0415
478
            RobotXMLFormat,
479
        )
480
        from strictdoc.backend.spdx.spdx_format import (  # noqa: PLC0415
481
            SPDXFormat,
482
        )
483
        from strictdoc.export.html.html_format import (  # noqa: PLC0415
484
            HTMLFormat,
485
        )
486
        from strictdoc.features.doxygen.doxygen_format import (  # noqa: PLC0415
487
            DoxygenFormat,
488
        )
489
        from strictdoc.features.html2pdf.html2pdf_format import (  # noqa: PLC0415
490
            HTML2PDFFormat,
491
        )
492
 
493
        return [
494
            HTMLFormat(),
495
            HTML2PDFFormat(),
496
            RSTFormat(),
497
            ExcelFormat(),
498
            ReqIFFormat(),
499
            SDocFormat(),
500
            MarkdownFormat(),
501
            DoxygenFormat(),
502
            SPDXFormat(),
503
            JUnitXMLFormat(),
504
            GCovJSONFormat(),
505
            RobotXMLFormat(),
506
            JSONFormat(),
507
        ]
508
 
509
    @staticmethod
510
    def _builtin_features_by_handle() -> Dict[str, Feature]:
511
        # Imported locally to avoid a circular import, mirroring
512
        # default_formats() above.
513
        from strictdoc.features.project_statistics.feature import (  # noqa: PLC0415
514
            ProjectStatisticsFeature,
515
        )
516
 
517
        return {
518
            feature.HANDLE: feature for feature in [ProjectStatisticsFeature()]
519
        }
520
 
521
    def get_features(self) -> List[Feature]:
522
        """
523
        Resolve self.project_features (a mix of built-in string handles and
524
        directly-registered Feature instances) into concrete Feature
525
        instances. A string handle that isn't backed by a built-in Feature
526
        yet (i.e. most ProjectFeature enum members, which have not been
527
        migrated to the Feature abstraction) resolves to nothing here.
528
        """
529
        builtin_features_by_handle = ProjectConfig._builtin_features_by_handle()
530
        resolved_features: List[Feature] = []
531
        for feature in self.project_features:
532
            if isinstance(feature, Feature):
533
                resolved_features.append(feature)
534
            elif feature in builtin_features_by_handle:
535
                resolved_features.append(builtin_features_by_handle[feature])
536
        return resolved_features
537
 
538
    def get_feature(self, handle: str) -> Optional[Feature]:
539
        for feature in self.get_features():
540
            if feature.HANDLE == handle:
541
                return feature
542
        return None
543
 
544
    # Some server command settings can override the project config settings.
545
    def integrate_server_config(
546
        self, server_config: ServerCommandConfig
547
    ) -> None:
548
        self.is_running_on_server = True
549
        self.watch_enabled = server_config.watch
550
        if (server_host_ := server_config.host) is not None:
551
            self.server_host = server_host_
552
        if (server_port_ := server_config.port) is not None:
553
            self.server_port = server_port_
554
 
555
        self.input_paths = [server_config.get_full_input_path()]
556
        if self.source_root_path is None:
557
            source_root_path = self.input_paths[0]
558
            # If the input argument is a relative path, convert it to an
559
            # absolute path.
560
            source_root_path = os.path.abspath(source_root_path)
561
            source_root_path = source_root_path.rstrip("/")
562
            self.source_root_path = source_root_path
563
 
564
        # When setting the output dir, the CLI argument takes precedence.
565
        output_dir = self.output_dir
566
        if server_config.output_path is not None:
567
            output_dir = server_config.output_path
568
        elif output_dir == ProjectConfigDefault.DEFAULT_DIR_FOR_OUTPUT:
569
            output_dir = "./output/server"
570
        self.output_dir = output_dir
571
 
572
        self.export_output_html_root = os.path.join(output_dir, "html")
573
 
574
        # If a custom cache folder is not specified in the config, adjust the
575
        # cache folder to be located in the output folder.
576
        if self.dir_for_sdoc_cache.startswith(
577
            ProjectConfigDefault.DEFAULT_DIR_FOR_SDOC_CACHE
578
        ):
579
            self.dir_for_sdoc_cache = os.path.join(
580
                output_dir, "_cache", __version__
581
            )
582
 
583
        self.export_formats = ["html"]
584
        self.generate_bundle_document = False
585
        self.export_included_documents = True
586
 
587
    def integrate_export_config(
588
        self, export_config: ExportCommandConfig
589
    ) -> None:
590
        if export_config.project_title is not None:
591
            self.project_title = export_config.project_title
592
 
593
        self.input_paths = export_config.input_paths
594
        if self.source_root_path is None:
595
            source_root_path = export_config.input_paths[0]
596
            # If the input argument is a relative path, convert it to an
597
            # absolute path.
598
            source_root_path = os.path.abspath(source_root_path)
599
            source_root_path = source_root_path.rstrip("/")
600
            self.source_root_path = source_root_path
601
 
602
        #
603
        # Adjust the default output dir to the user-provided dir if needed.
604
        #
605
        output_dir = self.output_dir
606
        if export_config.output_dir is not None:
607
            output_dir = export_config.output_dir
608
        if not os.path.isabs(output_dir):
609
            cwd = os.getcwd()
610
            output_dir = os.path.join(cwd, output_dir)
611
        self.output_dir = output_dir
612
 
613
        # If a custom cache folder is not specified in the config, adjust the
614
        # cache folder to be located in the output folder.
615
        if self.dir_for_sdoc_cache.startswith(
616
            ProjectConfigDefault.DEFAULT_DIR_FOR_SDOC_CACHE
617
        ):
618
            self.dir_for_sdoc_cache = os.path.join(
619
                output_dir, "_cache", __version__
620
            )
621
 
622
        self.export_output_html_root = os.path.join(self.output_dir, "html")
623
        self.export_formats = export_config.formats
624
        self.export_included_documents = export_config.included_documents
625
        self.generate_bundle_document = export_config.generate_bundle_document
626
        self.filter_nodes = export_config.filter_nodes
627
        self.excel_export_fields = export_config.fields
628
        self.view = export_config.view
629
 
630
        if ProjectFeature.DIFF in self.project_features:
631
            if export_config.generate_diff_git is not None:
632
                self.diff_git_revisions = export_config.generate_diff_git
633
            if export_config.generate_diff_dirs is not None:
634
                self.diff_dir_revisions = export_config.generate_diff_dirs
635
 
636
        self.chromedriver = export_config.chromedriver
637
 
638
        if (
639
            export_config.enable_mathjax
640
            and ProjectFeature.MATHJAX not in self.project_features
641
        ):
642
            self.project_features.append(ProjectFeature.MATHJAX)
643
 
644
        if export_config.reqif_profile is not None:
645
            self.reqif_profile = export_config.reqif_profile
646
 
647
        # If the TOML file sets this to True, ignore what is in CLI.
648
        if not self.reqif_multiline_is_xhtml:
649
            self.reqif_multiline_is_xhtml = (
650
                export_config.reqif_multiline_is_xhtml
651
            )
652
        if not self.reqif_enable_mid:
653
            self.reqif_enable_mid = export_config.reqif_enable_mid
654
 
655
    def validate_and_finalize(self) -> None:
656
        project_path = self.get_project_root_path()
657
 
658
        #
659
        # Validate source nodes config.
660
        #
661
        if (
662
            len(self.source_nodes) > 0
663
            and ProjectFeature.REQUIREMENT_TO_SOURCE_TRACEABILITY
664
            not in self.project_features
665
        ):
666
            print(  # noqa: T201
667
                "warning: defining source_nodes without enabling REQUIREMENT_TO_SOURCE_TRACEABILITY "
668
                "has no effect"
669
            )
670
 
671
        if ProjectFeature.SOURCE_FILE_LANGUAGE_PARSERS in self.project_features:
672
            print(  # noqa: T201
673
                "info: the SOURCE_FILE_LANGUAGE_PARSERS feature is no longer "
674
                "experimental and is now enabled by default. "
675
                "It can be safely removed from the project configuration."
676
            )
677
 
678
        if ProjectFeature.MATHJAX in self.project_features:
679
            DEPRECATION_ENGINE.add_message(
680
                "MATHJAX_feature_deprecated",
681
                "The MATHJAX feature is now enabled by default and no longer "
682
                "needs to be listed in the project configuration. "
683
                "Please remove it from the project_features list in your "
684
                "strictdoc_config.py file.",
685
            )
686
 
687
        if ProjectFeature.MERMAID in self.project_features:
688
            DEPRECATION_ENGINE.add_message(
689
                "MERMAID_feature_deprecated",
690
                "The MERMAID feature is now enabled by default and no longer "
691
                "needs to be listed in the project configuration. "
692
                "Please remove it from the project_features list in your "
693
                "strictdoc_config.py file.",
694
            )
695
 
696
        #
697
        # Validate HTML2PDF template path.
698
        #
699
        if (html2pdf_template := self.html2pdf_template) is not None:
700
            assert not os.path.isabs(html2pdf_template)
701
            if project_path is not None:
702
                html2pdf_template = os.path.join(
703
                    project_path, html2pdf_template
704
                )
705
            if not os.path.isfile(html2pdf_template):
706
                raise ValueError(
707
                    "config: html2pdf_template: "
708
                    f"invalid path to a template file: {html2pdf_template}."
709
                )
710
 
711
        #
712
        # Validate favicon path.
713
        #
714
        if (favicon_path := self.favicon_path) is not None:
715
            assert not os.path.isabs(favicon_path)
716
            if project_path is not None:
717
                favicon_path = os.path.join(project_path, favicon_path)
718
            if not os.path.isfile(favicon_path):
719
                raise ValueError(
720
                    "config: favicon_path: "
721
                    f"invalid path to a favicon file: {favicon_path}."
722
                )
723
            self.favicon_path = favicon_path
724
 
725
        #
726
        # Validate path to Chrome Driver.
727
        #
728
        if (
729
            chromedriver := self.chromedriver
730
        ) is not None and not os.path.isfile(chromedriver):
731
            raise ValueError(
732
                f"config: chromedriver: not found at path: {chromedriver}."
733
            )
734
 
735
        #
736
        # Resolve the source root path.
737
        #
738
        if os.path.isdir(project_path):
739
            source_root_path = self.source_root_path
740
            if source_root_path is not None:
741
                original_source_root_path = source_root_path
742
                if not os.path.isabs(source_root_path):
743
                    source_root_path = os.path.join(
744
                        project_path, source_root_path
745
                    )
746
                    source_root_path = os.path.abspath(source_root_path)
747
                if not os.path.isdir(source_root_path):
748
                    raise ValueError(
749
                        "config: "
750
                        "source_root_path: "
751
                        f"Provided path does not exist: "
752
                        f"{original_source_root_path}."
753
                    )
754
                self.source_root_path = source_root_path
755
 
756
        #
757
        # Read exclude paths from .gitignore. Add them to the user project's
758
        # both SDoc and source file search paths.
759
        #
760
        path_to_gitignore = os.path.join(project_path, ".gitignore")
761
        if os.path.isfile(path_to_gitignore):
762
            patterns = ["/.git/"]
763
 
764
            with open(path_to_gitignore, encoding="utf-8") as f:
765
                for line_ in f:
766
                    line = line_.strip()
767
                    if not line or line.startswith("#"):
768
                        continue
769
                    # Ignore !-negated gitignores for now or reimplement
770
                    # using a dedicated gitignore Python library.
771
                    if line.startswith("!"):
772
                        continue
773
                    patterns.append(line)
774
 
775
            self.exclude_doc_paths.extend(patterns)
776
            self.exclude_source_paths.extend(patterns)
777
 
778
        #
779
        # Validate that the provided grammar shortcuts all point to existing
780
        # grammar files.
781
        #
782
        for grammar_alias_, grammar_path_ in list(self.grammars.items()):
783
            assert grammar_alias_.startswith("@"), (
784
                "Grammar alias must start with an '@' character."
785
            )
786
            assert "." not in grammar_alias_, (
787
                "Grammar alias must not contain any . characters."
788
            )
789
            assert os.path.isfile(os.path.join(project_path, grammar_path_)), (
790
                "Grammar path must point to an existing path relative to the "
791
                f"project config file: {grammar_path_}."
792
            )
793
            if grammar_path_.startswith("./"):
794
                self.grammars[grammar_alias_] = grammar_path_.removeprefix("./")
795
 
796
    def is_feature_activated(self, feature: ProjectFeature) -> bool:
797
        return feature in self.project_features
798
 
799
    def get_favicon_variant(self) -> str:
800
        return resolve_favicon_variant(
801
            self.environment, self.is_running_on_server
802
        )
803
 
804
    def get_custom_favicon_path(self) -> Optional[str]:
805
        if self.favicon_path is None:
806
            return None
807
        if self.get_favicon_variant() in ("dev", "test"):
808
            return None
809
        return self.favicon_path
810
 
811
    def get_favicon_filename(self) -> str:
812
        custom_favicon_path = self.get_custom_favicon_path()
813
        if custom_favicon_path is None:
814
            return "favicon.svg"
815
        return "favicon" + os.path.splitext(custom_favicon_path)[1]
816
 
817
    def get_favicon_mime_type(self) -> str:
818
        custom_favicon_path = self.get_custom_favicon_path()
819
        if custom_favicon_path is None:
820
            return "image/svg+xml"
821
        mime_type, _ = mimetypes.guess_type(custom_favicon_path)
822
        return mime_type or "application/octet-stream"
823
 
824
    def is_activated_table_screen(self) -> bool:
825
        return ProjectFeature.TABLE_SCREEN in self.project_features
826
 
827
    def is_activated_trace_screen(self) -> bool:
828
        return ProjectFeature.TRACEABILITY_SCREEN in self.project_features
829
 
830
    def is_activated_deep_trace_screen(self) -> bool:
831
        return ProjectFeature.DEEP_TRACEABILITY_SCREEN in self.project_features
832
 
833
    def is_activated_project_statistics(self) -> bool:
834
        return (
835
            self.get_feature(ProjectFeature.PROJECT_STATISTICS_SCREEN)
836
            is not None
837
        )
838
 
839
    def is_activated_requirements_to_source_traceability(self) -> bool:
840
        return (
841
            ProjectFeature.REQUIREMENT_TO_SOURCE_TRACEABILITY
842
            in self.project_features
843
        )
844
 
845
    def is_activated_requirements_coverage(self) -> bool:
846
        return (
847
            ProjectFeature.TRACEABILITY_MATRIX_SCREEN in self.project_features
848
        )
849
 
850
    def is_activated_tree_map(self) -> bool:
851
        return ProjectFeature.TREE_MAP_SCREEN in self.project_features
852
 
853
    def is_activated_search(self) -> bool:
854
        return (
855
            self.is_running_on_server
856
            and ProjectFeature.SEARCH in self.project_features
857
        )
858
 
859
    def is_activated_html2pdf(self) -> bool:
860
        return ProjectFeature.HTML2PDF in self.project_features
861
 
862
    def is_activated_diff(self) -> bool:
863
        return ProjectFeature.DIFF in self.project_features
864
 
865
    def is_activated_reqif(self) -> bool:
866
        return ProjectFeature.REQIF in self.project_features
867
 
868
    def is_activated_mathjax(self) -> bool:
869
        # FIXME: Refactor Jinja templates to not rely on the MathJax feature
870
        # flag, since MathJax is now a stable feature that is always included in
871
        # the static assets.
872
        return True
873
 
874
    def is_activated_mermaid(self) -> bool:
875
        # FIXME: Refactor Jinja templates to not rely on the Mermaid feature
876
        # flag, since Mermaid is now a stable feature that is always included
877
        # in the static assets.
878
        return True
879
 
880
    def get_project_root_path(self) -> str:
881
        if self.input_paths is not None and len(self.input_paths) > 0:
882
            return self.input_paths[0]
883
        raise NotImplementedError
884
 
885
    def get_strictdoc_root_path(self) -> str:
886
        return self.environment.path_to_strictdoc
887
 
888
    def get_path_to_cache_dir(self) -> str:
889
        return self.dir_for_sdoc_cache
890
 
891
    def get_static_files_paths(self) -> List[str]:
892
        return self.environment.get_static_files_paths()
893
 
894
    def get_project_hash(self) -> str:
895
        assert self.input_paths is not None and len(self.input_paths) > 0
896
        return get_md5(self.input_paths[0])
897
 
898
    def get_relevant_source_nodes_entry(
899
        self, path_to_file: str
900
    ) -> Optional[SourceNodesEntry]:
901
        """
902
        Get relevant source_nodes config item for a given source code file.
903
 
904
        Returns data for the first entry from source_nodes that is a parent path of path_to_file.
905
        If path_to_file is absolute, source node config entries are assumed to be in the source_root_path.
906
        """
907
 
908
        source_root_path = self.source_root_path
909
        assert source_root_path is not None
910
        assert os.path.exists(source_root_path), source_root_path
911
 
912
        source_file_path = Path(path_to_file)
913
        for sdoc_source_config_entry_ in self.source_nodes:
914
            # FIXME: Move the setting of full paths to .finalize() of this config
915
            #        class when it is implemented.
916
            if sdoc_source_config_entry_.full_path is None:
917
                sdoc_source_config_entry_.full_path = Path(
918
                    source_root_path
919
                ) / Path(sdoc_source_config_entry_.path)
920
 
921
            if source_file_path.is_absolute():
922
                if (
923
                    sdoc_source_config_entry_.full_path
924
                    in source_file_path.parents
925
                ):
926
                    return sdoc_source_config_entry_
927
            else:
928
                if (
929
                    Path(sdoc_source_config_entry_.path)
930
                    in source_file_path.parents
931
                ):
932
                    return sdoc_source_config_entry_
933
 
934
        return None
935
 
936
 
937
class ProjectConfigLoader:
938
    @classmethod
939
    def load(
940
        cls, input_path: str, output_dir: Optional[str] = None
941
    ) -> ProjectConfig:
942
        assert os.path.exists(input_path), input_path
943
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
944
            path_to_config=input_path
945
        )
946
        project_config.input_paths = [input_path]
947
        if output_dir is not None:
948
            project_config.output_dir = output_dir
949
        project_config.validate_and_finalize()
950
        return project_config
951
 
952
    @classmethod
953
    def load_using_export_config(
954
        cls,
955
        export_config: ExportCommandConfig,
956
    ) -> ProjectConfig:
957
        path_to_config = export_config.get_path_to_config()
958
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
959
            path_to_config=path_to_config
960
        )
961
        project_config.integrate_export_config(export_config)
962
        project_config.validate_and_finalize()
963
        return project_config
964
 
965
    @classmethod
966
    def load_using_server_config(
967
        cls,
968
        server_config: ServerCommandConfig,
969
    ) -> ProjectConfig:
970
        path_to_config = server_config.get_path_to_config()
971
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
972
            path_to_config=path_to_config
973
        )
974
        project_config.integrate_server_config(server_config)
975
        project_config.validate_and_finalize()
976
        return project_config
977
 
978
    @classmethod
979
    def load_using_convert_config(
980
        cls,
981
        convert_config: ConvertCommandConfig,
982
    ) -> ProjectConfig:
983
        path_to_config = convert_config.get_path_to_config()
984
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
985
            path_to_config=path_to_config
986
        )
987
        project_config.input_paths = [os.getcwd()]
988
        project_config.validate_and_finalize()
989
        return project_config
990
 
991
    @classmethod
992
    def load_using_manage_autouid_config(
993
        cls,
994
        manage_autouid_config: ManageAutoUIDCommandConfig,
995
    ) -> ProjectConfig:
996
        path_to_config = manage_autouid_config.get_path_to_config()
997
 
998
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
999
            path_to_config=path_to_config
1000
        )
1001
 
1002
        # FIXME: Encapsulate all this in project_config.integrate_manage_autouid_config(),
1003
        #        following the example of integrate_export_config().
1004
        project_config.input_paths = [manage_autouid_config.input_path]
1005
        if project_config.source_root_path is None:
1006
            project_config.source_root_path = str(
1007
                Path(manage_autouid_config.input_path).resolve()
1008
            )
1009
        project_config.auto_uid_mode = True
1010
        project_config.autouuid_include_sections = (
1011
            manage_autouid_config.include_sections
1012
        )
1013
 
1014
        # FIXME: Traceability Index is coupled with HTML output.
1015
        project_config.export_output_html_root = "NOT_RELEVANT"
1016
 
1017
        project_config.validate_and_finalize()
1018
 
1019
        return project_config
1020
 
1021
    @classmethod
1022
    def load_using_format_config(
1023
        cls,
1024
        format_config: FormatCommandConfig,
1025
    ) -> "ProjectConfig":
1026
        path_to_config = format_config.get_path_to_config()
1027
 
1028
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
1029
            path_to_config=path_to_config
1030
        )
1031
 
1032
        project_config.input_paths = [format_config.input_path]
1033
        if project_config.source_root_path is None:
1034
            project_config.source_root_path = str(
1035
                Path(format_config.input_path).resolve()
1036
            )
1037
 
1038
        # FIXME: Traceability Index is coupled with HTML output.
1039
        project_config.export_output_html_root = "NOT_RELEVANT"
1040
 
1041
        project_config.validate_and_finalize()
1042
 
1043
        return project_config
1044
 
1045
    @classmethod
1046
    def load_using_manage_new_config(
1047
        cls,
1048
        manage_new_config: ManageNewCommandConfig,
1049
    ) -> "ProjectConfig":
1050
        path_to_config = manage_new_config.get_path_to_config()
1051
 
1052
        project_config: ProjectConfig = cls.load_from_path_or_get_default(
1053
            path_to_config=path_to_config
1054
        )
1055
 
1056
        project_config.input_paths = [manage_new_config.project_root_path]
1057
        if project_config.source_root_path is None:
1058
            project_config.source_root_path = str(
1059
                Path(manage_new_config.project_root_path).resolve()
1060
            )
1061
 
1062
        # FIXME: Traceability Index is coupled with HTML output.
1063
        project_config.export_output_html_root = "NOT_RELEVANT"
1064
 
1065
        project_config.validate_and_finalize()
1066
 
1067
        return project_config
1068
 
1069
    @staticmethod
1070
    def load_from_path_or_get_default(
1071
        *,
1072
        path_to_config: str,
1073
    ) -> ProjectConfig:
1074
        if not os.path.exists(path_to_config):
1075
            return ProjectConfig.default_config()
1076
        if os.path.isdir(path_to_config):
1077
            path_to_config_dir = path_to_config
1078
            # Prefer the Python config file when both are present.
1079
            path_to_py_config = os.path.join(
1080
                path_to_config_dir, "strictdoc_config.py"
1081
            )
1082
            path_to_toml_config = os.path.join(
1083
                path_to_config_dir, "strictdoc.toml"
1084
            )
1085
 
1086
            if os.path.isfile(path_to_py_config):
1087
                path_to_config = path_to_py_config
1088
            elif os.path.isfile(path_to_toml_config):
1089
                path_to_config = path_to_toml_config
1090
 
1091
        if not os.path.isfile(path_to_config):
1092
            return ProjectConfig.default_config()
1093
 
1094
        if path_to_config.endswith(".py"):
1095
            return ProjectConfigLoader.load_from_python(
1096
                config_py_path=path_to_config
1097
            )
1098
 
1099
        try:
1100
            config_content = toml.load(path_to_config)
1101
        except toml.decoder.TomlDecodeError as exception:
1102
            raise StrictDocException(  # noqa: T201
1103
                f"Could not parse the config file {path_to_config}: "
1104
                f"{exception}."
1105
            ) from None
1106
        except Exception as exception:  # pragma: no cover
1107
            raise AssertionError from exception
1108
 
1109
        DEPRECATION_ENGINE.add_message(
1110
            "DEPRECATED_CONFIG_TOML",
1111
            (
1112
                "WARNING: StrictDoc TOML configuration format is deprecated. "
1113
                "Replace the TOML config file with a Python config file.\n\n"
1114
                "See the migration guide for mode details:\n\n"
1115
                "https://strictdoc.readthedocs.io/en/stable/?a=SECTION-UG-MIGRATE-CONFIG-2025-Q4"
1116
            ),
1117
        )
1118
 
1119
        config_last_update = get_file_modification_time(path_to_config)
1120
 
1121
        return ProjectConfigLoader._load_from_dictionary(
1122
            config_dict=config_content,
1123
            config_last_update=config_last_update,
1124
        )
1125
 
1126
    @staticmethod
1127
    def load_from_python(*, config_py_path: str) -> ProjectConfig:
1128
        module = import_from_path(config_py_path)
1129
        create_config_function = module.create_config
1130
        assert isinstance(create_config_function, types.FunctionType), type(
1131
            create_config_function
1132
        )
1133
        project_config = create_config_function()
1134
        assert isinstance(project_config, ProjectConfig)
1135
        project_config.config_last_update = get_file_modification_time(
1136
            config_py_path
1137
        )
1138
        return project_config
1139
 
1140
    @staticmethod
1141
    def _load_from_dictionary(
1142
        *,
1143
        config_dict: Dict[str, Any],
1144
        config_last_update: Optional[datetime.datetime],
1145
    ) -> ProjectConfig:
1146
        project_title = ProjectConfigDefault.DEFAULT_PROJECT_TITLE
1147
        dir_for_sdoc_assets = ProjectConfigDefault.DEFAULT_DIR_FOR_SDOC_ASSETS
1148
        dir_for_sdoc_cache = ProjectConfigDefault.DEFAULT_DIR_FOR_SDOC_CACHE
1149
        project_features: List[Union[str, Feature]] = list(
1150
            ProjectConfigDefault.DEFAULT_FEATURES
1151
        )
1152
        server_host = ProjectConfigDefault.DEFAULT_SERVER_HOST
1153
        server_port = ProjectConfigDefault.DEFAULT_SERVER_PORT
1154
        include_doc_paths: List[str] = []
1155
        exclude_doc_paths: List[str] = []
1156
        source_root_path = None
1157
        include_source_paths: List[str] = []
1158
        exclude_source_paths: List[str] = []
1159
        test_report_root_dict: Dict[str, str] = {}
1160
        source_nodes: List[SourceNodesEntry] = []
1161
        html2pdf_strict: bool = False
1162
        html2pdf_template: Optional[str] = None
1163
        bundle_document_version = (
1164
            ProjectConfigDefault.DEFAULT_BUNDLE_DOCUMENT_VERSION
1165
        )
1166
        bundle_document_date = (
1167
            ProjectConfigDefault.DEFAULT_BUNDLE_DOCUMENT_COMMIT_DATE
1168
        )
1169
 
1170
        traceability_matrix_relation_columns: Optional[
1171
            List[Tuple[str, Optional[str]]]
1172
        ] = None
1173
        reqif_profile = ReqIFProfile.P01_SDOC
1174
        reqif_multiline_is_xhtml = False
1175
        reqif_enable_mid = False
1176
        reqif_import_markup: Optional[str] = None
1177
        chromedriver: Optional[str] = None
1178
 
1179
        section_behavior: str = ProjectConfigDefault.DEFAULT_SECTION_BEHAVIOR
1180
        statistics_generator: Optional[str] = None
1181
        document_line_width: Optional[int] = None
1182
 
1183
        if "project" in config_dict:
1184
            project_content = config_dict["project"]
1185
            project_title = project_content.get("title", project_title)
1186
            dir_for_sdoc_assets = project_content.get(
1187
                "html_assets_strictdoc_dir", dir_for_sdoc_assets
1188
            )
1189
            dir_for_sdoc_cache = project_content.get(
1190
                "cache_dir", dir_for_sdoc_cache
1191
            )
1192
 
1193
            project_features = project_content.get("features", project_features)
1194
 
1195
            statistics_generator = project_content.get(
1196
                "statistics_generator", statistics_generator
1197
            )
1198
 
1199
            include_doc_paths = project_content.get(
1200
                "include_doc_paths", include_doc_paths
1201
            )
1202
 
1203
            exclude_doc_paths = project_content.get(
1204
                "exclude_doc_paths", exclude_doc_paths
1205
            )
1206
 
1207
            source_root_path = project_content.get(
1208
                "source_root_path", source_root_path
1209
            )
1210
 
1211
            include_source_paths = project_content.get(
1212
                "include_source_paths", include_source_paths
1213
            )
1214
 
1215
            exclude_source_paths = project_content.get(
1216
                "exclude_source_paths", exclude_source_paths
1217
            )
1218
 
1219
            html2pdf_strict = project_content.get(
1220
                "html2pdf_strict", html2pdf_strict
1221
            )
1222
 
1223
            html2pdf_template = project_content.get(
1224
                "html2pdf_template", html2pdf_template
1225
            )
1226
 
1227
            bundle_document_version = project_content.get(
1228
                "bundle_document_version", bundle_document_version
1229
            )
1230
 
1231
            bundle_document_date = project_content.get(
1232
                "bundle_document_date", bundle_document_date
1233
            )
1234
 
1235
            traceability_matrix_relation_columns_config: Optional[List[str]] = (
1236
                project_content.get(
1237
                    "traceability_matrix_relation_columns", None
1238
                )
1239
            )
1240
            if traceability_matrix_relation_columns_config is not None:
1241
                assert isinstance(
1242
                    traceability_matrix_relation_columns_config, list
1243
                )
1244
                traceability_matrix_relation_columns = []
1245
                for (
1246
                    relation_column_string_
1247
                ) in traceability_matrix_relation_columns_config:
1248
                    relation_tuple = parse_relation_tuple(
1249
                        relation_column_string_
1250
                    )
1251
                    assert relation_tuple is not None
1252
                    traceability_matrix_relation_columns.append(relation_tuple)
1253
 
1254
            chromedriver = project_content.get("chromedriver", chromedriver)
1255
 
1256
            if (
1257
                test_report_root_dict_ := project_content.get(
1258
                    "test_report_root_dict", None
1259
                )
1260
            ) is not None:
1261
                assert isinstance(test_report_root_dict_, list), (
1262
                    test_report_root_dict
1263
                )
1264
                for test_report_root_entry_ in test_report_root_dict_:
1265
                    assert isinstance(test_report_root_entry_, dict)
1266
                    test_report_root_dict.update(test_report_root_entry_)
1267
 
1268
            section_behavior = project_content.get(
1269
                "section_behavior", section_behavior
1270
            )
1271
            assert section_behavior in ("[SECTION]", "[[SECTION]]")
1272
 
1273
            document_line_width = project_content.get(
1274
                "document_line_width", document_line_width
1275
            )
1276
 
1277
            if "source_nodes" in project_content:
1278
                source_nodes_config = project_content["source_nodes"]
1279
                assert isinstance(source_nodes_config, list)
1280
                for item_ in source_nodes_config:
1281
                    source_node_path = next(iter(item_))
1282
                    source_node_item = item_[source_node_path]
1283
                    source_nodes.append(
1284
                        SourceNodesEntry(
1285
                            path=source_node_path,
1286
                            uid=source_node_item["uid"],
1287
                            node_type=source_node_item["node_type"],
1288
                            sdoc_to_source_map=source_node_item["map"]
1289
                            if "map" in source_node_item
1290
                            else {},
1291
                        )
1292
                    )
1293
 
1294
        if "server" in config_dict:
1295
            server_content = config_dict["server"]
1296
            server_host = server_content.get("host", server_host)
1297
            server_port = server_content.get("port", server_port)
1298
 
1299
        if "reqif" in config_dict:
1300
            reqif_content = config_dict["reqif"]
1301
            reqif_multiline_is_xhtml = reqif_content.get(
1302
                "multiline_is_xhtml", False
1303
            )
1304
            reqif_enable_mid = reqif_content.get("enable_mid", False)
1305
            reqif_import_markup = reqif_content.get("import_markup", None)
1306
 
1307
        return ProjectConfig(
1308
            project_title=project_title,
1309
            dir_for_sdoc_assets=dir_for_sdoc_assets,
1310
            dir_for_sdoc_cache=dir_for_sdoc_cache,
1311
            project_features=project_features,
1312
            server_host=server_host,
1313
            server_port=server_port,
1314
            include_doc_paths=include_doc_paths,
1315
            exclude_doc_paths=exclude_doc_paths,
1316
            source_root_path=source_root_path,
1317
            include_source_paths=include_source_paths,
1318
            exclude_source_paths=exclude_source_paths,
1319
            test_report_root_dict=test_report_root_dict,
1320
            source_nodes=source_nodes,
1321
            html2pdf_strict=html2pdf_strict,
1322
            html2pdf_template=html2pdf_template,
1323
            bundle_document_version=bundle_document_version,
1324
            bundle_document_date=bundle_document_date,
1325
            traceability_matrix_relation_columns=traceability_matrix_relation_columns,
1326
            reqif_profile=reqif_profile,
1327
            reqif_multiline_is_xhtml=reqif_multiline_is_xhtml,
1328
            reqif_enable_mid=reqif_enable_mid,
1329
            reqif_import_markup=reqif_import_markup,
1330
            chromedriver=chromedriver,
1331
            section_behavior=section_behavior,
1332
            statistics_generator=statistics_generator,
1333
            document_line_width=document_line_width,
1334
            _config_last_update=config_last_update,
1335
        )