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