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
@dataclass40
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
@staticmethod50
def all() -> List[str]: # noqa: A003
51
return list(map(lambda c: c.value, SDocNodeFieldOrigin))
52
53
54
@auto_described55
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
@staticmethod79
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_value88
)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
@staticmethod98
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- "1.9. Free text" (REQUIREMENT)
- "1.3. Requirement model fields" (REQUIREMENT)
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 to207
# keep this class textx-only?208
self.has_meta: bool = has_meta
209
210
# This property is only used for validating fields against grammar211
# during TextX parsing and processing.212
self.fields_as_parsed = fields
213
214
self.ordered_fields_lookup: OrderedDict[str, List[SDocNodeField]] = (
215
ordered_fields_lookup216
)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
0241
].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 write250
# 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
@staticmethod275
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 grammar300
# stub whose file has not been loaded at parse time).301
# Store TITLE directly; SDocValidator will validate the node type302
# 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
@staticmethod315
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_number328
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
@property347
def is_root_included_document(self) -> bool:
348
return False
349
350
@property351
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 fields384
#385
386
@property387
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
@property407
def reserved_status(self) -> Optional[str]:
408
return self._get_cached_field(
409
RequirementFieldName.STATUS, singleline_only=True
410
)411
412
@property413
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
@property426
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
@property438
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
@property447
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
continue557
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
continue624
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
continue631
if (not is_single_line_field) and skip_multi_lines:
632
continue633
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 for777
updating any field of a requirement. A requirement might use only some778
fields of a document grammar, so an extra exercise done by the method is779
to ensure that an added field that has not been attached to the780
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 the788
# updated requirement.789
if field_name == RequirementFieldName.COMMENT:
790
return791
792
if field_name in self.ordered_fields_lookup:
793
del self.ordered_fields_lookup[field_name]
794
return795
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
multiline812
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
return846
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_described884
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)