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