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