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