plain-parser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain_parser/__init__.py +27 -0
- plain_parser/concept_utils.py +221 -0
- plain_parser/exceptions.py +39 -0
- plain_parser/liquid_nodes.py +109 -0
- plain_parser/loaders.py +101 -0
- plain_parser/plain_file.py +861 -0
- plain_parser/plain_spec.py +414 -0
- plain_parser-0.1.0.dist-info/METADATA +62 -0
- plain_parser-0.1.0.dist-info/RECORD +11 -0
- plain_parser-0.1.0.dist-info/WHEEL +4 -0
- plain_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
plain_parser/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Parser for ***plain specification files."""
|
|
2
|
+
|
|
3
|
+
from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec
|
|
4
|
+
from plain_parser.plain_file import (
|
|
5
|
+
PlainFileParseResult,
|
|
6
|
+
get_filename_from_module_name,
|
|
7
|
+
get_module_name_from_filename,
|
|
8
|
+
marshall_plain_source,
|
|
9
|
+
parse_plain_file,
|
|
10
|
+
parse_plain_source,
|
|
11
|
+
plain_file_parser,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"PlainFileParseResult",
|
|
16
|
+
"concept_utils",
|
|
17
|
+
"exceptions",
|
|
18
|
+
"get_filename_from_module_name",
|
|
19
|
+
"get_module_name_from_filename",
|
|
20
|
+
"loaders",
|
|
21
|
+
"marshall_plain_source",
|
|
22
|
+
"parse_plain_file",
|
|
23
|
+
"parse_plain_source",
|
|
24
|
+
"plain_file",
|
|
25
|
+
"plain_file_parser",
|
|
26
|
+
"plain_spec",
|
|
27
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import mistletoe.block_token
|
|
6
|
+
import networkx as nx
|
|
7
|
+
|
|
8
|
+
from plain_parser import plain_spec
|
|
9
|
+
from plain_parser.exceptions import PlainSyntaxError
|
|
10
|
+
|
|
11
|
+
# These are reserved concept names that are pre-defined and must not be redefined in the definitions section.
|
|
12
|
+
DEFAULT_CONCEPTS = {
|
|
13
|
+
":ConformanceTests:",
|
|
14
|
+
":UnitTests:",
|
|
15
|
+
":AcceptanceTest:",
|
|
16
|
+
":AcceptanceTests:",
|
|
17
|
+
":Implementation:",
|
|
18
|
+
":plainDefinitions:",
|
|
19
|
+
":plainImplementationReqs:",
|
|
20
|
+
":plainFunctionality:",
|
|
21
|
+
":plainTestReqs:",
|
|
22
|
+
":plainImplementationCode:",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_concepts_from_definition(text: str) -> tuple[list[str], list[str]]:
|
|
27
|
+
pattern = r"-\s(:[^\:]+:)(?:,\s*:[^\:]+:)*"
|
|
28
|
+
match = re.search(pattern, text)
|
|
29
|
+
|
|
30
|
+
if not match:
|
|
31
|
+
return list(), [
|
|
32
|
+
f"Syntax error: Invalid definition specification text: {text}. Should start with `- :<concept>:` (where <concept> is any string that does not contain `:`)."
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
concept_candidates = re.findall(r":[^\:]+:", match.group())
|
|
36
|
+
valid_concepts = list[str]()
|
|
37
|
+
concept_re = r":[+\-\.0-9A-Z_a-z]+:"
|
|
38
|
+
errors = list[str]()
|
|
39
|
+
for candidate in concept_candidates:
|
|
40
|
+
if re.match(concept_re, candidate):
|
|
41
|
+
valid_concepts.append(candidate)
|
|
42
|
+
else:
|
|
43
|
+
errors.append(
|
|
44
|
+
f"Syntax error: Invalid concept: {candidate}. Should contain only letters, numbers, hyphens and dots."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return valid_concepts, errors
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_concepts_from_spec_text(text: str) -> str:
|
|
51
|
+
return re.findall(r":[+\-\.0-9A-Z_a-z]+:", text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_concepts(plain_source) -> tuple[set[str], list[str]]:
|
|
55
|
+
if plain_spec.DEFINITIONS not in plain_source:
|
|
56
|
+
return set(), list()
|
|
57
|
+
|
|
58
|
+
concepts = list[str]()
|
|
59
|
+
errors = list[str]()
|
|
60
|
+
for definition in plain_source[plain_spec.DEFINITIONS]:
|
|
61
|
+
spec_text = definition["markdown"]
|
|
62
|
+
new_concepts, new_errors = extract_concepts_from_definition(spec_text)
|
|
63
|
+
errors.extend(new_errors)
|
|
64
|
+
concepts.extend(new_concepts)
|
|
65
|
+
|
|
66
|
+
return concepts, errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_concepts_in_spec(spec: dict, concepts: set[str], spec_group: str) -> list[str]:
|
|
70
|
+
spec_text = spec["markdown"]
|
|
71
|
+
used_concepts = extract_concepts_from_spec_text(spec_text)
|
|
72
|
+
errors = []
|
|
73
|
+
for used_concept in used_concepts:
|
|
74
|
+
if used_concept not in concepts and used_concept not in DEFAULT_CONCEPTS:
|
|
75
|
+
errors.append(
|
|
76
|
+
f"Syntax error: Concept {used_concept} is not defined in the definitions. {spec_group}: {spec_text}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return errors
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_concepts_in_spec_group(spec_group, plain_source, concepts: set[str]) -> list[str]:
|
|
83
|
+
if spec_group not in plain_source:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
errors = []
|
|
87
|
+
for spec in plain_source[spec_group]:
|
|
88
|
+
errors.extend(validate_concepts_in_spec(spec, concepts, spec_group))
|
|
89
|
+
|
|
90
|
+
return errors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_concepts(marshalled_plain_source) -> list[str]:
|
|
94
|
+
errors = list[str]()
|
|
95
|
+
concepts = deepcopy(DEFAULT_CONCEPTS)
|
|
96
|
+
tree_based_concepts = deepcopy(DEFAULT_CONCEPTS)
|
|
97
|
+
|
|
98
|
+
new_concepts, new_errors = collect_concepts(marshalled_plain_source)
|
|
99
|
+
errors.extend(new_errors)
|
|
100
|
+
|
|
101
|
+
unique_new_concepts = set(new_concepts)
|
|
102
|
+
intersection = unique_new_concepts.intersection(concepts)
|
|
103
|
+
|
|
104
|
+
if len(unique_new_concepts) < len(new_concepts) or (len(intersection) > 0):
|
|
105
|
+
misused_concepts = {concept for concept in new_concepts if new_concepts.count(concept) > 1}
|
|
106
|
+
misused_concepts.update(intersection)
|
|
107
|
+
errors.append(f"Syntax error: Concepts were defined multiple times: {', '.join(misused_concepts)} .")
|
|
108
|
+
|
|
109
|
+
concepts.update(unique_new_concepts)
|
|
110
|
+
tmp_tree_based_concepts = tree_based_concepts.union(unique_new_concepts)
|
|
111
|
+
|
|
112
|
+
for spec_group in [
|
|
113
|
+
plain_spec.NON_FUNCTIONAL_REQUIREMENTS,
|
|
114
|
+
plain_spec.TEST_REQUIREMENTS,
|
|
115
|
+
plain_spec.FUNCTIONAL_REQUIREMENTS,
|
|
116
|
+
plain_spec.DEFINITIONS,
|
|
117
|
+
]:
|
|
118
|
+
errors.extend(validate_concepts_in_spec_group(spec_group, marshalled_plain_source, tmp_tree_based_concepts))
|
|
119
|
+
|
|
120
|
+
if plain_spec.FUNCTIONAL_REQUIREMENTS in marshalled_plain_source:
|
|
121
|
+
for func_spec in marshalled_plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]:
|
|
122
|
+
if "acceptance_tests" not in func_spec:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
acceptance_tests = func_spec["acceptance_tests"]
|
|
126
|
+
for spec in acceptance_tests:
|
|
127
|
+
errors.extend(validate_concepts_in_spec(spec, tmp_tree_based_concepts, plain_spec.ACCEPTANCE_TESTS))
|
|
128
|
+
|
|
129
|
+
return errors
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def find_concept_definitions_in_plain_source(
|
|
133
|
+
concept: str,
|
|
134
|
+
plain_source: dict,
|
|
135
|
+
renderer,
|
|
136
|
+
) -> list[mistletoe.block_token.token]:
|
|
137
|
+
definitions = list[mistletoe.block_token.token]()
|
|
138
|
+
if plain_source[plain_spec.DEFINITIONS] is None:
|
|
139
|
+
return definitions
|
|
140
|
+
|
|
141
|
+
for definition in plain_source[plain_spec.DEFINITIONS].children:
|
|
142
|
+
rendered_definition = renderer.render(definition)
|
|
143
|
+
defined_concepts, _ = extract_concepts_from_definition(rendered_definition)
|
|
144
|
+
if concept in defined_concepts:
|
|
145
|
+
definitions.append(definition)
|
|
146
|
+
used_concepts = [
|
|
147
|
+
used_concept
|
|
148
|
+
for used_concept in extract_concepts_from_spec_text(rendered_definition)
|
|
149
|
+
if used_concept != concept and used_concept not in DEFAULT_CONCEPTS
|
|
150
|
+
]
|
|
151
|
+
for used_concept in used_concepts:
|
|
152
|
+
definitions.extend(find_concept_definitions_in_plain_source(used_concept, plain_source, renderer))
|
|
153
|
+
|
|
154
|
+
return definitions
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_adjacency_list(definitions: Optional[list[dict]]) -> tuple[dict, dict]:
|
|
158
|
+
adjacency_list = dict[str, list[str]]()
|
|
159
|
+
concept_definitions = dict[str, dict]()
|
|
160
|
+
if definitions is None or len(definitions) == 0:
|
|
161
|
+
return adjacency_list, concept_definitions
|
|
162
|
+
|
|
163
|
+
for definition in definitions:
|
|
164
|
+
def_text = definition["markdown"]
|
|
165
|
+
new_concepts, _ = extract_concepts_from_definition(def_text)
|
|
166
|
+
used_concepts = [
|
|
167
|
+
used_concept
|
|
168
|
+
for used_concept in extract_concepts_from_spec_text(def_text)
|
|
169
|
+
if used_concept not in new_concepts and used_concept not in DEFAULT_CONCEPTS
|
|
170
|
+
]
|
|
171
|
+
used_concepts = set(used_concepts)
|
|
172
|
+
|
|
173
|
+
for new_concept in new_concepts:
|
|
174
|
+
if new_concept not in adjacency_list:
|
|
175
|
+
adjacency_list[new_concept] = []
|
|
176
|
+
|
|
177
|
+
for used_concept in used_concepts:
|
|
178
|
+
if used_concept not in adjacency_list:
|
|
179
|
+
adjacency_list[used_concept] = []
|
|
180
|
+
|
|
181
|
+
adjacency_list[used_concept].extend(new_concepts)
|
|
182
|
+
|
|
183
|
+
for new_concept in new_concepts:
|
|
184
|
+
concept_definitions[new_concept] = definition
|
|
185
|
+
|
|
186
|
+
return adjacency_list, concept_definitions
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sort_definitions(definitions: list[dict]) -> list[dict]:
|
|
190
|
+
if len(definitions) <= 1:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
adjacency_list, concept_definitions = build_adjacency_list(definitions)
|
|
194
|
+
|
|
195
|
+
concept_graph = nx.DiGraph()
|
|
196
|
+
for node, neighbors in adjacency_list.items():
|
|
197
|
+
for neighbour in neighbors:
|
|
198
|
+
concept_graph.add_edge(node, neighbour)
|
|
199
|
+
if not neighbors:
|
|
200
|
+
concept_graph.add_node(node)
|
|
201
|
+
|
|
202
|
+
if not nx.is_directed_acyclic_graph(concept_graph):
|
|
203
|
+
msg = "Found cycles in the concept graph. Cycles are not allowed."
|
|
204
|
+
all_cycles = list(nx.simple_cycles(concept_graph))
|
|
205
|
+
|
|
206
|
+
for cycle in all_cycles:
|
|
207
|
+
cyclic_definitions = []
|
|
208
|
+
cyclic_definitions.append(concept_definitions[cycle[0]]["markdown"])
|
|
209
|
+
cyclic_definitions.append(concept_definitions[cycle[1]]["markdown"])
|
|
210
|
+
msg += "Cyclic definitons:\n"
|
|
211
|
+
msg += "\n".join(cyclic_definitions)
|
|
212
|
+
msg += "\n"
|
|
213
|
+
|
|
214
|
+
raise PlainSyntaxError(f"Plain syntax error: {msg}")
|
|
215
|
+
|
|
216
|
+
order = list(nx.topological_sort(concept_graph))
|
|
217
|
+
if len(order) > 0:
|
|
218
|
+
definition_to_order_idx = {
|
|
219
|
+
plain_spec.hash_text(str(concept_definitions[concept])): idx for idx, concept in enumerate(order)
|
|
220
|
+
}
|
|
221
|
+
definitions.sort(key=lambda definition: definition_to_order_idx[plain_spec.hash_text(str(definition))])
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class PlainSyntaxError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class UnsupportedBase64Content(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnsupportedResourceType(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidFridArgument(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidLiquidVariableName(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModuleDoesNotExistError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MissingFunctionalitiesError(Exception):
|
|
26
|
+
"""Raised when a module to be rendered has no functionalities specified at all."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ImportedModuleWithFunctionalitiesError(Exception):
|
|
32
|
+
"""Raised when a module brought in via ``import`` contains functional specs.
|
|
33
|
+
|
|
34
|
+
This is a usage error, not a syntax error: the module is syntactically valid
|
|
35
|
+
and would be fine as a render target, but functionalities are not allowed in
|
|
36
|
+
the import role.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
pass
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Mapping, Sequence, TextIO
|
|
3
|
+
|
|
4
|
+
from liquid2 import Environment, RenderContext, Template, TemplateNotFoundError
|
|
5
|
+
from liquid2.builtin import IncludeTag
|
|
6
|
+
from liquid2.builtin.tags.include_tag import IncludeNode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Plain2CodeIncludeNode(IncludeNode):
|
|
10
|
+
def render_to_output(self, context: RenderContext, buffer: TextIO) -> int:
|
|
11
|
+
"""Render the node to the output buffer."""
|
|
12
|
+
name = self.name.evaluate(context)
|
|
13
|
+
whitespaces = 0
|
|
14
|
+
is_comment = False
|
|
15
|
+
i = self.token.start
|
|
16
|
+
while self.token.source[i] != "\n" and i >= 0:
|
|
17
|
+
if self.token.source[i] == " ":
|
|
18
|
+
whitespaces += 1
|
|
19
|
+
elif self.token.source[i] == ">":
|
|
20
|
+
is_comment = True
|
|
21
|
+
break
|
|
22
|
+
else:
|
|
23
|
+
whitespaces = 0
|
|
24
|
+
i -= 1
|
|
25
|
+
|
|
26
|
+
if is_comment:
|
|
27
|
+
return buffer.write(str(self))
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
template = context.env.get_template(str(name), context=context, tag=self.tag, whitespaces=whitespaces)
|
|
31
|
+
except TemplateNotFoundError as err:
|
|
32
|
+
wrapped = TemplateNotFoundError(f"Template not found: {err}")
|
|
33
|
+
wrapped.token = self.name.token
|
|
34
|
+
wrapped.template_name = context.template.full_name()
|
|
35
|
+
raise wrapped from err
|
|
36
|
+
|
|
37
|
+
namespace: dict[str, object] = dict(arg.evaluate(context) for arg in self.args)
|
|
38
|
+
|
|
39
|
+
character_count = 0
|
|
40
|
+
|
|
41
|
+
with context.extend(namespace, template=template):
|
|
42
|
+
if self.var:
|
|
43
|
+
val = self.var.evaluate(context)
|
|
44
|
+
key = self.alias or template.name.split(".")[0]
|
|
45
|
+
|
|
46
|
+
if isinstance(val, Sequence) and not isinstance(val, str):
|
|
47
|
+
context.raise_for_loop_limit(len(val))
|
|
48
|
+
for itm in val:
|
|
49
|
+
namespace[key] = itm
|
|
50
|
+
character_count += template.render_with_context(context, buffer, partial=True)
|
|
51
|
+
else:
|
|
52
|
+
namespace[key] = val
|
|
53
|
+
character_count = template.render_with_context(context, buffer, partial=True)
|
|
54
|
+
else:
|
|
55
|
+
|
|
56
|
+
character_count = template.render_with_context(context, buffer, partial=True)
|
|
57
|
+
|
|
58
|
+
return character_count
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Plain2CodeIncludeTag(IncludeTag):
|
|
62
|
+
node_class = Plain2CodeIncludeNode
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Plain2CodeLoaderMixin:
|
|
66
|
+
def __init__(self, *args, **kwargs):
|
|
67
|
+
if not hasattr(self, "get_source"):
|
|
68
|
+
raise NotImplementedError("Class must implement get_source")
|
|
69
|
+
super().__init__(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
def load(
|
|
72
|
+
self,
|
|
73
|
+
env: Environment,
|
|
74
|
+
name: str,
|
|
75
|
+
*,
|
|
76
|
+
globals: Mapping[str, object] | None = None,
|
|
77
|
+
context: RenderContext | None = None,
|
|
78
|
+
**kwargs: object,
|
|
79
|
+
) -> Template:
|
|
80
|
+
"""
|
|
81
|
+
Find and parse template source code.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
env: The `Environment` attempting to load the template source text.
|
|
85
|
+
name: A name or identifier for a template's source text.
|
|
86
|
+
globals: A mapping of render context variables attached to the
|
|
87
|
+
resulting template.
|
|
88
|
+
context: An optional render context that can be used to narrow the template
|
|
89
|
+
source search space.
|
|
90
|
+
kwargs: Arbitrary arguments that can be used to narrow the template source
|
|
91
|
+
search space.
|
|
92
|
+
"""
|
|
93
|
+
source, full_name, uptodate, matter = self.get_source(env, name, context=context, **kwargs)
|
|
94
|
+
whitespaces = kwargs.get("whitespaces", 0)
|
|
95
|
+
assert isinstance(whitespaces, int)
|
|
96
|
+
source = source.rstrip().replace("\n", "\n" + " " * whitespaces)
|
|
97
|
+
|
|
98
|
+
path = Path(full_name)
|
|
99
|
+
|
|
100
|
+
template = env.from_string(
|
|
101
|
+
source,
|
|
102
|
+
name=path.name,
|
|
103
|
+
path=path,
|
|
104
|
+
globals=globals,
|
|
105
|
+
overlay_data=matter,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
template.uptodate = uptodate
|
|
109
|
+
return template
|
plain_parser/loaders.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from liquid2 import Environment, FileSystemLoader, StrictUndefined
|
|
6
|
+
from liquid2.exceptions import UndefinedError
|
|
7
|
+
|
|
8
|
+
from plain_parser import plain_spec
|
|
9
|
+
from plain_parser.exceptions import UnsupportedBase64Content, UnsupportedResourceType
|
|
10
|
+
from plain_parser.liquid_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
|
|
11
|
+
|
|
12
|
+
MAX_BASE64_BLOB_LENGTH = 8192
|
|
13
|
+
|
|
14
|
+
# Matches a long contiguous base64 / base64url run, optionally preceded by a data: URI header.
|
|
15
|
+
_BASE64_BLOB_PATTERN = re.compile(
|
|
16
|
+
r"(?:data:[\w.+-]+/[\w.+-]+;base64,)?[A-Za-z0-9+/_-]{%d,}={0,2}" % MAX_BASE64_BLOB_LENGTH
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_large_base64_blob(text: str) -> Optional[str]:
|
|
21
|
+
"""Return the first contiguous base64 blob at or above the threshold, or None."""
|
|
22
|
+
match = _BASE64_BLOB_PATTERN.search(text)
|
|
23
|
+
return match.group(0) if match else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def open_from(dirs, file_name):
|
|
27
|
+
for dir in dirs:
|
|
28
|
+
full_file_name = os.path.join(dir, file_name)
|
|
29
|
+
if not os.path.isfile(full_file_name):
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
with open(full_file_name, "rb") as f:
|
|
33
|
+
content = f.read()
|
|
34
|
+
return content.decode("utf-8")
|
|
35
|
+
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_linked_resources(template_dirs: list[str], resources_list, module_name: str):
|
|
40
|
+
linked_resources = {}
|
|
41
|
+
|
|
42
|
+
for resource in resources_list:
|
|
43
|
+
file_name = resource["target"]
|
|
44
|
+
if file_name in linked_resources:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
content = open_from(template_dirs, file_name)
|
|
49
|
+
except UnicodeDecodeError:
|
|
50
|
+
raise UnsupportedResourceType(
|
|
51
|
+
f"Referenced resource '{file_name}' in module '{module_name}' is a binary file. "
|
|
52
|
+
f"Only text files (e.g. .md, .txt, .json, .yaml) can be referenced from a .plain file."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if content is None:
|
|
56
|
+
raise FileNotFoundError(f"Resource file '{file_name}' referenced in module '{module_name}' not found.")
|
|
57
|
+
|
|
58
|
+
blob = find_large_base64_blob(content)
|
|
59
|
+
if blob is not None:
|
|
60
|
+
raise UnsupportedBase64Content(
|
|
61
|
+
f"Referenced resource '{file_name}' in module '{module_name}' contains a large "
|
|
62
|
+
f"base64-encoded blob ({len(blob)} characters), such as an embedded image. Inline "
|
|
63
|
+
"base64 data is not supported. Remove the data from the resource. "
|
|
64
|
+
"If the data should be used by the end software, "
|
|
65
|
+
"save the data to a separate file and include the file path in the specification without it being a reference file."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
linked_resources[file_name] = content
|
|
69
|
+
|
|
70
|
+
return linked_resources
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TrackingFileSystemLoader(Plain2CodeLoaderMixin, FileSystemLoader):
|
|
74
|
+
def __init__(self, *args, **kwargs):
|
|
75
|
+
super().__init__(*args, **kwargs)
|
|
76
|
+
self.loaded_templates = {}
|
|
77
|
+
|
|
78
|
+
def get_source(self, env, template_name, **kwargs):
|
|
79
|
+
source = super().get_source(env, template_name, **kwargs)
|
|
80
|
+
self.loaded_templates[template_name] = source.source
|
|
81
|
+
return source
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_loaded_templates(source_path, plain_source):
|
|
85
|
+
# Render the plain source with Liquid templating engine
|
|
86
|
+
# to identify the templates that are being loaded
|
|
87
|
+
|
|
88
|
+
liquid_loader = TrackingFileSystemLoader(source_path)
|
|
89
|
+
liquid_env = Environment(loader=liquid_loader, undefined=StrictUndefined)
|
|
90
|
+
liquid_env.tags["include"] = Plain2CodeIncludeTag(liquid_env)
|
|
91
|
+
|
|
92
|
+
liquid_env.filters["code_variable"] = plain_spec.code_variable_liquid_filter
|
|
93
|
+
liquid_env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter
|
|
94
|
+
|
|
95
|
+
plain_source_template = liquid_env.from_string(plain_source)
|
|
96
|
+
try:
|
|
97
|
+
plain_source = plain_source_template.render()
|
|
98
|
+
except UndefinedError as e:
|
|
99
|
+
raise Exception(f"Undefined liquid variable: {str(e)}")
|
|
100
|
+
|
|
101
|
+
return plain_source, liquid_loader.loaded_templates
|