StrictDoc Documentation
strictdoc/core/format.py
Source file coverage
Path:
strictdoc/core/format.py
Lines:
201
Non-empty lines:
173
Non-empty lines covered with requirements:
173 / 173 (100.0%)
Functions:
22
Functions covered by requirements:
22 / 22 (100.0%)
1
"""
2
The Format abstraction: top-level abstraction for all formats that
3
StrictDoc can export to and/or import from.
4
 
5
@relation(SDOC-SRS-119, scope=file)
6
"""
7
 
8
import argparse
9
from abc import ABC, abstractmethod
10
from dataclasses import dataclass
11
from typing import TYPE_CHECKING, List, Optional, Tuple
12
 
13
from strictdoc.helpers.string import create_safe_document_file_name
14
 
15
if TYPE_CHECKING:
16
    from strictdoc.backend.sdoc.models.document import SDocDocument
17
    from strictdoc.backend.sdoc.models.document_grammar import DocumentGrammar
18
    from strictdoc.commands.convert_config import ConvertCommandConfig
19
    from strictdoc.core.file_system.file_tree import File
20
    from strictdoc.core.project_config import ProjectConfig
21
    from strictdoc.core.traceability_index import TraceabilityIndex
22
    from strictdoc.export.html.document_type import DocumentType
23
    from strictdoc.export.html.html_templates import HTMLTemplates
24
    from strictdoc.helpers.parallelizer import Parallelizer
25
 
26
 
27
@dataclass
28
class ExportContext:
29
    """
30
    Shared, per-export() call state that Format.export_complete_tree()
31
    implementations pull from. html_templates/bundle_* fields are only
32
    populated when an HTML-based format (html, html2pdf) was requested, since
33
    building them is expensive and only those formats need them.
34
    """
35
 
36
    project_config: "ProjectConfig"
37
    traceability_index: "TraceabilityIndex"
38
    parallelizer: "Parallelizer"
39
    html_templates: Optional["HTMLTemplates"] = None
40
    bundle_traceability_index: Optional["TraceabilityIndex"] = None
41
    bundle_document: Optional["SDocDocument"] = None
42
 
43
 
44
class Format(ABC):
45
    @staticmethod
46
    @abstractmethod
47
    def handles() -> List[str]:
48
        """The CLI/config handle(s) this Format reacts to, e.g. ['html']."""
49
        raise NotImplementedError
50
 
51
    @staticmethod
52
    @abstractmethod
53
    def supported_extensions() -> List[str]:
54
        raise NotImplementedError
55
 
56
    @staticmethod
57
    @abstractmethod
58
    def supports_import() -> bool:
59
        raise NotImplementedError
60
 
61
    @staticmethod
62
    @abstractmethod
63
    def supports_export() -> bool:
64
        raise NotImplementedError
65
 
66
    @staticmethod
67
    @abstractmethod
68
    def supports_read() -> bool:
69
        raise NotImplementedError
70
 
71
    @staticmethod
72
    def read_extensions() -> List[str]:
73
        """
74
        Extensions this Format auto-discovers/reads as native DocumentFinder
75
        tree-building input. Distinct from supported_extensions(), since not
76
        every extension a Format is generally associated with is also one it
77
        natively reads today (e.g. ReqIFFormat reads ".reqif" but not
78
        ".reqifz", even though supported_extensions() lists both).
79
        """
80
        return []
81
 
82
    def read_from_file(
83
        self, doc_file: "File", project_config: "ProjectConfig"
84
    ) -> "SDocDocument":
85
        raise NotImplementedError(
86
            f"{self.__class__.__name__} does not support reading."
87
        )
88
 
89
    @staticmethod
90
    @abstractmethod
91
    def supports_grammar() -> bool:
92
        raise NotImplementedError
93
 
94
    @staticmethod
95
    def grammar_extensions() -> List[str]:
96
        return []
97
 
98
    def read_grammar(
99
        self, doc_file: "File", project_config: "ProjectConfig"
100
    ) -> "DocumentGrammar":
101
        raise NotImplementedError(
102
            f"{self.__class__.__name__} does not support reading a grammar."
103
        )
104
 
105
    @abstractmethod
106
    def export_complete_tree(self, context: ExportContext, handle: str) -> None:
107
        raise NotImplementedError
108
 
109
    def export_single_document(
110
        self,
111
        context: ExportContext,
112
        document: "SDocDocument",
113
        specific_documents: Optional[Tuple["DocumentType", ...]] = None,
114
    ) -> None:
115
        raise NotImplementedError(
116
            f"{self.__class__.__name__} does not support exporting a "
117
            f"single document."
118
        )
119
 
120
    @classmethod
121
    def handle_for_extension(cls, extension: str) -> Optional[str]:
122
        """
123
        Map a file extension to one of this Format's handles(), for
124
        `convert --input-format` auto-discovery. Pairs handles()/
125
        supported_extensions() positionally when both lists are the same
126
        length (e.g. ReqIFFormat's ["reqif-sdoc", "reqifz-sdoc"] <->
127
        [".reqif", ".reqifz"]); otherwise falls back to the first handle.
128
        Returns None if this Format has no CLI handle at all (e.g.
129
        JUnitXMLFormat) or doesn't support the given extension.
130
        """
131
        handles = cls.handles()
132
        if not handles:
133
            return None
134
        extensions = cls.supported_extensions()
135
        if extension not in extensions:
136
            return None
137
        if len(handles) == len(extensions):
138
            return handles[extensions.index(extension)]
139
        return handles[0]
140
 
141
    @staticmethod
142
    def supports_convert_output() -> bool:
143
        """Whether `convert --output-format` may target this Format."""
144
        return False
145
 
146
    def write_converted_document(
147
        self,
148
        document: "SDocDocument",
149
        output_dir: str,
150
        filename_stem: str,
151
        project_config: "ProjectConfig",
152
    ) -> None:
153
        raise NotImplementedError(
154
            f"{self.__class__.__name__} does not support being a convert "
155
            f"output format."
156
        )
157
 
158
    def import_output_filename_stem(
159
        self,
160
        document: "SDocDocument",
161
        convert_config: "ConvertCommandConfig",  # noqa: ARG002
162
    ) -> str:
163
        return create_safe_document_file_name(document.reserved_title)
164
 
165
    @classmethod
166
    def add_import_arguments(cls, parser: argparse.ArgumentParser) -> None:  # noqa: ARG003
167
        """
168
        Override to contribute this Format's own CLI flags to `convert`
169
        (e.g. ExcelFormat adds --excel-parser, ReqIFFormat adds
170
        --reqif-profile/--reqif-enable-mid/--reqif-import-markup). A custom
171
        Format registered via the project config can add its own flags here
172
        without touching ConvertCommand/ConvertCommandConfig at all -- the
173
        only thing shared across formats is input_path/output_path/
174
        --input-format/--output-format/--config. Default: no extra flags.
175
        """
176
        return
177
 
178
    @classmethod
179
    def build_import_options(cls, args: argparse.Namespace) -> object:  # noqa: ARG003
180
        """
181
        Pluck this Format's own fields off the parsed argparse.Namespace
182
        (populated by this same Format's add_import_arguments()) into a
183
        type-safe, format-specific options object, e.g. a @dataclass. The
184
        return type is `object` here since ConvertAction dispatches to an
185
        arbitrary Format by handle and can't know the concrete type -- but
186
        each override should return its own concrete dataclass, and its own
187
        import_file() should declare that same concrete type as its
188
        parameter, so the plucking + consuming stays type-checked within
189
        that Format's own module.
190
        """
191
        raise NotImplementedError(f"{cls.__name__} does not support import.")
192
 
193
    def import_file(self, *args: object, **kwargs: object) -> object:
194
        raise NotImplementedError(
195
            f"{self.__class__.__name__} does not support import."
196
        )
197
 
198
    def import_folder(self, *args: object, **kwargs: object) -> object:
199
        raise NotImplementedError(
200
            f"{self.__class__.__name__} does not support import."
201
        )