StrictDoc Documentation
strictdoc/backend/sdoc/models/node.py
Source file coverage
Path:
strictdoc/backend/sdoc/models/node.py
Lines:
895
Non-empty lines:
772
Non-empty lines covered with requirements:
772 / 772 (100.0%)
Functions:
65
Functions covered by requirements:
65 / 65 (100.0%)
1
"""
2
@relation(SDOC-SRS-26, scope=file)
3
"""
4
 
5
from collections import OrderedDict
6
from dataclasses import dataclass
7
from enum import Enum
8
from typing import Any, Generator, List, Optional, Tuple, Union
9
 
10
from strictdoc.backend.sdoc.document_reference import DocumentReference
11
from strictdoc.backend.sdoc.models.anchor import Anchor
12
from strictdoc.backend.sdoc.models.document_config import DocumentConfig
13
from strictdoc.backend.sdoc.models.document_grammar import (
14
    DocumentGrammar,
15
)
16
from strictdoc.backend.sdoc.models.grammar_element import (
17
    GrammarElement,
18
    ReferenceType,
19
)
20
from strictdoc.backend.sdoc.models.inline_link import InlineLink
21
from strictdoc.backend.sdoc.models.model import (
22
    RequirementFieldName,
23
    SDocDocumentIF,
24
    SDocElementIF,
25
    SDocNodeIF,
26
)
27
from strictdoc.backend.sdoc.models.reference import (
28
    ChildReqReference,
29
    ParentReqReference,
30
    Reference,
31
)
32
from strictdoc.helpers.auto_described import auto_described
33
from strictdoc.helpers.cast import assert_cast
34
from strictdoc.helpers.exception import StrictDocException
35
from strictdoc.helpers.mid import MID
36
from strictdoc.helpers.string import ensure_newline
37
 
38
 
39
@dataclass
40
class SDocNodeContext:
41
    title_number_string: Optional[str] = None
42
    ng_level: int = 0
43
 
44
 
45
class SDocNodeFieldOrigin(str, Enum):
46
    DOCUMENT = "DOCUMENT"
47
    SOURCE = "SOURCE"
48
 
49
    @staticmethod
50
    def all() -> List[str]:  # noqa: A003
51
        return list(map(lambda c: c.value, SDocNodeFieldOrigin))
52
 
53
 
54
@auto_described
55
class SDocNodeField:
56
    def __init__(
57
        self,
58
        parent: Optional["SDocNode"],
59
        field_name: str,
60
        parts: List[Any],
61
        multiline__: Optional[str],
62
    ) -> None:
63
        self.parent: Optional[SDocNode] = parent
64
        self.field_name: str = field_name
65
        self.parts: List[Any] = parts
66
        self.multiline: bool = multiline__ is not None and len(multiline__) > 0
67
        self.origin: SDocNodeFieldOrigin = SDocNodeFieldOrigin.DOCUMENT
68
 
69
        if (
70
            self.multiline
71
            and field_name in RequirementFieldName.RESERVED_SINGLELINE_FIELDS
72
        ):
73
            raise StrictDocException(
74
                f"The node field {field_name} is a reserved field "
75
                "and can only be written as a single-line, not multiline, field."
76
            )
77
 
78
    @staticmethod
79
    def create_from_string(
80
        parent: Optional["SDocNode"],
81
        field_name: str,
82
        field_value: str,
83
        multiline: bool,
84
    ) -> "SDocNodeField":
85
        assert isinstance(field_name, str) and len(field_name) > 0, field_name
86
        assert isinstance(field_value, str) and len(field_value) > 0, (
87
            field_value
88
        )
89
 
90
        return SDocNodeField(
91
            parent=parent,
92
            field_name=field_name,
93
            parts=[field_value],
94
            multiline__="multiline" if multiline else None,
95
        )
96
 
97
    @staticmethod
98
    def from_parts(
99
        parent: Optional["SDocNode"],
100
        field_name: str,
101
        parts: List[Any],
102
        multiline: bool,
103
    ) -> "SDocNodeField":
104
        sdoc_node_field = SDocNodeField(
105
            parent=parent,
106
            field_name=field_name,
107
            parts=parts,
108
            multiline__="multiline" if multiline else None,
109
        )
110
        for part in parts:
111
            if isinstance(part, (InlineLink, Anchor)):
112
                part.parent = sdoc_node_field
113
        return sdoc_node_field
114
 
115
    def is_multiline(self) -> bool:
116
        return self.multiline
117
 
118
    def get_owning_node(self) -> Optional["SDocNode"]:
119
        return self.parent
120
 
121
    def get_text_value(self) -> str:
122
        text = ""
123
        for part in self.parts:
124
            if isinstance(part, str):
125
                text += part
126
            elif isinstance(part, InlineLink):
127
                text += "[LINK: "
128
                text += part.link
129
                text += "]"
130
            elif isinstance(part, Anchor):
131
                text += "[ANCHOR: "
132
                text += part.value
133
                if part.has_title:
134
                    text += ", "
135
                    text += part.get_source_title()
136
                text += "]"
137
                text += "\n"
138
            else:
139
                raise NotImplementedError(part)  # pragma: no cover
140
        return text
141
 
142
    def is_document_origin(self) -> bool:
143
        return self.origin == SDocNodeFieldOrigin.DOCUMENT
144
 
145
    def mark_as_source_origin(self) -> None:
146
        self.origin = SDocNodeFieldOrigin.SOURCE
147
 
148
 
149
@auto_described
150
class SDocNode(SDocNodeIF):
151
    """
152
    @relation(SDOC-SRS-135, SDOC-SRS-100, scope=class)
153
    """
154
 
155
    def __init__(
156
        self,
157
        *,
158
        parent: Union[SDocDocumentIF, SDocNodeIF],
159
        node_type: str,
160
        fields: List[SDocNodeField],
161
        relations: List[Reference],
162
        is_composite: bool = False,
163
        section_contents: Optional[List[SDocElementIF]] = None,
164
        node_type_close: Optional[str] = None,
165
        autogen: bool = False,
166
    ) -> None:
167
        assert parent
168
        assert isinstance(node_type, str)
169
        assert isinstance(relations, list), relations
170
 
171
        self.parent: Union[SDocDocumentIF, SDocNodeIF] = parent
172
 
173
        self.node_type: str = node_type
174
 
175
        if node_type_close is not None and len(node_type_close) > 0:
176
            if node_type != node_type_close:
177
                raise StrictDocException(
178
                    "[[NODE]] syntax error: "
179
                    "Opening and closing tags must match: "
180
                    f"opening: {node_type}, closing: {node_type_close}."
181
                )
182
            assert is_composite
183
        else:
184
            assert not is_composite
185
 
186
        self.is_composite: bool = is_composite
187
 
188
        ordered_fields_lookup: OrderedDict[str, List[SDocNodeField]] = (
189
            OrderedDict()
190
        )
191
 
192
        has_meta: bool = False
193
        for field in fields:
194
            if (
195
                field.field_name
196
                not in RequirementFieldName.RESERVED_NON_META_FIELDS
197
            ):
198
                has_meta = True
199
            ordered_fields_lookup.setdefault(field.field_name, []).append(field)
200
 
201
        self.section_contents: List[SDocElementIF] = (
202
            section_contents if section_contents is not None else []
203
        )
204
 
205
        self.relations: List[Reference] = relations
206
 
207
        # TODO: Is it worth to move this to dedicated Presenter* classes to
208
        # keep this class textx-only?
209
        self.has_meta: bool = has_meta
210
 
211
        # This property is only used for validating fields against grammar
212
        # during TextX parsing and processing.
213
        self.fields_as_parsed = fields
214
 
215
        self.ordered_fields_lookup: OrderedDict[str, List[SDocNodeField]] = (
216
            ordered_fields_lookup
217
        )
218
        self.ng_document_reference: Optional[DocumentReference] = None
219
        self.ng_including_document_reference: Optional[DocumentReference] = None
220
        self.ng_line_start: Optional[int] = None
221
        self.ng_line_end: Optional[int] = None
222
        self.ng_col_start: Optional[int] = None
223
        self.ng_col_end: Optional[int] = None
224
        self.ng_byte_start: Optional[int] = None
225
        self.ng_byte_end: Optional[int] = None
226
        self.context: SDocNodeContext = SDocNodeContext()
227
 
228
        mid: Optional[str] = None
229
        mid_fields: Optional[List[SDocNodeField]] = ordered_fields_lookup.get(
230
            "MID", None
231
        )
232
        if mid_fields is not None:
233
            mid = mid_fields[0].get_text_value()
234
        self.reserved_mid: MID = MID(mid) if mid is not None else MID.create()
235
        self.mid_permanent: bool = mid is not None
236
 
237
        self.ng_resolved_custom_level: Optional[str] = None
238
        self.custom_level: Optional[str] = None
239
        if RequirementFieldName.LEVEL in ordered_fields_lookup:
240
            level = ordered_fields_lookup[RequirementFieldName.LEVEL][
241
                0
242
            ].get_text_value()
243
            self.ng_resolved_custom_level = level
244
            self.custom_level = level
245
 
246
        self.ng_has_requirements: bool = False
247
 
248
        # Specifies whether a node is created from text or autogenerated, e.g.,
249
        # from a JUnit XML test report or from reading source file comments.
250
        # The SDoc writer uses this property to decide whether it shall write
251
        # autogenerated code to disk.
252
        self.autogen: bool = autogen
253
 
254
    def get_total_size(self) -> Tuple[int, int, int]:
255
        """
256
        Calculate the how many nodes a given node contains.
257
 
258
        The returned value is a tuple:
259
        (total nodes, normative nodes, non-normative nodes)
260
        """
261
        if self.section_contents is None or len(self.section_contents) == 0:
262
            is_normative = self.is_normative_node()
263
            return 1, int(is_normative), int(not is_normative)
264
        total_size = (0, 0, 0)
265
        for node_ in self.section_contents:
266
            if isinstance(node_, SDocNode):
267
                node_total_size = node_.get_total_size()
268
                total_size = (
269
                    total_size[0] + node_total_size[0],
270
                    total_size[1] + node_total_size[1],
271
                    total_size[2] + node_total_size[2],
272
                )
273
        return total_size
274
 
275
    @staticmethod
276
    def create_section(
277
        parent: Any, document: SDocDocumentIF, title: str
278
    ) -> "SDocCompositeNode":
279
        node = SDocCompositeNode(
280
            parent=parent,
281
            node_type="SECTION",
282
            fields=[],
283
            relations=[],
284
            section_contents=[],
285
            node_type_close="SECTION",
286
        )
287
        node.ng_including_document_reference = DocumentReference()
288
        node.ng_document_reference = DocumentReference()
289
        node.ng_document_reference.set_document(document)
290
        if (
291
            document.grammar is not None
292
            and "SECTION" in document.grammar.elements_by_type
293
        ):
294
            node.set_field_value(
295
                field_name="TITLE",
296
                form_field_index=0,
297
                value=title,
298
            )
299
        else:
300
            # The grammar does not have SECTION yet (e.g., a custom grammar
301
            # stub whose file has not been loaded at parse time).
302
            # Store TITLE directly; SDocValidator will validate the node type
303
            # once the grammar is fully loaded.
304
            if title:
305
                node.ordered_fields_lookup["TITLE"] = [
306
                    SDocNodeField.create_from_string(
307
                        node,
308
                        field_name="TITLE",
309
                        field_value=title,
310
                        multiline=False,
311
                    )
312
                ]
313
        return node
314
 
315
    @staticmethod
316
    def get_type_string() -> str:
317
        return "requirement"
318
 
319
    def get_node_type_string(self) -> Optional[str]:
320
        return self.node_type
321
 
322
    def get_display_title(
323
        self,
324
        include_toc_number: bool = True,  # noqa: ARG002
325
    ) -> str:
326
        if self.reserved_title is not None:
327
            if (
328
                include_toc_number
329
                and self.context.title_number_string is not None
330
            ):
331
                return (
332
                    f"{self.context.title_number_string}. {self.reserved_title}"
333
                )
334
            return self.reserved_title
335
        if self.reserved_uid is not None:
336
            return self.reserved_uid
337
        if self.node_type == "TEXT":
338
            if (
339
                isinstance(self.parent, SDocNode)
340
                and self.parent.node_type == "SECTION"
341
            ):
342
                return f'Text node from section "{self.parent.get_display_title()}"'
343
            if isinstance(self.parent, SDocDocumentIF):
344
                return f'Text node from document "{self.parent.get_display_title()}"'
345
        return f"{self.node_type} with no title/UID"
346
 
347
    @property
348
    def is_root_included_document(self) -> bool:
349
        return False
350
 
351
    @property
352
    def is_root(self) -> bool:
353
        document = assert_cast(self.get_document(), SDocDocumentIF)
354
        return document.config.root is True
355
 
356
    def has_multiline_fields(self) -> bool:
357
        """
358
        FIXME: It should be possible to avoid calculating this every time.
359
        """
360
 
361
        document = assert_cast(self.get_document(), SDocDocumentIF)
362
        grammar = assert_cast(document.grammar, DocumentGrammar)
363
        element: GrammarElement = grammar.elements_by_type[self.node_type]
364
 
365
        for fields_ in self.ordered_fields_lookup.values():
366
            for field_ in fields_:
367
                if element.is_field_multiline(field_.field_name):
368
                    return True
369
        return False
370
 
371
    def has_any_text_nodes(self) -> bool:
372
        # The workaround: hasattr(...) makes mypy happy.
373
        return any(
374
            node_.__class__.__name__ == "SDocNode"
375
            and hasattr(node_, "node_type")
376
            and node_.node_type == "TEXT"
377
            for node_ in self.section_contents
378
        )
379
 
380
    def has_child_nodes(self) -> bool:
381
        return len(self.section_contents) > 0
382
 
383
    #
384
    # Reserved fields
385
    #
386
 
387
    @property
388
    def reserved_uid(self) -> Optional[str]:
389
        document = assert_cast(self.get_document(), SDocDocumentIF)
390
        config = assert_cast(document.config, DocumentConfig)
391
 
392
        return self._get_cached_field(
393
            config.get_relation_field(), singleline_only=True
394
        )
395
 
396
    @reserved_uid.setter
397
    def reserved_uid(self, uid: Optional[str]) -> None:
398
        document = assert_cast(self.get_document(), SDocDocumentIF)
399
        config = assert_cast(document.config, DocumentConfig)
400
 
401
        self.set_field_value(
402
            field_name=config.get_relation_field(),
403
            form_field_index=0,
404
            value=uid,
405
        )
406
 
407
    @property
408
    def reserved_status(self) -> Optional[str]:
409
        return self._get_cached_field(
410
            RequirementFieldName.STATUS, singleline_only=True
411
        )
412
 
413
    @property
414
    def reserved_tags(self) -> Optional[List[str]]:
415
        if RequirementFieldName.TAGS not in self.ordered_fields_lookup:
416
            return None
417
        field: SDocNodeField = self.ordered_fields_lookup[
418
            RequirementFieldName.TAGS
419
        ][0]
420
        assert not field.is_multiline(), (
421
            f"Field {RequirementFieldName.TAGS} must be a single-line field."
422
        )
423
        tags = field.get_text_value().split(", ")
424
        return tags
425
 
426
    @property
427
    def reserved_title(self) -> Optional[str]:
428
        return self._get_cached_field(
429
            RequirementFieldName.TITLE, singleline_only=True
430
        )
431
 
432
    def has_reserved_statement(self) -> bool:
433
        document = assert_cast(self.get_document(), SDocDocumentIF)
434
        grammar = assert_cast(document.grammar, DocumentGrammar)
435
        element: GrammarElement = grammar.elements_by_type[self.node_type]
436
        return element.content_field[0] in self.ordered_fields_lookup
437
 
438
    @property
439
    def reserved_statement(self) -> Optional[str]:
440
        document = assert_cast(self.get_document(), SDocDocumentIF)
441
        grammar = assert_cast(document.grammar, DocumentGrammar)
442
        element: GrammarElement = grammar.elements_by_type[self.node_type]
443
        return self._get_cached_field(
444
            element.content_field[0], singleline_only=False
445
        )
446
 
447
    @property
448
    def rationale(self) -> Optional[str]:
449
        return self._get_cached_field(
450
            RequirementFieldName.RATIONALE, singleline_only=False
451
        )
452
 
453
    def is_requirement(self) -> bool:
454
        return True
455
 
456
    def is_normative_node(self) -> bool:
457
        return self.node_type not in ("SECTION", "TEXT")
458
 
459
    def is_text_node(self) -> bool:
460
        return self.node_type == "TEXT"
461
 
462
    def is_document(self) -> bool:
463
        return False
464
 
465
    def get_document(self) -> Optional[SDocDocumentIF]:
466
        assert self.ng_document_reference is not None, self
467
        return self.ng_document_reference.get_document()
468
 
469
    def get_including_document(self) -> Optional[SDocDocumentIF]:
470
        assert self.ng_including_document_reference is not None
471
        return self.ng_including_document_reference.get_document()
472
 
473
    def get_parent_or_including_document(self) -> SDocDocumentIF:
474
        assert self.ng_including_document_reference is not None
475
        including_document_or_none = (
476
            self.ng_including_document_reference.get_document()
477
        )
478
        if including_document_or_none is not None:
479
            return including_document_or_none
480
 
481
        assert self.ng_document_reference is not None
482
        document: Optional[SDocDocumentIF] = (
483
            self.ng_document_reference.get_document()
484
        )
485
        assert document is not None, (
486
            "A valid requirement must always have a reference to the document."
487
        )
488
        return document
489
 
490
    def get_display_node_type(self) -> str:
491
        return "Node"
492
 
493
    def get_debug_info(self) -> str:
494
        debug_components: List[str] = []
495
        if self.reserved_mid is not None:
496
            debug_components.append(f"MID = '{self.reserved_mid}'")
497
        if (reserved_uid_ := self.reserved_uid) is not None:
498
            debug_components.append(f"UID = '{reserved_uid_}'")
499
        if self.reserved_title is not None:
500
            debug_components.append(f"TITLE = '{self.reserved_title}'")
501
 
502
        document: Optional[SDocDocumentIF] = self.get_document()
503
        if document is not None:
504
            debug_components.append(f"document = {document.get_debug_info()}")
505
        return f"Requirement({', '.join(debug_components)})"
506
 
507
    def document_is_included(self) -> bool:
508
        assert self.ng_including_document_reference is not None
509
        return self.ng_including_document_reference.get_document() is not None
510
 
511
    def get_requirement_style_mode(self) -> str:
512
        document: SDocDocumentIF = assert_cast(
513
            self.get_document(), SDocDocumentIF
514
        )
515
        grammar = assert_cast(document.grammar, DocumentGrammar)
516
        element: GrammarElement = grammar.elements_by_type[self.node_type]
517
        if node_style := element.get_view_style():
518
            return node_style
519
        return document.config.get_requirement_style_mode()
520
 
521
    def get_content_field_name(self) -> str:
522
        document = assert_cast(self.get_document(), SDocDocumentIF)
523
        grammar = assert_cast(document.grammar, DocumentGrammar)
524
 
525
        element: GrammarElement = grammar.elements_by_type[self.node_type]
526
        return element.content_field[0]
527
 
528
    def get_content_field(self) -> SDocNodeField:
529
        document = assert_cast(self.get_document(), SDocDocumentIF)
530
        grammar = assert_cast(document.grammar, DocumentGrammar)
531
 
532
        element: GrammarElement = grammar.elements_by_type[self.node_type]
533
        return self.ordered_fields_lookup[element.content_field[0]][0]
534
 
535
    def get_field_by_name(self, field_name: str) -> SDocNodeField:
536
        return self.ordered_fields_lookup[field_name][0]
537
 
538
    def get_anchors(self) -> List[Anchor]:
539
        this_node_anchors: List[Anchor] = []
540
        for field_ in self.enumerate_fields():
541
            for field_part_ in field_.parts:
542
                if isinstance(field_part_, Anchor):
543
                    this_node_anchors.append(field_part_)
544
        return this_node_anchors
545
 
546
    def get_comment_fields(self) -> List[SDocNodeField]:
547
        if RequirementFieldName.COMMENT not in self.ordered_fields_lookup:
548
            return []
549
        return self.ordered_fields_lookup[RequirementFieldName.COMMENT]
550
 
551
    def get_requirement_references(self, ref_type: str) -> List[Reference]:
552
        if len(self.relations) == 0:
553
            return []
554
        references: List[Reference] = []
555
        for reference in self.relations:
556
            if reference.ref_type != ref_type:
557
                continue
558
            references.append(reference)
559
        return references
560
 
561
    def get_requirement_reference_uids(
562
        self,
563
    ) -> List[Tuple[str, str, Optional[str]]]:
564
        if len(self.relations) == 0:
565
            return []
566
        references: List[Tuple[str, str, Optional[str]]] = []
567
        for reference in self.relations:
568
            if reference.ref_type == ReferenceType.PARENT:
569
                parent_reference: ParentReqReference = assert_cast(
570
                    reference, ParentReqReference
571
                )
572
                references.append(
573
                    (
574
                        parent_reference.ref_type,
575
                        parent_reference.ref_uid,
576
                        parent_reference.role,
577
                    )
578
                )
579
            elif reference.ref_type == ReferenceType.CHILD:
580
                child_reference: ChildReqReference = assert_cast(
581
                    reference, ChildReqReference
582
                )
583
                references.append(
584
                    (
585
                        child_reference.ref_type,
586
                        child_reference.ref_uid,
587
                        child_reference.role,
588
                    )
589
                )
590
        return references
591
 
592
    def enumerate_fields(self) -> Generator[SDocNodeField, None, None]:
593
        requirement_fields = self.ordered_fields_lookup.values()
594
        for requirement_field_list in requirement_fields:
595
            yield from requirement_field_list
596
 
597
    def enumerate_all_fields(
598
        self,
599
    ) -> Generator[Tuple[SDocNodeField, str, str], None, None]:
600
        for field in self.enumerate_fields():
601
            meta_field_value = field.get_text_value()
602
            yield field, field.field_name, meta_field_value
603
 
604
    def enumerate_meta_fields(
605
        self, skip_single_lines: bool = False, skip_multi_lines: bool = False
606
    ) -> Generator[Tuple[str, SDocNodeField], None, None]:
607
        document: SDocDocumentIF = assert_cast(
608
            self.get_document(), SDocDocumentIF
609
        )
610
 
611
        document_grammar: DocumentGrammar = assert_cast(
612
            document.grammar, DocumentGrammar
613
        )
614
 
615
        element: GrammarElement = document_grammar.elements_by_type[
616
            self.node_type
617
        ]
618
 
619
        for field in self.enumerate_fields():
620
            if (
621
                field.field_name
622
                in RequirementFieldName.RESERVED_NON_META_FIELDS
623
            ):
624
                continue
625
 
626
            is_single_line_field = not element.is_field_multiline(
627
                field.field_name
628
            )
629
 
630
            if is_single_line_field and skip_single_lines:
631
                continue
632
            if (not is_single_line_field) and skip_multi_lines:
633
                continue
634
 
635
            field_human_title = element.fields_map[field.field_name]
636
            yield field_human_title.get_field_human_name(), field
637
 
638
    def get_meta_field_value_by_title(self, field_title: str) -> Optional[str]:
639
        assert isinstance(field_title, str)
640
        if field_title not in self.ordered_fields_lookup:
641
            return None
642
        field: SDocNodeField = self.ordered_fields_lookup[field_title][0]
643
        return field.get_text_value()
644
 
645
    def get_field_human_title(self, field_name: str) -> str:
646
        document: SDocDocumentIF = assert_cast(
647
            self.get_document(), SDocDocumentIF
648
        )
649
        document_grammar: DocumentGrammar = assert_cast(
650
            document.grammar, DocumentGrammar
651
        )
652
        element: GrammarElement = document_grammar.elements_by_type[
653
            self.node_type
654
        ]
655
        field_human_title = element.fields_map[field_name]
656
        return field_human_title.get_field_human_name()
657
 
658
    def get_field_human_title_for_statement(self) -> str:
659
        document: SDocDocumentIF = assert_cast(
660
            self.get_document(), SDocDocumentIF
661
        )
662
        grammar: DocumentGrammar = assert_cast(
663
            document.grammar, DocumentGrammar
664
        )
665
        element: GrammarElement = grammar.elements_by_type[self.node_type]
666
        field_human_title = element.fields_map[element.content_field[0]]
667
        return field_human_title.get_field_human_name()
668
 
669
    def get_prefix(self) -> Optional[str]:
670
        if (
671
            own_prefix := self._get_cached_field(
672
                RequirementFieldName.PREFIX, singleline_only=True
673
            )
674
        ) is not None:
675
            if own_prefix == "None":
676
                return None
677
            return own_prefix
678
 
679
        document: SDocDocumentIF = assert_cast(
680
            self.get_document(), SDocDocumentIF
681
        )
682
        grammar: DocumentGrammar = assert_cast(
683
            document.grammar, DocumentGrammar
684
        )
685
        element: GrammarElement = grammar.elements_by_type[self.node_type]
686
        if (element_prefix := element.property_prefix) is not None:
687
            if element_prefix == "None":
688
                return None
689
            return element_prefix
690
 
691
        # FIXME: Is this a reasonable behavior?
692
        if (
693
            isinstance(self.parent, SDocNode)
694
            and self.parent.node_type == "SECTION"
695
        ):
696
            if (parent_prefix := self.parent.get_prefix()) is not None:
697
                return parent_prefix
698
            return document.get_prefix()
699
 
700
        return self.parent.get_prefix()
701
 
702
    def get_prefix_for_new_node(self, node_type: str) -> Optional[str]:
703
        assert isinstance(node_type, str) and len(node_type), node_type
704
 
705
        document: SDocDocumentIF = assert_cast(
706
            self.get_document(), SDocDocumentIF
707
        )
708
        grammar: DocumentGrammar = assert_cast(
709
            document.grammar, DocumentGrammar
710
        )
711
        element: GrammarElement = grammar.elements_by_type[node_type]
712
        if (element_prefix := element.property_prefix) is not None:
713
            if element_prefix == "None":
714
                return None
715
            return element_prefix
716
 
717
        return self.get_prefix()
718
 
719
    def is_managed_by_source_code(self) -> bool:
720
        """
721
        Helper method to check if a node is partially managed by source code.
722
        """
723
 
724
        # Is the node entirely generated from source code?
725
        if self.autogen:
726
            return True
727
 
728
        # Check if fields were merged from source-files.
729
        for field_list in self.ordered_fields_lookup.values():
730
            for field in field_list:
731
                # If any field did not originate from the document,
732
                # the node's content is partially managed by source code...
733
                if not field.is_document_origin():
734
                    return True
735
 
736
        return False
737
 
738
    def dump_fields_as_parsed(self) -> str:
739
        # FIXME:
740
        # - The name of the method can be improved (used in error messages).
741
        # - fields can diverge from fields_as_parsed.
742
        return ", ".join(
743
            list(
744
                map(
745
                    lambda r: r.field_name,
746
                    self.fields_as_parsed,
747
                )
748
            )
749
        )
750
 
751
    def _get_cached_field(
752
        self, field_name: str, singleline_only: bool
753
    ) -> Optional[str]:
754
        if field_name not in self.ordered_fields_lookup:
755
            return None
756
        field: SDocNodeField = self.ordered_fields_lookup[field_name][0]
757
 
758
        if singleline_only and field.is_multiline():
759
            raise NotImplementedError(
760
                f"Field {field_name} must be a single-line field."
761
            )
762
 
763
        return field.get_text_value()
764
 
765
    # Below all mutating methods.
766
 
767
    def set_field_value(
768
        self,
769
        *,
770
        field_name: str,
771
        form_field_index: int,
772
        value: Optional[Union[str, SDocNodeField]],
773
    ) -> None:
774
        """
775
        Create or update a field by name with the given value.
776
 
777
        The purpose of this purpose is to provide a single-method API for
778
        updating any field of a requirement. A requirement might use only some
779
        fields of a document grammar, so an extra exercise done by the method is
780
        to ensure that an added field that has not been attached to the
781
        requirement before will be put at the right index.
782
        """
783
        assert isinstance(field_name, str)
784
 
785
        # If a field value is being removed, there is not much to do.
786
        if value is None or (isinstance(value, str) and len(value) == 0):
787
            # Comment is a special because there can be multiple comments.
788
            # Empty comments are simply ignored and do not show up in the
789
            # updated requirement.
790
            if field_name == RequirementFieldName.COMMENT:
791
                return
792
 
793
            if field_name in self.ordered_fields_lookup:
794
                del self.ordered_fields_lookup[field_name]
795
            return
796
 
797
        # If a field value is being added or updated.
798
        document: SDocDocumentIF = assert_cast(
799
            self.get_document(), SDocDocumentIF
800
        )
801
        grammar: DocumentGrammar = assert_cast(
802
            document.grammar, DocumentGrammar
803
        )
804
        element: GrammarElement = grammar.elements_by_type[self.node_type]
805
 
806
        field_index = element.field_titles.index(field_name)
807
 
808
        multiline = element.is_field_multiline(field_name)
809
        if multiline and isinstance(value, str):
810
            value = ensure_newline(value)
811
        elif (
812
            multiline
813
            and isinstance(value, SDocNodeField)
814
            and len(value.parts) > 0
815
        ):
816
            last_part = value.parts[-1]
817
            if isinstance(last_part, str):
818
                value.parts[-1] = ensure_newline(last_part)
819
            elif isinstance(last_part, InlineLink):
820
                value.parts.append("\n")
821
 
822
        if field_name in self.ordered_fields_lookup:
823
            if len(self.ordered_fields_lookup[field_name]) > form_field_index:
824
                self.ordered_fields_lookup[field_name][form_field_index] = (
825
                    SDocNodeField.create_from_string(
826
                        self,
827
                        field_name=field_name,
828
                        field_value=value,
829
                        multiline=multiline,
830
                    )
831
                    if isinstance(value, str)
832
                    else value
833
                )
834
            else:
835
                self.ordered_fields_lookup[field_name].insert(
836
                    form_field_index,
837
                    SDocNodeField.create_from_string(
838
                        self,
839
                        field_name=field_name,
840
                        field_value=value,
841
                        multiline=multiline,
842
                    )
843
                    if isinstance(value, str)
844
                    else value,
845
                )
846
            return
847
 
848
        new_ordered_fields_lookup = OrderedDict()
849
        for field_title in element.field_titles[:field_index]:
850
            if field_title in self.ordered_fields_lookup:
851
                new_ordered_fields_lookup[field_title] = (
852
                    self.ordered_fields_lookup[field_title]
853
                )
854
        new_ordered_fields_lookup[field_name] = [
855
            SDocNodeField.create_from_string(
856
                self,
857
                field_name=field_name,
858
                field_value=value,
859
                multiline=multiline,
860
            )
861
            if isinstance(value, str)
862
            else value
863
        ]
864
        after_field_index = field_index + 1
865
        for field_title in element.field_titles[after_field_index:]:
866
            if field_title in self.ordered_fields_lookup:
867
                new_ordered_fields_lookup[field_title] = (
868
                    self.ordered_fields_lookup[field_title]
869
                )
870
        self.ordered_fields_lookup = new_ordered_fields_lookup
871
        self._update_has_meta()
872
 
873
    def _update_has_meta(self) -> None:
874
        has_meta: bool = False
875
        for field in self.enumerate_fields():
876
            if (
877
                field.field_name
878
                not in RequirementFieldName.RESERVED_NON_META_FIELDS
879
            ):
880
                has_meta = True
881
        self.has_meta = has_meta
882
 
883
 
884
@auto_described
885
class SDocCompositeNode(SDocNode):
886
    """
887
    @relation(SDOC-SRS-99, scope=class)
888
    """
889
 
890
    def __init__(
891
        self,
892
        parent: Union[SDocDocumentIF, SDocNodeIF],
893
        **fields: Any,
894
    ) -> None:
895
        super().__init__(parent=parent, **fields, is_composite=True)