Path:
strictdoc/backend/sdoc/models/document.py
Lines:
386
Non-empty lines:
318
Non-empty lines covered with requirements:
318 / 318 (100.0%)
Functions:
33
Functions covered by requirements:
33 / 33 (100.0%)
- "1.5. Document model" (REQUIREMENT)
- "1.10. Composeable document" (REQUIREMENT)
1
"""2
@relation(SDOC-SRS-98, SDOC-SRS-109, scope=file)3
"""4
5
from collections import defaultdict
6
from dataclasses import dataclass
7
from typing import DefaultDict, Dict, Generator, List, Optional, Set, Tuple
8
9
from strictdoc.backend.sdoc.document_reference import DocumentReference
10
from strictdoc.backend.sdoc.models.document_config import DocumentConfig
11
from strictdoc.backend.sdoc.models.document_grammar import (
12
DocumentGrammar,
13
)14
from strictdoc.backend.sdoc.models.document_view import DocumentView
15
from strictdoc.backend.sdoc.models.grammar_element import (
16
GrammarElement,
17
GrammarElementField,
18
GrammarElementFieldMultipleChoice,
19
GrammarElementFieldSingleChoice,
20
GrammarElementFieldTag,
21
)22
from strictdoc.backend.sdoc.models.model import (
23
SDocDocumentFromFileIF,
24
SDocDocumentIF,
25
SDocElementIF,
26
SDocNodeIF,
27
)28
from strictdoc.backend.sdoc.models.node import (
29
SDocNode,
30
SDocNodeContext,
31
SDocNodeField,
32
)33
from strictdoc.core.document_meta import DocumentMeta
34
from strictdoc.helpers.auto_described import auto_described
35
from strictdoc.helpers.cast import assert_cast
36
from strictdoc.helpers.mid import MID
37
from strictdoc.helpers.ordered_set import OrderedSet
38
from strictdoc.helpers.string import tokenize
39
40
41
@dataclass42
class SDocDocumentSearchIndex:
43
document_index: DefaultDict[str, Set[str]]
44
map_nodes_by_mid: Dict[str, Dict[str, str]]
45
46
@classmethod47
def create_empty(cls) -> "SDocDocumentSearchIndex":
48
return SDocDocumentSearchIndex(
49
document_index=defaultdict(set), map_nodes_by_mid={}
50
)51
52
53
@auto_described54
class SDocDocument(SDocDocumentIF):
55
def __init__(
56
self,
57
mid: Optional[str],
58
title: str,
59
config: Optional[DocumentConfig],
60
view: Optional[DocumentView],
61
grammar: Optional[DocumentGrammar],
62
section_contents: List[SDocElementIF],
63
is_bundle_document: bool = False,
64
autogen: bool = False,
65
) -> None:
66
self.title: str = title
67
self.reserved_title: str = title
68
self.config: DocumentConfig = (
69
config70
if config is not None
71
else DocumentConfig.default_config(self)
72
)73
self.view: DocumentView = (
74
view if view is not None else DocumentView.create_default(self)
75
)76
self.grammar: Optional[DocumentGrammar] = grammar
77
self.section_contents: List[SDocElementIF] = section_contents
78
79
self.is_bundle_document: bool = is_bundle_document
80
81
self.fragments_from_files: List[SDocDocumentFromFileIF] = []
82
83
self.ng_has_requirements = False
84
85
self.meta: Optional[DocumentMeta] = None
86
87
self.reserved_mid: MID = MID(mid) if mid is not None else MID.create()
88
self.mid_permanent: bool = mid is not None
89
self.included_documents: List[SDocDocumentIF] = []
90
self.context: SDocNodeContext = SDocNodeContext()
91
92
self.ng_including_document_reference: Optional[DocumentReference] = None
93
self.ng_including_document_from_file: Optional[
94
SDocDocumentFromFileIF95
] = None
96
97
self.search_index = SDocDocumentSearchIndex.create_empty()
98
self.ng_source_content: Optional[str] = None
99
100
# Specifies whether a node is created from text or autogenerated, e.g.,101
# from a JUnit XML test report or from reading source file comments.102
# The SDoc writer uses this property to decide whether it shall write103
# autogenerated code to disk.104
self.autogen: bool = autogen
105
106
def get_total_size(self) -> Tuple[int, int, int]:
107
"""
108
Calculate the how many nodes a given document contains.109
110
The returned value is a tuple:111
(total nodes, normative nodes, non-normative nodes)112
"""113
if self.section_contents is None or len(self.section_contents) == 0:
114
return 0, 0, 0
115
total_size = 0, 0, 0
116
for node_ in self.section_contents:
117
if isinstance(node_, SDocNode):
118
node_total_size = node_.get_total_size()
119
total_size = (
120
total_size[0] + node_total_size[0],
121
total_size[1] + node_total_size[1],
122
total_size[2] + node_total_size[2],
123
)124
return total_size
125
126
def iterate_nodes(
127
self, element_type: Optional[str] = None
128
) -> Generator[SDocNodeIF, None, None]:
129
"""
130
Iterate over all non-[TEXT] nodes in the document.131
132
If element_type is given, then only nodes of type `element_type` are133
returned. Otherwise, all element types are returned.134
"""135
task_list: List[SDocElementIF] = list(self.section_contents)
136
while task_list:
137
node = task_list.pop(0)
138
139
if isinstance(node, SDocDocumentFromFileIF):
140
yield from node.iterate_nodes(element_type)
141
142
if isinstance(node, SDocNodeIF):
143
if node.node_type != "TEXT":
144
if element_type is None or node.node_type == element_type:
145
yield node
146
147
task_list.extend(node.section_contents)
148
149
def has_any_requirements(self) -> bool:
150
return any(True for _ in self.iterate_nodes())
151
152
def collect_options_for_tag(
153
self, element_type: str, field_name: str
154
) -> List[str]:
155
"""
156
Returns the list of existing options for a tag field in this document.157
"""158
option_set: OrderedSet[str] = OrderedSet()
159
160
for nodeif in self.iterate_nodes(element_type):
161
node = assert_cast(nodeif, SDocNode)
162
if field_name in node.ordered_fields_lookup:
163
node_field = node.ordered_fields_lookup[field_name][0]
164
field_value = node_field.get_text_value()
165
if field_value:
166
options = [
167
option.strip()
168
for option in field_value.split(",")
169
if option.strip()
170
]171
for option in options:
172
option_set.add(option)
173
174
return list(option_set)
175
176
@property177
def uid(self) -> Optional[str]:
178
return self.config.uid
179
180
@property181
def is_root_included_document(self) -> bool:
182
return self.document_is_included()
183
184
def is_requirement(self) -> bool:
185
return False
186
187
def is_document(self) -> bool:
188
return True
189
190
@property191
def node_type(self) -> str:
192
"""
193
This is used in UI only for included documents that are rendered as194
SECTIONS.195
"""196
assert self.document_is_included()
197
return "SECTION"
198
199
def get_display_node_type(self) -> str:
200
"""
201
This is only used for validation messages.202
"""203
return "Document"
204
205
def get_node_type_string(self) -> Optional[str]:
206
return None
207
208
def get_type_string(self) -> str:
209
return "document" if not self.document_is_included() else "section"
210
211
def get_debug_info(self) -> str:
212
debug_components: List[str] = [f"TITLE = '{self.title}'"]
213
if self.meta is not None:
214
debug_components.append(
215
f" ({self.meta.input_doc_rel_path.relative_path})"
216
)217
return f"Document({', '.join(debug_components)})"
218
219
def document_is_included(self) -> bool:
220
if self.ng_including_document_reference is None:
221
return False
222
return self.ng_including_document_reference.get_document() is not None
223
224
def get_including_document(self) -> Optional["SDocDocumentIF"]:
225
if self.ng_including_document_reference is None:
226
return None
227
return self.ng_including_document_reference.get_document()
228
229
def iterate_included_documents_depth_first(
230
self,
231
) -> Generator["SDocDocumentIF", None, None]:
232
for included_document_ in self.included_documents:
233
yield included_document_
234
yield from included_document_.iterate_included_documents_depth_first()
235
236
@property237
def reserved_uid(self) -> Optional[str]:
238
return self.config.uid
239
240
def assign_meta(self, meta: DocumentMeta) -> None:
241
assert isinstance(meta, DocumentMeta)
242
self.meta = meta
243
244
def has_any_nodes(self) -> bool:
245
return len(self.section_contents) > 0
246
247
def get_display_title(
248
self,
249
include_toc_number: bool = True, # noqa: ARG002
250
) -> str:
251
return self.title
252
253
@property254
def ng_resolved_custom_level(self) -> Optional[str]:
255
return None
256
257
@property258
def requirement_prefix(self) -> str:
259
return self.get_prefix()
260
261
def get_prefix(self) -> str:
262
return self.config.get_prefix()
263
264
def get_prefix_for_new_node(self, node_type: str) -> Optional[str]:
265
assert isinstance(node_type, str) and len(node_type), node_type
266
267
grammar: DocumentGrammar = assert_cast(self.grammar, DocumentGrammar)
268
element: GrammarElement = grammar.elements_by_type[node_type]
269
if (element_prefix := element.property_prefix) is not None:
270
if element_prefix == "None":
271
return None
272
return element_prefix
273
274
return self.get_prefix()
275
276
def enumerate_table_meta_field_titles(self) -> Generator[str, None, None]:
277
assert self.grammar is not None
278
assert self.grammar.elements is not None
279
seen: Set[str] = set()
280
for element in self.grammar.elements:
281
for title in element.enumerate_table_meta_field_titles():
282
if title not in seen:
283
seen.add(title)
284
yield title
285
286
def enumerate_table_non_reserved_content_field_titles(
287
self,
288
) -> Generator[str, None, None]:
289
assert self.grammar is not None
290
assert self.grammar.elements is not None
291
seen: Set[str] = set()
292
for element in self.grammar.elements:
293
for (
294
title295
) in element.enumerate_table_non_reserved_content_field_titles():
296
if title not in seen:
297
seen.add(title)
298
yield title
299
300
def get_grammar_element_field_for(
301
self, element_type: str, field_name: str
302
) -> GrammarElementField:
303
"""
304
Returns the GrammarElementField for a field of a [element_type] in this document.305
"""306
grammar: DocumentGrammar = assert_cast(self.grammar, DocumentGrammar)
307
element: GrammarElement = grammar.elements_by_type[element_type]
308
field: GrammarElementField = element.fields_map[field_name]
309
return field
310
311
def get_options_for_field(
312
self, element_type: str, field_name: str
313
) -> List[str]:
314
"""
315
Returns the list of valid options for a Single/MultiChoice field in this document.316
"""317
field: GrammarElementField = self.get_grammar_element_field_for(
318
element_type, field_name
319
)320
321
if isinstance(field, GrammarElementFieldSingleChoice) or isinstance(
322
field, GrammarElementFieldMultipleChoice
323
):324
return field.options
325
326
if isinstance(field, GrammarElementFieldTag):
327
return self.collect_options_for_tag(element_type, field_name)
328
329
raise AssertionError(f"Must not reach here: {field}")
330
331
def build_search_index(self) -> None:
332
"""
333
Build a static search index for this document.334
335
@relation(SDOC-SRS-155, scope=function)336
"""337
338
document_index = defaultdict(set)
339
map_nodes_by_mid = {}
340
341
from strictdoc.core.document_iterator import ( # noqa: PLC0415
342
SDocDocumentIterator,
343
)344
345
document_iterator = SDocDocumentIterator(self)
346
347
for node, _ in document_iterator.all_content(
348
print_fragments=False,
349
):350
if not isinstance(node, SDocNode):
351
continue352
353
node_dict = {}
354
355
node_dict["MID"] = node.reserved_mid.get_string_value()
356
map_nodes_by_mid[node.reserved_mid.get_string_value()] = node_dict
357
358
for (
359
field_name_,
360
field_values_,
361
) in node.ordered_fields_lookup.items():
362
requirement_field: SDocNodeField = field_values_[0]
363
requirement_field_value = requirement_field.get_text_value()
364
365
node_dict[field_name_] = requirement_field_value
366
367
tokens = set(tokenize(requirement_field_value))
368
for token in tokens:
369
if len(token) > 1:
370
document_index[token].add(
371
node.reserved_mid.get_string_value()
372
)373
374
for i in range(0, len(token)):
375
token_incremental = token[: i + 1]
376
document_index[token_incremental].add(
377
node.reserved_mid
378
)379
token_deincremental = token[i:]
380
document_index[token_deincremental].add(
381
node.reserved_mid
382
)383
384
self.search_index = SDocDocumentSearchIndex(
385
document_index, map_nodes_by_mid
386
)