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