StrictDoc Documentation
strictdoc/core/traceability_index.py
Source file coverage
Path:
strictdoc/core/traceability_index.py
Lines:
1435
Non-empty lines:
1264
Non-empty lines covered with requirements:
1264 / 1264 (100.0%)
Functions:
69
Functions covered by requirements:
69 / 69 (100.0%)
1
"""
2
@relation(SDOC-SRS-28, scope=file)
3
"""
4
 
5
import datetime
6
from copy import copy, deepcopy
7
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
8
 
9
from strictdoc.backend.sdoc.document_reference import DocumentReference
10
from strictdoc.backend.sdoc.models.anchor import Anchor
11
from strictdoc.backend.sdoc.models.document import SDocDocument
12
from strictdoc.backend.sdoc.models.document_config import DocumentConfig
13
from strictdoc.backend.sdoc.models.grammar_element import GrammarElement
14
from strictdoc.backend.sdoc.models.inline_link import InlineLink
15
from strictdoc.backend.sdoc.models.node import SDocNode
16
from strictdoc.backend.sdoc.models.reference import (
17
    ChildReqReference,
18
    ParentReqReference,
19
)
20
from strictdoc.backend.sdoc.node_filter import NodeFilter
21
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
22
    RelationMarkerType,
23
    SourceFileTraceabilityInfo,
24
)
25
from strictdoc.core.asset_manager import AssetManager
26
from strictdoc.core.constants import GraphLinkType
27
from strictdoc.core.document_iterator import SDocDocumentIterator
28
from strictdoc.core.document_meta import DocumentMeta
29
from strictdoc.core.document_tree import DocumentTree
30
from strictdoc.core.file_dependency_manager import FileDependencyManager
31
from strictdoc.core.file_system.source_tree import SourceFile
32
from strictdoc.core.file_traceability_index import FileTraceabilityIndex
33
from strictdoc.core.graph.abstract_bucket import ALL_EDGES
34
from strictdoc.core.graph_database import GraphDatabase
35
from strictdoc.core.project_config import ProjectConfig
36
from strictdoc.core.transforms.constants import NodeCreationOrder
37
from strictdoc.core.transforms.validation_error import (
38
    MultipleValidationErrorAsList,
39
    SingleValidationError,
40
)
41
from strictdoc.core.tree_cycle_detector import TreeCycleDetector
42
from strictdoc.core.validation_index import ValidationIndex
43
from strictdoc.helpers.cast import assert_cast, assert_optional_cast
44
from strictdoc.helpers.file_modification_time import set_file_modification_time
45
from strictdoc.helpers.mid import MID
46
from strictdoc.helpers.ordered_set import OrderedSet
47
from strictdoc.helpers.paths import SDocRelativePath
48
from strictdoc.helpers.sorting import alphanumeric_sort
49
 
50
 
51
class TraceabilityIndex:
52
    def __init__(
53
        self,
54
        document_tree: DocumentTree,
55
        document_iterators: Dict[SDocDocument, SDocDocumentIterator],
56
        file_traceability_index: FileTraceabilityIndex,
57
        graph_database: GraphDatabase,
58
        file_dependency_manager: FileDependencyManager,
59
    ):
60
        self._document_iterators: Dict[SDocDocument, SDocDocumentIterator] = (
61
            document_iterators
62
        )
63
        self._file_traceability_index: FileTraceabilityIndex = (
64
            file_traceability_index
65
        )
66
        self.validation_index: ValidationIndex = ValidationIndex()
67
        self.graph_database: GraphDatabase = graph_database
68
        self.document_tree: DocumentTree = document_tree
69
        self.asset_manager: Optional[AssetManager] = None
70
        self.file_dependency_manager: FileDependencyManager = (
71
            file_dependency_manager
72
        )
73
        self.node_filter: Optional[NodeFilter] = None
74
        self.pending_inline_links: List[InlineLink] = []
75
 
76
        self.index_last_updated = datetime.datetime.today()
77
        self.contains_included_documents = False
78
        self.strictdoc_last_update: datetime.datetime = (
79
            datetime.datetime.fromtimestamp(0)
80
        )
81
 
82
        # The timestamp is used by HTML/JS for invalidating the search index
83
        # cache in the IndexedDB database.
84
        # If no documents have to be re-generated with the second+ run of
85
        # StrictDoc, this timestamp is set of a modification date of the first
86
        # SDoc document, see export_static_html_search_index.
87
        self.search_index_timestamp: datetime.datetime = datetime.datetime.now(
88
            datetime.timezone.utc
89
        )
90
 
91
    @property
92
    def document_iterators(self) -> Dict[SDocDocument, SDocDocumentIterator]:
93
        return self._document_iterators
94
 
95
    def is_small_project(self) -> bool:
96
        """
97
        Check if project is small to control feature availability.
98
 
99
        This method helps to decide if StrictDoc will precompile Jinja templates
100
        to Python files or not. Precompilation may take half a second time, so
101
        it is only worth doing it when a project is relatively large.
102
 
103
        Below, making some assumptions about what makes a small or larger
104
        project.
105
        """
106
        if len(self.document_tree.document_list) >= 3:
107
            return False
108
        for document_ in self.document_tree.document_list:
109
            if len(document_.section_contents) > 5:
110
                return False
111
        return (
112
            self.graph_database.get_count(
113
                link_type=GraphLinkType.NODE_TO_PARENT_NODES
114
            )
115
            <= 10
116
        )
117
 
118
    def can_edit_document(self, document: SDocDocument) -> bool:
119
        assert isinstance(document, SDocDocument), document
120
        return not document.autogen
121
 
122
    def can_edit_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
123
        assert isinstance(node, (SDocDocument, SDocNode)), node
124
        if isinstance(node, SDocDocument):
125
            return not node.autogen
126
 
127
        if node.get_parent_or_including_document().autogen or node.autogen:
128
            return False
129
 
130
        return True
131
 
132
    def can_delete_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
133
        assert isinstance(node, (SDocDocument, SDocNode)), node
134
        if isinstance(node, SDocDocument):
135
            return not node.autogen
136
 
137
        if node.get_parent_or_including_document().autogen:
138
            return False
139
 
140
        return not node.is_managed_by_source_code()
141
 
142
    def can_clone_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
143
        assert isinstance(node, (SDocDocument, SDocNode)), node
144
        if isinstance(node, SDocDocument):
145
            return False
146
 
147
        return self.can_delete_node(node)
148
 
149
    def can_add_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
150
        assert isinstance(node, (SDocDocument, SDocNode)), node
151
        if isinstance(node, SDocDocument):
152
            return not node.autogen
153
 
154
        if node.get_parent_or_including_document().autogen or node.autogen:
155
            return False
156
 
157
        return True
158
 
159
    def can_insert_next_to_node(
160
        self, node: Union[SDocDocument, SDocNode]
161
    ) -> bool:
162
        assert isinstance(node, (SDocDocument, SDocNode)), node
163
        if isinstance(node, SDocDocument):
164
            return True
165
 
166
        if node.get_parent_or_including_document().autogen:
167
            return False
168
 
169
        parent = node.parent
170
        if isinstance(parent, SDocNode) and parent.autogen:
171
            return False
172
 
173
        return True
174
 
175
    def can_create_node_at(
176
        self, reference_node: Union[SDocDocument, SDocNode], whereto: str
177
    ) -> bool:
178
        assert isinstance(reference_node, (SDocDocument, SDocNode)), (
179
            reference_node
180
        )
181
        assert isinstance(whereto, str), whereto
182
 
183
        if whereto == NodeCreationOrder.CHILD:
184
            return self.can_add_node(reference_node)
185
        if whereto in (NodeCreationOrder.BEFORE, NodeCreationOrder.AFTER):
186
            return self.can_insert_next_to_node(reference_node)
187
        return False
188
 
189
    def can_move_node(self, node: Union[SDocDocument, SDocNode]) -> bool:
190
        assert isinstance(node, (SDocDocument, SDocNode)), node
191
        if isinstance(node, SDocDocument):
192
            return not node.autogen
193
 
194
        if node.get_parent_or_including_document().autogen or node.autogen:
195
            return False
196
 
197
        return True
198
 
199
    def can_move_node_to(
200
        self,
201
        moved_node: SDocNode,
202
        target_node: Union[SDocDocument, SDocNode],
203
        whereto: str,
204
    ) -> bool:
205
        assert isinstance(moved_node, SDocNode), moved_node
206
        assert isinstance(target_node, (SDocDocument, SDocNode)), target_node
207
        assert isinstance(whereto, str), whereto
208
 
209
        if not self.can_move_node(moved_node):
210
            return False
211
 
212
        if (
213
            whereto == NodeCreationOrder.CHILD
214
            and isinstance(target_node, SDocNode)
215
            and not target_node.is_composite
216
        ):
217
            return self.can_insert_next_to_node(target_node)
218
 
219
        return self.can_create_node_at(target_node, whereto)
220
 
221
    def has_parent_requirements(self, requirement: SDocNode) -> bool:
222
        assert isinstance(requirement, SDocNode)
223
        if not isinstance(requirement.reserved_uid, str):
224
            return False
225
 
226
        parent_requirements = self.graph_database.get_link_values(
227
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
228
            lhs_node=requirement,
229
            edge=ALL_EDGES,
230
        )
231
        return len(parent_requirements) > 0
232
 
233
    def has_children_requirements(self, requirement: SDocNode) -> bool:
234
        assert isinstance(requirement, SDocNode)
235
        if not isinstance(requirement.reserved_uid, str):
236
            return False
237
 
238
        children_requirements = self.graph_database.get_link_values(
239
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
240
            lhs_node=requirement,
241
            edge=ALL_EDGES,
242
        )
243
        return len(children_requirements) > 0
244
 
245
    def has_source_file_reqs(self, source_file_rel_path: str) -> bool:
246
        return self._file_traceability_index.has_source_file_reqs(
247
            source_file_rel_path
248
        )
249
 
250
    def has_node_connections(self, node_uid: str) -> bool:
251
        assert isinstance(node_uid, str), node_uid
252
        return self.graph_database.has_any_link(
253
            link_type=GraphLinkType.UID_TO_NODE,
254
            lhs_node=node_uid,
255
        )
256
 
257
    def get_node_by_mid(self, node_mid: MID) -> Any:
258
        assert isinstance(node_mid, MID), node_mid
259
        return self.graph_database.get_link_value(
260
            link_type=GraphLinkType.MID_TO_NODE, lhs_node=node_mid
261
        )
262
 
263
    def get_node_by_mid_weak(self, node_mid: MID) -> Optional[Any]:
264
        assert isinstance(node_mid, MID), node_mid
265
        return self.graph_database.get_link_value_weak(
266
            link_type=GraphLinkType.MID_TO_NODE, lhs_node=node_mid
267
        )
268
 
269
    def get_file_traceability_index(self) -> FileTraceabilityIndex:
270
        return self._file_traceability_index
271
 
272
    def get_document_by_title(
273
        self,
274
        document_title: str,
275
    ) -> SDocDocument:
276
        for document_ in self.document_tree.document_list:
277
            if document_.reserved_title == document_title:
278
                return document_
279
        raise LookupError(f"Document not found: '{document_title}'")
280
 
281
    def get_document_iterator(
282
        self, document: SDocDocument
283
    ) -> SDocDocumentIterator:
284
        return SDocDocumentIterator(
285
            document=document, node_filter=self.node_filter
286
        )
287
 
288
    def get_parent_requirements(self, requirement: SDocNode) -> List[SDocNode]:
289
        assert isinstance(requirement, SDocNode)
290
        if not isinstance(requirement.reserved_uid, str):
291
            return []
292
 
293
        return list(
294
            self.graph_database.get_link_values(
295
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
296
                lhs_node=requirement,
297
                edge=ALL_EDGES,
298
            )
299
        )
300
 
301
    def get_parent_relations_with_roles(
302
        self, node: SDocNode
303
    ) -> List[Tuple[SDocNode, Optional[str]]]:
304
        assert isinstance(node, SDocNode)
305
 
306
        return list(
307
            self.graph_database.get_link_values_with_edges(
308
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
309
                lhs_node=node,
310
                edge=ALL_EDGES,
311
            )
312
        )
313
 
314
    def get_parent_relations_with_role(
315
        self, requirement: SDocNode, role: Optional[str]
316
    ) -> List[Tuple[SDocNode, Optional[str]]]:
317
        assert isinstance(requirement, SDocNode)
318
        if requirement.reserved_uid is None:
319
            return []
320
 
321
        return list(
322
            self.graph_database.get_link_values_with_edges(
323
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
324
                lhs_node=requirement,
325
                edge=role,
326
            )
327
        )
328
 
329
    def get_child_relations_with_roles(
330
        self, requirement: SDocNode
331
    ) -> List[Tuple[SDocNode, Optional[str]]]:
332
        assert isinstance(requirement, SDocNode)
333
 
334
        return list(
335
            self.graph_database.get_link_values_with_edges(
336
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
337
                lhs_node=requirement,
338
                edge=ALL_EDGES,
339
            )
340
        )
341
 
342
    def get_child_relations_with_role(
343
        self, requirement: SDocNode, role: Optional[str]
344
    ) -> List[Tuple[SDocNode, Optional[str]]]:
345
        assert isinstance(requirement, SDocNode)
346
        if requirement.reserved_uid is None:
347
            return []
348
 
349
        return list(
350
            self.graph_database.get_link_values_with_edges(
351
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
352
                lhs_node=requirement,
353
                edge=role,
354
            )
355
        )
356
 
357
    def get_display_role_for_parent_relation(
358
        self,
359
        node: SDocNode,
360
        parent_node: SDocNode,
361
        relation_role: Optional[str],
362
    ) -> Optional[str]:
363
        return self._get_display_role_for_relation(
364
            node=node,
365
            related_node=parent_node,
366
            forward_relation_type="Parent",
367
            reverse_relation_type="Child",
368
            relation_role=relation_role,
369
        )
370
 
371
    def get_display_role_for_child_relation(
372
        self,
373
        node: SDocNode,
374
        child_node: SDocNode,
375
        relation_role: Optional[str],
376
    ) -> Optional[str]:
377
        return self._get_display_role_for_relation(
378
            node=node,
379
            related_node=child_node,
380
            forward_relation_type="Child",
381
            reverse_relation_type="Parent",
382
            relation_role=relation_role,
383
        )
384
 
385
    def _get_display_role_for_relation(
386
        self,
387
        node: SDocNode,
388
        related_node: SDocNode,
389
        forward_relation_type: str,
390
        reverse_relation_type: str,
391
        relation_role: Optional[str],
392
    ) -> Optional[str]:
393
        if self._node_has_direct_relation(
394
            source_node=node,
395
            target_node=related_node,
396
            relation_type=forward_relation_type,
397
            relation_role=relation_role,
398
        ):
399
            return relation_role
400
 
401
        if self._node_has_direct_relation(
402
            source_node=related_node,
403
            target_node=node,
404
            relation_type=reverse_relation_type,
405
            relation_role=relation_role,
406
        ):
407
            reverse_role = self._get_reverse_role_from_node_grammar(
408
                node=related_node,
409
                relation_type=reverse_relation_type,
410
                relation_role=relation_role,
411
            )
412
            return reverse_role if reverse_role is not None else relation_role
413
 
414
        return relation_role
415
 
416
    @staticmethod
417
    def _node_has_direct_relation(
418
        source_node: SDocNode,
419
        target_node: SDocNode,
420
        relation_type: str,
421
        relation_role: Optional[str],
422
    ) -> bool:
423
        for relation_ in source_node.relations:
424
            if not isinstance(
425
                relation_, (ParentReqReference, ChildReqReference)
426
            ):
427
                continue
428
            if (
429
                relation_.ref_type == relation_type
430
                and relation_.ref_uid == target_node.reserved_uid
431
                and relation_.role == relation_role
432
            ):
433
                return True
434
        return False
435
 
436
    @staticmethod
437
    def _get_reverse_role_from_node_grammar(
438
        node: SDocNode,
439
        relation_type: str,
440
        relation_role: Optional[str],
441
    ) -> Optional[str]:
442
        document = assert_optional_cast(node.get_document(), SDocDocument)
443
        if document is None or document.grammar is None:
444
            return None
445
 
446
        grammar_element = document.grammar.elements_by_type.get(node.node_type)
447
        if grammar_element is None:
448
            return None
449
 
450
        return grammar_element.get_relation_reverse_role(
451
            relation_type=relation_type,
452
            relation_role=relation_role,
453
        )
454
 
455
    def get_children_requirements(
456
        self, requirement: SDocNode
457
    ) -> List[SDocNode]:
458
        assert isinstance(requirement, SDocNode)
459
        if not isinstance(requirement.reserved_uid, str):
460
            return []
461
 
462
        return list(
463
            self.graph_database.get_link_values(
464
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
465
                lhs_node=requirement,
466
                edge=ALL_EDGES,
467
            )
468
        )
469
 
470
    def has_tags(self, document: SDocDocument) -> bool:
471
        return self.graph_database.has_any_link(
472
            link_type=GraphLinkType.DOCUMENT_TO_TAGS,
473
            lhs_node=document.reserved_mid,
474
        )
475
 
476
    def get_counted_tags(
477
        self, document: SDocDocument
478
    ) -> Generator[Tuple[str, int], None, None]:
479
        document_tags_or_none = self.graph_database.get_link_value(
480
            link_type=GraphLinkType.DOCUMENT_TO_TAGS,
481
            lhs_node=document.reserved_mid,
482
        )
483
        document_tags: Dict[str, int] = assert_cast(document_tags_or_none, dict)
484
 
485
        tags = sorted(document_tags.keys(), key=alphanumeric_sort)
486
        for tag in tags:
487
            yield tag, document_tags[tag]
488
 
489
    def get_requirement_file_links(
490
        self, requirement: SDocNode
491
    ) -> List[Tuple[str, List[RelationMarkerType]]]:
492
        return self._file_traceability_index.get_requirement_file_links(
493
            requirement
494
        )
495
 
496
    def get_source_file_reqs(
497
        self, source_file_rel_path: str
498
    ) -> Optional[List[SDocNode]]:
499
        return self._file_traceability_index.get_source_file_reqs(
500
            source_file_rel_path
501
        )
502
 
503
    def get_coverage_info(
504
        self, source_file_rel_path: str
505
    ) -> SourceFileTraceabilityInfo:
506
        return self._file_traceability_index.get_coverage_info(
507
            source_file_rel_path
508
        )
509
 
510
    def get_coverage_info_weak(
511
        self, source_file_rel_path: str
512
    ) -> Optional[SourceFileTraceabilityInfo]:
513
        return self._file_traceability_index.get_coverage_info_weak(
514
            source_file_rel_path
515
        )
516
 
517
    def get_node_by_uid(self, uid: str) -> Any:
518
        assert isinstance(uid, str) and len(uid) > 0, uid
519
        return self.graph_database.get_link_value(
520
            link_type=GraphLinkType.UID_TO_NODE, lhs_node=uid
521
        )
522
 
523
    def get_node_by_uid_weak2(self, uid: str) -> Optional[Any]:
524
        """
525
        FIXME: This can likely replace _weak below with no problem.
526
        """
527
        assert isinstance(uid, str) and len(uid) > 0, uid
528
        return self.graph_database.get_link_value_weak(
529
            link_type=GraphLinkType.UID_TO_NODE, lhs_node=uid
530
        )
531
 
532
    def get_linkable_node_by_uid(
533
        self, uid: str
534
    ) -> Union[SDocDocument, SDocNode, Anchor]:
535
        return assert_cast(
536
            self.get_node_by_uid(uid),
537
            (SDocDocument, SDocNode, Anchor),
538
        )
539
 
540
    def get_node_by_uid_weak(
541
        self, uid: str
542
    ) -> Union[SDocDocument, SDocNode, None]:
543
        assert isinstance(uid, str), uid
544
        for document in self.document_tree.document_list:
545
            document_iterator = SDocDocumentIterator(document)
546
            for node, _ in document_iterator.all_content(print_fragments=False):
547
                if isinstance(node, SDocDocument):
548
                    if node.config.uid == uid:
549
                        return node
550
                elif isinstance(node, SDocNode):
551
                    if node.reserved_uid == uid:
552
                        return node
553
                else:
554
                    raise NotImplementedError
555
        return None
556
 
557
    def get_linkable_node_by_uid_weak(
558
        self, uid: str
559
    ) -> Union[SDocDocument, SDocNode, Anchor, None]:
560
        return assert_optional_cast(
561
            self.graph_database.get_link_value_weak(
562
                link_type=GraphLinkType.UID_TO_NODE, lhs_node=uid
563
            ),
564
            (SDocDocument, SDocNode, Anchor),
565
        )
566
 
567
    def get_incoming_links(
568
        self, node: Union[SDocDocument, SDocNode, Anchor]
569
    ) -> Optional[List[InlineLink]]:
570
        incoming_links = self.graph_database.get_link_values(
571
            link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
572
            lhs_node=node.reserved_mid,
573
        )
574
        if incoming_links is None or len(incoming_links) == 0:
575
            return None
576
        # FIXME: Should the graph database return OrderedSet or a copied list()?
577
        return list(incoming_links)
578
 
579
    def get_grammar_element(
580
        self, document_uid: str, node_type: str
581
    ) -> Optional[GrammarElement]:
582
        document = self.get_node_by_uid_weak2(document_uid)
583
        if isinstance(document, SDocDocument) and document.grammar is not None:
584
            return document.grammar.elements_by_type.get(node_type)
585
        return None
586
 
587
    def create_traceability_info(
588
        self,
589
        source_file: SourceFile,
590
        traceability_info: SourceFileTraceabilityInfo,
591
    ) -> None:
592
        self._file_traceability_index.create_traceability_info(
593
            source_file, traceability_info
594
        )
595
 
596
    def create_document(self, document: SDocDocument) -> None:
597
        assert isinstance(document, SDocDocument)
598
        if document.reserved_uid is not None:
599
            self.graph_database.create_link(
600
                link_type=GraphLinkType.UID_TO_NODE,
601
                lhs_node=document.reserved_uid,
602
                rhs_node=document,
603
            )
604
        self.graph_database.create_link(
605
            link_type=GraphLinkType.MID_TO_NODE,
606
            lhs_node=document.reserved_mid,
607
            rhs_node=document,
608
        )
609
 
610
    def create_inline_link(self, new_link: InlineLink) -> None:
611
        assert isinstance(new_link, InlineLink)
612
 
613
        # InlineLink points to a section, node or to anchor.
614
        assert self.graph_database.has_any_link(
615
            link_type=GraphLinkType.UID_TO_NODE, lhs_node=new_link.link
616
        )
617
 
618
        node_or_anchor: Union[SDocDocument, SDocNode, Anchor] = assert_cast(
619
            self.graph_database.get_link_value(
620
                link_type=GraphLinkType.UID_TO_NODE,
621
                lhs_node=new_link.link,
622
            ),
623
            (SDocDocument, SDocNode, Anchor),
624
        )
625
        self.graph_database.create_link(
626
            link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
627
            lhs_node=node_or_anchor.reserved_mid,
628
            rhs_node=new_link,
629
        )
630
        self.graph_database.create_link(
631
            link_type=GraphLinkType.MID_TO_NODE,
632
            lhs_node=new_link.reserved_mid,
633
            rhs_node=new_link,
634
        )
635
 
636
    def update_last_updated(self) -> None:
637
        """
638
        Update the index's last updated date to the current time.
639
 
640
        This is a rather broad way of signalling that all documents of the index
641
        need to be re-generated when they are opened next time. Several UI
642
        actions use this method to ensure a complete re-generation of all
643
        documents.
644
        """
645
        self.index_last_updated = datetime.datetime.today()
646
 
647
    def create_requirement(self, requirement: SDocNode) -> None:
648
        assert isinstance(requirement, SDocNode), requirement
649
 
650
        self.graph_database.create_link(
651
            link_type=GraphLinkType.MID_TO_NODE,
652
            lhs_node=requirement.reserved_mid,
653
            rhs_node=requirement,
654
        )
655
        if requirement.reserved_uid is not None:
656
            self.graph_database.create_link(
657
                link_type=GraphLinkType.UID_TO_NODE,
658
                lhs_node=requirement.reserved_uid,
659
                rhs_node=requirement,
660
            )
661
 
662
    def update_requirement_uid(
663
        self, requirement: SDocNode, old_uid: Optional[str]
664
    ) -> None:
665
        if old_uid is None:
666
            if requirement.reserved_uid:
667
                self.graph_database.create_link(
668
                    link_type=GraphLinkType.UID_TO_NODE,
669
                    lhs_node=requirement.reserved_uid,
670
                    rhs_node=requirement,
671
                )
672
            return
673
 
674
        self.graph_database.delete_link(
675
            link_type=GraphLinkType.UID_TO_NODE,
676
            lhs_node=old_uid,
677
            rhs_node=requirement,
678
        )
679
 
680
        if requirement.reserved_uid is not None:
681
            self.graph_database.create_link(
682
                link_type=GraphLinkType.UID_TO_NODE,
683
                lhs_node=requirement.reserved_uid,
684
                rhs_node=requirement,
685
            )
686
 
687
    def update_requirement_parent_uid(
688
        self, requirement: SDocNode, parent_uid: str, role: Optional[str]
689
    ) -> None:
690
        assert requirement.reserved_uid is not None
691
        assert isinstance(parent_uid, str), parent_uid
692
        assert role is None or len(role) > 0, role
693
        parent_nodes: OrderedSet[SDocNode] = (
694
            self.graph_database.get_link_values(
695
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
696
                lhs_node=requirement,
697
                edge=role,
698
            )
699
        )
700
        # If a relation to the parent uid through a given role already exists,
701
        # there is nothing to do.
702
        if any(node_.reserved_uid == parent_uid for node_ in parent_nodes):
703
            return
704
 
705
        parent_requirement: SDocNode = self.graph_database.get_link_value(
706
            link_type=GraphLinkType.UID_TO_NODE,
707
            lhs_node=parent_uid,
708
        )
709
 
710
        document = assert_cast(requirement.get_document(), SDocDocument)
711
        parent_requirement_document = assert_cast(
712
            parent_requirement.get_document(), SDocDocument
713
        )
714
 
715
        self.graph_database.create_link(
716
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
717
            lhs_node=requirement,
718
            rhs_node=parent_requirement,
719
            edge=role,
720
        )
721
        self.graph_database.create_link(
722
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
723
            lhs_node=parent_requirement,
724
            rhs_node=requirement,
725
            edge=role,
726
        )
727
 
728
        cycle_detector = TreeCycleDetector()
729
 
730
        def parent_cycle_traverse_(node_id: str) -> List[Any]:
731
            node = self.graph_database.get_link_value(
732
                link_type=GraphLinkType.UID_TO_NODE,
733
                lhs_node=node_id,
734
            )
735
            return list(
736
                map(
737
                    lambda node_: node_.reserved_uid,
738
                    self.graph_database.get_link_values(
739
                        link_type=GraphLinkType.NODE_TO_PARENT_NODES,
740
                        lhs_node=node,
741
                    ),
742
                )
743
            )
744
 
745
        cycle_detector.check_node(
746
            requirement.reserved_uid,
747
            parent_cycle_traverse_,
748
        )
749
 
750
        # Mark document and parent document (if different) for re-generation.
751
        assert document.meta is not None
752
        set_file_modification_time(
753
            document.meta.input_doc_full_path, datetime.datetime.today()
754
        )
755
        if parent_requirement_document != document:
756
            assert parent_requirement_document.meta is not None
757
            set_file_modification_time(
758
                parent_requirement_document.meta.input_doc_full_path,
759
                datetime.datetime.today(),
760
            )
761
 
762
    def update_node_mid(self, node: SDocNode, new_mid: str) -> None:
763
        """
764
        Update a node’s MID identifier and update all parent and child node
765
        relations to reference this new MID.
766
 
767
        The graph database contains relations between nodes. Since these
768
        relations remain unchanged, the job of this function is only to update
769
        each node's original .relations, so that they point to the new MID.
770
        """
771
 
772
        old_mid = node.reserved_mid
773
 
774
        #
775
        # Update all child nodes.
776
        #
777
        child_nodes: OrderedSet[SDocNode] = self.graph_database.get_link_values(
778
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
779
            lhs_node=node,
780
            edge=ALL_EDGES,
781
        )
782
        for child_node_ in child_nodes:
783
            child_node_document = child_node_.get_document()
784
            assert child_node_document is not None
785
 
786
            if child_node_document.config.relation_field != "MID":
787
                continue
788
 
789
            for relation_ in copy(child_node_.relations):
790
                if (
791
                    isinstance(relation_, ParentReqReference)
792
                    and relation_.ref_uid == old_mid
793
                ):
794
                    relation_.ref_uid = new_mid
795
 
796
        #
797
        # Update all parent nodes.
798
        #
799
        parent_nodes: OrderedSet[SDocNode] = (
800
            self.graph_database.get_link_values(
801
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
802
                lhs_node=node,
803
                edge=ALL_EDGES,
804
            )
805
        )
806
        for parent_node_ in parent_nodes:
807
            parent_node_document = parent_node_.get_document()
808
            assert parent_node_document is not None
809
 
810
            if parent_node_document.config.relation_field != "MID":
811
                continue
812
 
813
            for relation_ in copy(parent_node_.relations):
814
                if (
815
                    isinstance(relation_, ChildReqReference)
816
                    and relation_.ref_uid == old_mid
817
                ):
818
                    relation_.ref_uid = new_mid
819
 
820
        #
821
        # Update the node itself.
822
        #
823
        node.set_field_value(
824
            field_name="MID",
825
            form_field_index=0,
826
            value=new_mid,
827
        )
828
 
829
    def update_requirement_child_uid(
830
        self, requirement: SDocNode, child_uid: str, role: Optional[str]
831
    ) -> None:
832
        assert requirement.reserved_uid is not None
833
        assert isinstance(child_uid, str), child_uid
834
        assert role is None or len(role) > 0, role
835
        child_nodes: OrderedSet[SDocNode] = self.graph_database.get_link_values(
836
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
837
            lhs_node=requirement,
838
            edge=role,
839
        )
840
 
841
        # If a relation to the parent uid through a given role already exists,
842
        # there is nothing to do.
843
        if any(node_.reserved_uid == child_uid for node_ in child_nodes):
844
            return
845
        child_requirement = self.graph_database.get_link_value(
846
            link_type=GraphLinkType.UID_TO_NODE,
847
            lhs_node=child_uid,
848
        )
849
 
850
        document = assert_cast(requirement.get_document(), SDocDocument)
851
        child_requirement_document = assert_cast(
852
            child_requirement.get_document(), SDocDocument
853
        )
854
 
855
        self.graph_database.create_link(
856
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
857
            lhs_node=child_requirement,
858
            rhs_node=requirement,
859
            edge=role,
860
        )
861
        self.graph_database.create_link(
862
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
863
            lhs_node=requirement,
864
            rhs_node=child_requirement,
865
            edge=role,
866
        )
867
 
868
        cycle_detector = TreeCycleDetector()
869
 
870
        def child_cycle_traverse_(node_id: str) -> List[Any]:
871
            node = self.graph_database.get_link_value(
872
                link_type=GraphLinkType.UID_TO_NODE,
873
                lhs_node=node_id,
874
            )
875
            return list(
876
                map(
877
                    lambda node_: node_.reserved_uid,
878
                    self.graph_database.get_link_values(
879
                        link_type=GraphLinkType.NODE_TO_CHILD_NODES,
880
                        lhs_node=node,
881
                    ),
882
                )
883
            )
884
 
885
        cycle_detector.check_node(
886
            requirement.reserved_uid,
887
            child_cycle_traverse_,
888
        )
889
 
890
        # Mark document and parent document (if different) for re-generation.
891
        assert document.meta is not None
892
        set_file_modification_time(
893
            document.meta.input_doc_full_path, datetime.datetime.today()
894
        )
895
        if child_requirement_document != document:
896
            assert child_requirement_document.meta is not None
897
            set_file_modification_time(
898
                child_requirement_document.meta.input_doc_full_path,
899
                datetime.datetime.today(),
900
            )
901
 
902
    def update_with_anchor(self, anchor: Anchor) -> None:
903
        # By this time, we know that the validations have passed just before.
904
        existing_anchor: Optional[Anchor] = (
905
            self.graph_database.get_link_value_weak(
906
                link_type=GraphLinkType.UID_TO_NODE,
907
                lhs_node=anchor.value,
908
            )
909
        )
910
        if existing_anchor is not None:
911
            self.graph_database.delete_link(
912
                link_type=GraphLinkType.MID_TO_NODE,
913
                lhs_node=existing_anchor.mid,
914
                rhs_node=existing_anchor,
915
            )
916
            self.graph_database.delete_link(
917
                link_type=GraphLinkType.UID_TO_NODE,
918
                lhs_node=existing_anchor.value,
919
                rhs_node=existing_anchor,
920
            )
921
 
922
        self.graph_database.create_link(
923
            link_type=GraphLinkType.MID_TO_NODE,
924
            lhs_node=anchor.mid,
925
            rhs_node=anchor,
926
        )
927
        self.graph_database.create_link(
928
            link_type=GraphLinkType.UID_TO_NODE,
929
            lhs_node=anchor.value,
930
            rhs_node=anchor,
931
        )
932
 
933
    def update_disconnect_two_documents_if_no_links_left(
934
        self, document: SDocDocument, other_document: SDocDocument
935
    ) -> None:
936
        assert document != other_document
937
 
938
        for node, _ in self.document_iterators[document].all_content(
939
            print_fragments=False
940
        ):
941
            if not isinstance(node, SDocNode):
942
                continue
943
 
944
            requirement_node: SDocNode = node
945
 
946
            # If a requirement has no UID, it cannot contribute to any relation-based
947
            # connection between any two documents.
948
            if requirement_node.reserved_uid is None:
949
                continue
950
 
951
            requirement_parents: OrderedSet[SDocNode] = (
952
                self.graph_database.get_link_values(
953
                    link_type=GraphLinkType.NODE_TO_PARENT_NODES,
954
                    lhs_node=requirement_node,
955
                )
956
            )
957
 
958
            # If at least one parent or child relation points to the other
959
            # document, terminate, not deleting the link between documents.
960
            for parent_requirement in requirement_parents:
961
                parent_requirement_document: SDocDocument = assert_cast(
962
                    parent_requirement.get_document(), SDocDocument
963
                )
964
                if parent_requirement_document == other_document:
965
                    return
966
 
967
            requirement_children = self.graph_database.get_link_values(
968
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
969
                lhs_node=requirement_node,
970
            )
971
 
972
            for child_requirement_ in requirement_children:
973
                if child_requirement_.get_document() == other_document:
974
                    return
975
 
976
    def delete_document(self, document: SDocDocument) -> None:
977
        assert isinstance(document, SDocDocument), document
978
 
979
        self.graph_database.delete_link(
980
            link_type=GraphLinkType.MID_TO_NODE,
981
            lhs_node=document.reserved_mid,
982
            rhs_node=document,
983
        )
984
        if document.reserved_uid is not None:
985
            self.graph_database.delete_link(
986
                link_type=GraphLinkType.UID_TO_NODE,
987
                lhs_node=document.reserved_uid,
988
                rhs_node=document,
989
            )
990
 
991
    def delete_requirement(self, requirement: SDocNode) -> None:
992
        assert isinstance(requirement, SDocNode), SDocNode
993
 
994
        self.graph_database.delete_link(
995
            link_type=GraphLinkType.MID_TO_NODE,
996
            lhs_node=requirement.reserved_mid,
997
            rhs_node=requirement,
998
        )
999
        if requirement.reserved_uid is not None:
1000
            self.graph_database.delete_link(
1001
                link_type=GraphLinkType.UID_TO_NODE,
1002
                lhs_node=requirement.reserved_uid,
1003
                rhs_node=requirement,
1004
            )
1005
            # Remove NODE_TO_CHILD_NODES links that point FROM parent nodes TO
1006
            # this requirement. delete_all_links only removes links where this
1007
            # requirement is the lhs_node (its own children), leaving stale
1008
            # reverse entries on parent nodes that block their own deletion.
1009
            parent_relations = self.graph_database.get_link_values_with_edges(
1010
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
1011
                lhs_node=requirement,
1012
            )
1013
            for parent_node_, role_ in parent_relations:
1014
                self.graph_database.delete_link(
1015
                    link_type=GraphLinkType.NODE_TO_CHILD_NODES,
1016
                    lhs_node=parent_node_,
1017
                    rhs_node=requirement,
1018
                    edge=role_,
1019
                )
1020
            self.graph_database.delete_all_links(
1021
                link_type=GraphLinkType.NODE_TO_PARENT_NODES,
1022
                lhs_node=requirement,
1023
            )
1024
            self.graph_database.delete_all_links(
1025
                link_type=GraphLinkType.NODE_TO_CHILD_NODES,
1026
                lhs_node=requirement,
1027
            )
1028
 
1029
    def remove_requirement_parent_uid(
1030
        self, requirement: SDocNode, parent_uid: str, role: Optional[str]
1031
    ) -> None:
1032
        assert requirement.reserved_uid is not None
1033
        assert isinstance(parent_uid, str), parent_uid
1034
        assert role is None or len(role) > 0, role
1035
 
1036
        parent_requirement: SDocNode = self.graph_database.get_link_value(
1037
            link_type=GraphLinkType.UID_TO_NODE,
1038
            lhs_node=parent_uid,
1039
        )
1040
 
1041
        self.graph_database.delete_link(
1042
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
1043
            lhs_node=requirement,
1044
            rhs_node=parent_requirement,
1045
            edge=role,
1046
        )
1047
        self.graph_database.delete_link(
1048
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
1049
            lhs_node=parent_requirement,
1050
            rhs_node=requirement,
1051
            edge=role,
1052
        )
1053
 
1054
        document = assert_cast(requirement.get_document(), SDocDocument)
1055
        parent_requirement_document = assert_cast(
1056
            parent_requirement.get_document(), SDocDocument
1057
        )
1058
 
1059
        # If there are no requirements linking between the documents,
1060
        # remove the link.
1061
        if document != parent_requirement_document:
1062
            self.update_disconnect_two_documents_if_no_links_left(
1063
                document, parent_requirement_document
1064
            )
1065
 
1066
        # Mark document and parent document (if different) for re-generation.
1067
        assert document.meta is not None
1068
        set_file_modification_time(
1069
            document.meta.input_doc_full_path, datetime.datetime.today()
1070
        )
1071
        if parent_requirement_document != document:
1072
            assert parent_requirement_document.meta is not None
1073
            set_file_modification_time(
1074
                parent_requirement_document.meta.input_doc_full_path,
1075
                datetime.datetime.today(),
1076
            )
1077
 
1078
    def remove_requirement_child_uid(
1079
        self, requirement: SDocNode, child_uid: str, role: Optional[str]
1080
    ) -> None:
1081
        assert requirement.reserved_uid is not None
1082
        assert isinstance(child_uid, str), child_uid
1083
        assert role is None or len(role) > 0, role
1084
 
1085
        child_requirement: SDocNode = self.graph_database.get_link_value(
1086
            link_type=GraphLinkType.UID_TO_NODE,
1087
            lhs_node=child_uid,
1088
        )
1089
        document: SDocDocument = assert_cast(
1090
            requirement.get_document(), SDocDocument
1091
        )
1092
        child_requirement_document: SDocDocument = assert_cast(
1093
            child_requirement.get_document(), SDocDocument
1094
        )
1095
 
1096
        self.graph_database.delete_link(
1097
            link_type=GraphLinkType.NODE_TO_CHILD_NODES,
1098
            lhs_node=requirement,
1099
            rhs_node=child_requirement,
1100
            edge=role,
1101
        )
1102
        self.graph_database.delete_link(
1103
            link_type=GraphLinkType.NODE_TO_PARENT_NODES,
1104
            lhs_node=child_requirement,
1105
            rhs_node=requirement,
1106
            edge=role,
1107
        )
1108
 
1109
        # If there are no requirements linking between the documents,
1110
        # remove the link.
1111
        if document != child_requirement_document:
1112
            self.update_disconnect_two_documents_if_no_links_left(
1113
                document, child_requirement_document
1114
            )
1115
 
1116
        # Mark document and parent document (if different) for re-generation.
1117
        assert document.meta is not None
1118
        set_file_modification_time(
1119
            document.meta.input_doc_full_path, datetime.datetime.today()
1120
        )
1121
        if child_requirement_document != document:
1122
            assert child_requirement_document.meta is not None
1123
            set_file_modification_time(
1124
                child_requirement_document.meta.input_doc_full_path,
1125
                datetime.datetime.today(),
1126
            )
1127
 
1128
    def remove_inline_link(self, inline_link: InlineLink) -> None:
1129
        sections_with_incoming_links = (
1130
            self.graph_database.get_link_values_reverse(
1131
                link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
1132
                rhs_node=inline_link,
1133
            )
1134
        )
1135
 
1136
        for node_with_incoming_links in list(sections_with_incoming_links):
1137
            self.graph_database.delete_link(
1138
                link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
1139
                lhs_node=node_with_incoming_links,
1140
                rhs_node=inline_link,
1141
            )
1142
 
1143
        self.graph_database.delete_link(
1144
            link_type=GraphLinkType.MID_TO_NODE,
1145
            lhs_node=inline_link.reserved_mid,
1146
            rhs_node=inline_link,
1147
        )
1148
 
1149
    def remove_anchor_by_uid(self, anchor_uid: str) -> None:
1150
        anchor: Anchor = self.graph_database.get_link_value(
1151
            link_type=GraphLinkType.UID_TO_NODE,
1152
            lhs_node=anchor_uid,
1153
        )
1154
        self.graph_database.delete_link(
1155
            link_type=GraphLinkType.MID_TO_NODE,
1156
            lhs_node=anchor.mid,
1157
            rhs_node=anchor,
1158
        )
1159
        self.graph_database.delete_link(
1160
            link_type=GraphLinkType.UID_TO_NODE,
1161
            lhs_node=anchor_uid,
1162
            rhs_node=anchor,
1163
        )
1164
 
1165
    def validate_node_against_anchors(
1166
        self,
1167
        *,
1168
        node: Union[SDocDocument, SDocNode, None],
1169
        new_anchors: List[Anchor],
1170
    ) -> None:
1171
        assert node is None or isinstance(node, (SDocDocument, SDocNode))
1172
        assert isinstance(new_anchors, list)
1173
 
1174
        # Check that this node does not have duplicated anchors.
1175
        new_anchor_uids = set()
1176
        for anchor in new_anchors:
1177
            if anchor.value in new_anchor_uids:
1178
                raise SingleValidationError(
1179
                    "A node cannot have two anchors with "
1180
                    f"the same identifier: {anchor.value}."
1181
                )
1182
            new_anchor_uids.add(anchor.value)
1183
 
1184
        # If the node is new, the validation is easier: we just need to make
1185
        # sure that there are no existing anchors with the UIDs brought
1186
        # by the new anchors.
1187
        if node is None:
1188
            for anchor_uid in new_anchor_uids:
1189
                if self.graph_database.has_any_link(
1190
                    link_type=GraphLinkType.UID_TO_NODE, lhs_node=anchor_uid
1191
                ):
1192
                    raise SingleValidationError(
1193
                        "A node contains an anchor that already exists: "
1194
                        f"{anchor_uid}."
1195
                    )
1196
            return
1197
 
1198
        # If the node is an existing node, we need to check that:
1199
        # 1) If some of the new anchors already exist in the project tree, we
1200
        #    need to ensure that they exist in the current node, otherwise we
1201
        #    raise a duplication validation error.
1202
        # 2) If the new anchors do not contain some of the existing node's
1203
        #    current anchors, this means these anchors are being removed. In
1204
        #    that case, we need to check if these anchors are used by any LINKs,
1205
        #    raising a validation if they do.
1206
        existing_node_anchor_uids = set()
1207
 
1208
        # FIXME: No test reaches this for Section or Document.
1209
        assert isinstance(node, SDocNode)
1210
        for node_anchor_ in node.get_anchors():
1211
            existing_node_anchor_uids.add(node_anchor_.value)
1212
 
1213
        #
1214
        # Validation 1: Assert all UIDs are either:
1215
        # a) new
1216
        # b) exist in this node
1217
        # c) raise a duplication error.
1218
        #
1219
        for anchor_uid in new_anchor_uids:
1220
            if (
1221
                self.graph_database.has_any_link(
1222
                    link_type=GraphLinkType.UID_TO_NODE, lhs_node=anchor_uid
1223
                )
1224
                and anchor_uid not in existing_node_anchor_uids
1225
            ):
1226
                duplicate_anchor: Anchor = self.graph_database.get_link_value(
1227
                    link_type=GraphLinkType.UID_TO_NODE, lhs_node=anchor_uid
1228
                )
1229
                node_with_duplicate_anchor: SDocNode = assert_cast(
1230
                    duplicate_anchor.parent_node(), SDocNode
1231
                )
1232
                raise SingleValidationError(
1233
                    "Another node contains an anchor with the same UID: "
1234
                    f"{anchor_uid}. {node_with_duplicate_anchor.get_display_node_type()}: "
1235
                    f"{node_with_duplicate_anchor.get_display_title()}."
1236
                )
1237
 
1238
        #
1239
        # Validation 2: Check that removed anchors do not have any incoming
1240
        # links.
1241
        #
1242
        to_be_removed_anchor_uids = existing_node_anchor_uids - new_anchor_uids
1243
        for to_be_removed_anchor_uid_ in to_be_removed_anchor_uids:
1244
            to_be_removed_anchor: Anchor = self.graph_database.get_link_value(
1245
                link_type=GraphLinkType.UID_TO_NODE,
1246
                lhs_node=to_be_removed_anchor_uid_,
1247
            )
1248
            incoming_links = self.graph_database.get_link_values(
1249
                link_type=GraphLinkType.NODE_TO_INCOMING_LINKS,
1250
                lhs_node=to_be_removed_anchor.reserved_mid,
1251
            )
1252
            if incoming_links is not None and len(incoming_links) > 0:
1253
                incoming_link: InlineLink = incoming_links[0]
1254
                incoming_link_parent_node = incoming_link.parent_node()
1255
                raise SingleValidationError(
1256
                    f"Cannot remove anchor with UID "
1257
                    f"'{incoming_link.link}' because it has incoming "
1258
                    f"links. Containing node: "
1259
                    f"{incoming_link_parent_node.get_display_node_type()}: "
1260
                    f"'{incoming_link_parent_node.get_display_title()}'."
1261
                )
1262
 
1263
    def validate_can_remove_node(
1264
        self, *, node: Union[SDocNode, Anchor]
1265
    ) -> None:
1266
        incoming_links: Optional[List[InlineLink]] = self.get_incoming_links(
1267
            node
1268
        )
1269
        if incoming_links is not None and len(incoming_links) > 0:
1270
            link_list_message = ", ".join(
1271
                map(
1272
                    lambda l_: (
1273
                        f"'{l_.parent_node().get_display_title()}' -> '{l_.link}'"
1274
                    ),
1275
                    incoming_links,
1276
                )
1277
            )
1278
            raise SingleValidationError(
1279
                f"Cannot remove node '{node.get_display_title()}' with incoming LINKs from: {link_list_message}."
1280
            )
1281
        if isinstance(node, SDocNode):
1282
            child_nodes: List[SDocNode] = self.get_children_requirements(node)
1283
            if child_nodes is not None and len(child_nodes) > 0:
1284
                nodes_list_message = ", ".join(
1285
                    map(
1286
                        lambda n_: "'" + n_.get_display_title() + "'",
1287
                        child_nodes,
1288
                    )
1289
                )
1290
                raise SingleValidationError(
1291
                    f"Cannot remove node '{node.get_display_title()}' "
1292
                    f"with incoming relations from:\n{nodes_list_message}."
1293
                )
1294
 
1295
    def validate_can_remove_document(self, document: SDocDocument) -> None:
1296
        errors: List[str] = []
1297
 
1298
        if document.document_is_included():
1299
            including_document = document.get_including_document()
1300
            if including_document is not None:
1301
                errors.append(
1302
                    f"Cannot remove document '{document.get_display_title()}' because it is included in document '{including_document.get_display_title()}'."
1303
                )
1304
            else:
1305
                errors.append(
1306
                    f"Cannot remove document '{document.get_display_title()}' because it is included in another document."
1307
                )
1308
 
1309
        incoming_links: Optional[List[InlineLink]] = self.get_incoming_links(
1310
            document
1311
        )
1312
        if incoming_links is not None and len(incoming_links) > 0:
1313
            link_list_message = ", ".join(
1314
                map(
1315
                    lambda l_: (
1316
                        f"'{l_.parent_node().get_display_title()}' -> '{l_.link}'"
1317
                    ),
1318
                    incoming_links,
1319
                )
1320
            )
1321
            errors.append(
1322
                f"Cannot remove document '{document.get_display_title()}' with incoming LINKs from: {link_list_message}."
1323
            )
1324
 
1325
        document_iterator = SDocDocumentIterator(document=document)
1326
        for document_node_, _ in document_iterator.all_content(
1327
            print_fragments=True
1328
        ):
1329
            if not isinstance(document_node_, SDocNode):
1330
                continue
1331
 
1332
            nodes_with_incoming_links = [
1333
                document_node_
1334
            ] + document_node_.get_anchors()
1335
            for node_ in nodes_with_incoming_links:
1336
                try:
1337
                    self.validate_can_remove_node(node=node_)
1338
                except SingleValidationError as exception_:
1339
                    errors.append(exception_.args[0])
1340
 
1341
            parent_nodes: List[SDocNode] = self.get_parent_requirements(
1342
                document_node_
1343
            )
1344
            if len(parent_nodes) > 0:
1345
                nodes_list_message = ", ".join(
1346
                    map(
1347
                        lambda n_: "'" + n_.get_display_title() + "'",
1348
                        parent_nodes,
1349
                    )
1350
                )
1351
                errors.append(
1352
                    f"Cannot remove document '{document_node_.get_display_title()}' "
1353
                    f"with incoming relations from:\n{nodes_list_message}."
1354
                )
1355
 
1356
        if len(errors) > 0:
1357
            raise MultipleValidationErrorAsList("NOT_RELEVANT", errors)
1358
 
1359
    def clone_to_bundle_document(
1360
        self, project_config: ProjectConfig
1361
    ) -> Tuple["TraceabilityIndex", SDocDocument]:
1362
        """
1363
        Clone this traceability index and create a new bundle document.
1364
 
1365
        The only use case for this method is the generation of a bundle document.
1366
        Since the bundle document does not exist on file system, some parameters
1367
        are set artificially:
1368
        - The bundle is assumed to be an input file in the root input folder.
1369
        - The bundle is generated to the root of the output folder (level=0).
1370
        - Some variables do not contribute (yet) to the final result, so they
1371
          are marked as NOT_RELEVANT.
1372
 
1373
        @relation(SDOC-SRS-51, scope=function)
1374
        """
1375
        traceability_index_copy = deepcopy(self)
1376
        bundle_document = SDocDocument(
1377
            mid=None,
1378
            title=project_config.project_title,
1379
            config=None,
1380
            view=None,
1381
            grammar=None,
1382
            section_contents=[],
1383
            is_bundle_document=True,
1384
        )
1385
        bundle_document.config = DocumentConfig.default_config(bundle_document)
1386
 
1387
        if (
1388
            project_config.bundle_document_uid is not None
1389
            and len(project_config.bundle_document_uid) > 0
1390
        ):
1391
            bundle_document.config.uid = project_config.bundle_document_uid
1392
 
1393
        if (
1394
            project_config.bundle_document_version is not None
1395
            and len(project_config.bundle_document_version) > 0
1396
        ):
1397
            bundle_document.config.version = (
1398
                project_config.bundle_document_version
1399
            )
1400
 
1401
        if (
1402
            project_config.bundle_document_date is not None
1403
            and len(project_config.bundle_document_date) > 0
1404
        ):
1405
            bundle_document.config.date = project_config.bundle_document_date
1406
 
1407
        bundle_document.meta = DocumentMeta(
1408
            level=0,
1409
            file_tree_mount_folder="NOT_RELEVANT",
1410
            document_filename="bundle.sdoc",
1411
            document_filename_base="bundle",
1412
            input_doc_full_path="NOT_RELEVANT",
1413
            input_doc_rel_path=SDocRelativePath("bundle.sdoc"),
1414
            input_doc_dir_rel_path=SDocRelativePath(""),
1415
            input_doc_assets_dir_rel_path=SDocRelativePath("NOT_RELEVANT"),
1416
            output_document_dir_full_path=project_config.export_output_html_root,
1417
            output_document_dir_rel_path=SDocRelativePath(""),
1418
        )
1419
        traceability_index_copy.document_iterators[bundle_document] = (
1420
            SDocDocumentIterator(bundle_document)
1421
        )
1422
        for document_ in traceability_index_copy.document_tree.document_list:
1423
            # Ignore all included documents. They are anyway included by
1424
            # the including documents.
1425
            if document_.document_is_included():
1426
                continue
1427
 
1428
            assert document_.ng_including_document_reference is not None
1429
            document_.ng_including_document_reference.set_document(
1430
                bundle_document
1431
            )
1432
            bundle_document.section_contents.append(document_)
1433
        traceability_index_copy.document_tree.document_list = [bundle_document]
1434
        bundle_document.ng_including_document_reference = DocumentReference()
1435
        return traceability_index_copy, bundle_document