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