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