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
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable, Sequence, cast
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import frontmatter
|
|
9
|
+
import mistletoe
|
|
10
|
+
import mistletoe.block_token
|
|
11
|
+
from liquid2 import DictLoader, Environment
|
|
12
|
+
from mistletoe.block_token import List, Paragraph, Quote
|
|
13
|
+
from mistletoe.markdown_renderer import Fragment, MarkdownRenderer
|
|
14
|
+
from mistletoe.span_token import Emphasis, Link, RawText, SpanToken, Strong
|
|
15
|
+
from mistletoe.token import Token
|
|
16
|
+
from mistletoe.utils import traverse
|
|
17
|
+
|
|
18
|
+
from plain_parser import concept_utils, loaders, plain_spec
|
|
19
|
+
from plain_parser.exceptions import (
|
|
20
|
+
ImportedModuleWithFunctionalitiesError,
|
|
21
|
+
MissingFunctionalitiesError,
|
|
22
|
+
ModuleDoesNotExistError,
|
|
23
|
+
PlainSyntaxError,
|
|
24
|
+
UnsupportedBase64Content,
|
|
25
|
+
)
|
|
26
|
+
from plain_parser.liquid_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
|
|
27
|
+
from plain_parser.loaders import find_large_base64_blob
|
|
28
|
+
|
|
29
|
+
RESOURCE_MARKER = "[resource]"
|
|
30
|
+
|
|
31
|
+
IMPORT_DIRECTIVE = "import"
|
|
32
|
+
REQUIRES_DIRECTIVE = "requires"
|
|
33
|
+
REQUIRED_CONCEPTS_DIRECTIVE = "required_concepts"
|
|
34
|
+
EXPORTED_CONCEPTS_DIRECTIVE = "exported_concepts"
|
|
35
|
+
|
|
36
|
+
PLAIN_SOURCE_FILE_EXTENSION = ".plain"
|
|
37
|
+
|
|
38
|
+
PLAIN_SOURCE_TEMPLATE = {
|
|
39
|
+
plain_spec.DEFINITIONS: None,
|
|
40
|
+
plain_spec.NON_FUNCTIONAL_REQUIREMENTS: None,
|
|
41
|
+
plain_spec.TEST_REQUIREMENTS: None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PlainFileParseResult:
|
|
47
|
+
plain_source: dict
|
|
48
|
+
plain_source_obj: frontmatter.Post
|
|
49
|
+
required_modules: list[str]
|
|
50
|
+
required_concepts: list[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PlainRenderer(MarkdownRenderer):
|
|
54
|
+
def render_link(self, token: Link) -> Iterable[Fragment]:
|
|
55
|
+
yield from self.embed_span(
|
|
56
|
+
Fragment(f"`{RESOURCE_MARKER}"),
|
|
57
|
+
cast("Iterable[SpanToken]", token.children),
|
|
58
|
+
Fragment("`"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_filename_from_module_name(module_name: str) -> str:
|
|
63
|
+
return f"{module_name}{PLAIN_SOURCE_FILE_EXTENSION}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_module_name_from_filename(filename: str) -> str:
|
|
67
|
+
return filename.replace(PLAIN_SOURCE_FILE_EXTENSION, "")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def remove_quotes(token):
|
|
71
|
+
# If the token has no children, there's nothing to remove.
|
|
72
|
+
if not hasattr(token, "children") or token.children is None:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Convert children to a list for easy filtering.
|
|
76
|
+
children_list = list(token.children)
|
|
77
|
+
|
|
78
|
+
# Build a filtered list that excludes Quote tokens.
|
|
79
|
+
new_children = []
|
|
80
|
+
for child in children_list:
|
|
81
|
+
if not isinstance(child, Quote):
|
|
82
|
+
# Recursively remove quotes in any nested children.
|
|
83
|
+
remove_quotes(child)
|
|
84
|
+
new_children.append(child)
|
|
85
|
+
|
|
86
|
+
# Convert the filtered list back to a tuple (or a list, if you prefer).
|
|
87
|
+
# Mistletoe tokens often expect a tuple, so tuple is safest.
|
|
88
|
+
token.children = tuple(new_children)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_section_for_linked_resources(section):
|
|
92
|
+
linked_resources = []
|
|
93
|
+
for link in traverse(section, klass=Link):
|
|
94
|
+
parsed_url = urlparse(link.node.target)
|
|
95
|
+
if parsed_url.scheme != "" or os.path.isabs(link.node.target):
|
|
96
|
+
raise PlainSyntaxError(
|
|
97
|
+
f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not os.path.exists(link.node.target):
|
|
101
|
+
raise PlainSyntaxError(f"Plain syntax error: Link {link.node.target} does not exist.")
|
|
102
|
+
|
|
103
|
+
if not os.path.isfile(link.node.target):
|
|
104
|
+
raise PlainSyntaxError(f"Plain syntax error: Link {link.node.target} must be a file.")
|
|
105
|
+
|
|
106
|
+
if len(link.node.children) != 1:
|
|
107
|
+
raise PlainSyntaxError(f"Plain syntax error: Link must have text specified (link: {link.node.target}).")
|
|
108
|
+
|
|
109
|
+
linked_resource_file_extension = os.path.splitext(os.path.basename(link.node.target))[1]
|
|
110
|
+
if linked_resource_file_extension == ".plain":
|
|
111
|
+
raise PlainSyntaxError(
|
|
112
|
+
f"Referenced resource '{link.node.target}' is a .plain file. "
|
|
113
|
+
f"Referencing .plain files through Linked Resources is not supported. "
|
|
114
|
+
"Please use the import section to include definitions or implementation/test requirements from another module."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
linked_resources.append({"text": link.node.children[0].content, "target": link.node.target})
|
|
118
|
+
|
|
119
|
+
if linked_resources:
|
|
120
|
+
section.linked_resources = linked_resources
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_for_linked_resources(plain_source):
|
|
124
|
+
for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
|
|
125
|
+
if specification_heading in plain_source and hasattr(plain_source[specification_heading], "children"):
|
|
126
|
+
for requirement in plain_source[specification_heading].children:
|
|
127
|
+
check_section_for_linked_resources(requirement)
|
|
128
|
+
|
|
129
|
+
if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS):
|
|
130
|
+
for acceptance_test in requirement.acceptance_tests:
|
|
131
|
+
check_section_for_linked_resources(acceptance_test)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def process_section_code_variables(section, code_variables):
|
|
135
|
+
if "markdown" not in section:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
code_variables_array = []
|
|
139
|
+
for key, value in code_variables.items():
|
|
140
|
+
if key not in section["markdown"]:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
variable_name = next(iter(value))
|
|
144
|
+
section["markdown"] = section["markdown"].replace(key, f"{{{{ {variable_name} }}}}")
|
|
145
|
+
|
|
146
|
+
code_variables_array.append({"name": variable_name, "value": value[variable_name]})
|
|
147
|
+
|
|
148
|
+
if code_variables_array:
|
|
149
|
+
section["code_variables"] = code_variables_array
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def process_code_variables(plain_source, code_variables):
|
|
153
|
+
for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
|
|
154
|
+
if specification_heading in plain_source:
|
|
155
|
+
for requirement in plain_source[specification_heading]:
|
|
156
|
+
process_section_code_variables(requirement, code_variables)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def has_functional_specs_section(plain_source) -> bool:
|
|
160
|
+
"""Whether the module declares a ***functional specs*** section (even if it is empty)."""
|
|
161
|
+
return plain_spec.FUNCTIONAL_REQUIREMENTS in plain_source
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def count_functionalities(plain_source) -> int:
|
|
165
|
+
"""Number of functionalities under the ***functional specs*** section (0 if absent or empty)."""
|
|
166
|
+
section = plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS)
|
|
167
|
+
return len(section.children) if section is not None else 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_functionalities_have_implementation_reqs(plain_source, module_name, has_requires=False) -> None:
|
|
171
|
+
"""Raise if the module has functionalities but no implementation reqs are specified.
|
|
172
|
+
|
|
173
|
+
``has_requires`` indicates whether the module depends on another module via ``requires``.
|
|
174
|
+
Unlike ``import``, ``requires`` does not inherit implementation reqs, so a hint is added to
|
|
175
|
+
steer users who expected inheritance.
|
|
176
|
+
"""
|
|
177
|
+
implementation_reqs = plain_source[plain_spec.NON_FUNCTIONAL_REQUIREMENTS]
|
|
178
|
+
has_implementation_reqs = (
|
|
179
|
+
implementation_reqs is not None
|
|
180
|
+
and hasattr(implementation_reqs, "children")
|
|
181
|
+
and len(implementation_reqs.children) > 0
|
|
182
|
+
)
|
|
183
|
+
if not has_implementation_reqs:
|
|
184
|
+
message = (
|
|
185
|
+
f"Plain syntax error: Module '{module_name}' defines functionalities but specifies no "
|
|
186
|
+
f"{plain_spec.NON_FUNCTIONAL_REQUIREMENTS}. At least one implementation req is required."
|
|
187
|
+
)
|
|
188
|
+
if has_requires:
|
|
189
|
+
message += (
|
|
190
|
+
" Implementation reqs are not inherited through 'requires' — "
|
|
191
|
+
"add them to this module, or 'import' a module that provides them."
|
|
192
|
+
)
|
|
193
|
+
raise PlainSyntaxError(message)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_acceptance_test_heading(token) -> tuple[bool, str | None]:
|
|
197
|
+
"""
|
|
198
|
+
Check if token is an 'Acceptance test:' heading.
|
|
199
|
+
|
|
200
|
+
This method is going to evaluate to True when:
|
|
201
|
+
|
|
202
|
+
- The token is a Paragraph
|
|
203
|
+
- The paragraph has 1 child
|
|
204
|
+
- The child is an Emphasis (*** ... *** marker)
|
|
205
|
+
- The Emphasis has 1 child (the raw text content)
|
|
206
|
+
- The child of the Emphasis is a Strong
|
|
207
|
+
- The Strong has 1 child
|
|
208
|
+
- The child of the Strong is a RawText
|
|
209
|
+
- The RawText content is equal to "Acceptance test:"
|
|
210
|
+
|
|
211
|
+
This is expected structure for the acceptance test heading.
|
|
212
|
+
"""
|
|
213
|
+
if not isinstance(token, Paragraph):
|
|
214
|
+
return False, None
|
|
215
|
+
|
|
216
|
+
# Check for the specific structure, descending one level at a time. mistletoe types
|
|
217
|
+
# ``children`` as ``Optional[Iterable[Token]]``, so cast each level to a concrete
|
|
218
|
+
# sequence before indexing/len.
|
|
219
|
+
children = cast("Sequence[Token]", token.children)
|
|
220
|
+
if len(children) != 1 or not isinstance(children[0], Emphasis):
|
|
221
|
+
return False, None
|
|
222
|
+
|
|
223
|
+
emphasis_children = cast("Sequence[Token]", children[0].children)
|
|
224
|
+
if len(emphasis_children) != 1 or not isinstance(emphasis_children[0], Strong):
|
|
225
|
+
return False, None
|
|
226
|
+
|
|
227
|
+
strong_children = cast("Sequence[Token]", emphasis_children[0].children)
|
|
228
|
+
if len(strong_children) != 1 or not isinstance(strong_children[0], RawText):
|
|
229
|
+
return False, None
|
|
230
|
+
|
|
231
|
+
# Check the actual text content
|
|
232
|
+
content = strong_children[0].content.strip()
|
|
233
|
+
if content == plain_spec.ACCEPTANCE_TEST_HEADING:
|
|
234
|
+
return True, None
|
|
235
|
+
problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}.`"
|
|
236
|
+
return False, problem
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _find_acceptance_test_heading(token):
|
|
240
|
+
"""
|
|
241
|
+
Recursively search a token tree for an `acceptance tests` heading.
|
|
242
|
+
|
|
243
|
+
Returns the heading token if one is found anywhere in the subtree, otherwise None.
|
|
244
|
+
Used to detect acceptance tests nested under sections other than functional specs.
|
|
245
|
+
"""
|
|
246
|
+
is_acceptance_test_heading, _ = _is_acceptance_test_heading(token)
|
|
247
|
+
if is_acceptance_test_heading:
|
|
248
|
+
return token
|
|
249
|
+
|
|
250
|
+
if getattr(token, "children", None):
|
|
251
|
+
for child in token.children:
|
|
252
|
+
found = _find_acceptance_test_heading(child)
|
|
253
|
+
if found is not None:
|
|
254
|
+
return found
|
|
255
|
+
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem):
|
|
260
|
+
"""
|
|
261
|
+
Process a single functionality to extract acceptance tests.
|
|
262
|
+
|
|
263
|
+
Expected functional_requirement properties:
|
|
264
|
+
- Is a list item
|
|
265
|
+
- If acceptance tests are specified, it has 3 children:
|
|
266
|
+
- List item element with functionality instructions/text
|
|
267
|
+
- Paragraph with `***Acceptance test:***` heading
|
|
268
|
+
- List of acceptance tests
|
|
269
|
+
- If acceptance tests are not specified, it has 1 child:
|
|
270
|
+
- List item element with functionality instructions/text
|
|
271
|
+
"""
|
|
272
|
+
new_children: list = []
|
|
273
|
+
functional_requirement_children = iter(functional_requirement.children or ())
|
|
274
|
+
acceptance_tests_found_already = False
|
|
275
|
+
|
|
276
|
+
for functional_requirement_child in functional_requirement_children:
|
|
277
|
+
is_acceptance_test_heading, acceptance_test_heading_problem = _is_acceptance_test_heading(
|
|
278
|
+
functional_requirement_child
|
|
279
|
+
)
|
|
280
|
+
if acceptance_test_heading_problem:
|
|
281
|
+
# Handle the case when the heading is not valid. This case includes cases such as:
|
|
282
|
+
# - Writing `acceptance test` instead of `acceptance tests` (or any other syntax diffs).
|
|
283
|
+
# - Instead of specifying `acceptance tests` below the functionality, creator of the plain file
|
|
284
|
+
# might have specified some other building block (e.g. `implementation reqs`)
|
|
285
|
+
raise PlainSyntaxError(f"Plain syntax error: {acceptance_test_heading_problem}")
|
|
286
|
+
|
|
287
|
+
if is_acceptance_test_heading:
|
|
288
|
+
if acceptance_tests_found_already:
|
|
289
|
+
# Handle edge case of duplicated ***acceptance tests*** heading
|
|
290
|
+
raise PlainSyntaxError(
|
|
291
|
+
f"Plain syntax error: Syntax error at line {functional_requirement_child.line_number}: Duplicate 'acceptance tests' heading found within the same functionality. Only one block of acceptance tests is allowed per functionality."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
# If there is an acceptance test heading, the next token should be a list, with children being list items
|
|
296
|
+
next_token = next(functional_requirement_children)
|
|
297
|
+
if isinstance(next_token, List):
|
|
298
|
+
# Found valid acceptance test list -> Assign it property acceptance_tests and append all list items to it
|
|
299
|
+
if not hasattr(functional_requirement, plain_spec.ACCEPTANCE_TESTS):
|
|
300
|
+
functional_requirement.acceptance_tests = []
|
|
301
|
+
for list_item in next_token.children:
|
|
302
|
+
functional_requirement.acceptance_tests.append(list_item)
|
|
303
|
+
acceptance_tests_found_already = True
|
|
304
|
+
else:
|
|
305
|
+
# Not followed by a list, keep both tokens
|
|
306
|
+
new_children.append(functional_requirement_child)
|
|
307
|
+
new_children.append(next_token)
|
|
308
|
+
except StopIteration:
|
|
309
|
+
# No next token, keep this one
|
|
310
|
+
new_children.append(functional_requirement_child)
|
|
311
|
+
else:
|
|
312
|
+
# Regular token, keep it
|
|
313
|
+
new_children.append(functional_requirement_child)
|
|
314
|
+
|
|
315
|
+
# Assign the children property to all the children of the functionality from previous, with exception
|
|
316
|
+
# of those we parsed as acceptance tests
|
|
317
|
+
container_type = cast(type, type(functional_requirement.children))
|
|
318
|
+
functional_requirement.children = container_type(new_children)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def process_acceptance_tests(plain_source):
|
|
322
|
+
# Early returns for cases without functionalities
|
|
323
|
+
if plain_spec.FUNCTIONAL_REQUIREMENTS not in plain_source:
|
|
324
|
+
return
|
|
325
|
+
frs = plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]
|
|
326
|
+
if not hasattr(frs, "children"):
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
# Process each functionality
|
|
330
|
+
for functional_requirement in frs.children:
|
|
331
|
+
if not hasattr(functional_requirement, "children"):
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
# Process each requirement to extract acceptance tests
|
|
335
|
+
_process_single_acceptance_test_requirement(functional_requirement)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_raw_text(token):
|
|
339
|
+
if isinstance(token, RawText):
|
|
340
|
+
yield token.content
|
|
341
|
+
elif hasattr(token, "children"):
|
|
342
|
+
if token.children is None:
|
|
343
|
+
return
|
|
344
|
+
for child in token.children:
|
|
345
|
+
yield from get_raw_text(child)
|
|
346
|
+
elif hasattr(token, "content"):
|
|
347
|
+
yield token.content
|
|
348
|
+
else:
|
|
349
|
+
raise Exception(f"Unknown token type: {type(token)}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def marshall_plain_source(input_plain_source):
|
|
353
|
+
plain_source = {}
|
|
354
|
+
with PlainRenderer() as renderer:
|
|
355
|
+
if "ID" in input_plain_source:
|
|
356
|
+
plain_source["ID"] = input_plain_source["ID"]
|
|
357
|
+
|
|
358
|
+
if "Heading" in input_plain_source:
|
|
359
|
+
plain_source["Heading"] = input_plain_source["Heading"]
|
|
360
|
+
|
|
361
|
+
for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
|
|
362
|
+
if specification_heading in input_plain_source and hasattr(
|
|
363
|
+
input_plain_source[specification_heading], "children"
|
|
364
|
+
):
|
|
365
|
+
list_of_requirements = []
|
|
366
|
+
for requirement in input_plain_source[specification_heading].children:
|
|
367
|
+
requirement_section = {"markdown": renderer.render(requirement).strip()}
|
|
368
|
+
if hasattr(requirement, "linked_resources"):
|
|
369
|
+
requirement_section["linked_resources"] = requirement.linked_resources
|
|
370
|
+
|
|
371
|
+
if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS):
|
|
372
|
+
requirement_section[plain_spec.ACCEPTANCE_TESTS] = []
|
|
373
|
+
for acceptance_test in requirement.acceptance_tests:
|
|
374
|
+
acceptance_test_section = {"markdown": renderer.render(acceptance_test).strip()}
|
|
375
|
+
if hasattr(acceptance_test, "linked_resources"):
|
|
376
|
+
acceptance_test_section["linked_resources"] = acceptance_test.linked_resources
|
|
377
|
+
requirement_section[plain_spec.ACCEPTANCE_TESTS].append(acceptance_test_section)
|
|
378
|
+
|
|
379
|
+
list_of_requirements.append(requirement_section)
|
|
380
|
+
|
|
381
|
+
plain_source[specification_heading] = list_of_requirements
|
|
382
|
+
|
|
383
|
+
return plain_source
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Plain2CodeDictLoader(Plain2CodeLoaderMixin, DictLoader):
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def render_plain_source(plain_source, loaded_templates, code_variables):
|
|
391
|
+
env = Environment(loader=Plain2CodeDictLoader(loaded_templates))
|
|
392
|
+
env.tags["include"] = Plain2CodeIncludeTag(env)
|
|
393
|
+
env.filters["code_variable"] = plain_spec.code_variable_liquid_filter
|
|
394
|
+
env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter
|
|
395
|
+
|
|
396
|
+
template = env.from_string(plain_source)
|
|
397
|
+
|
|
398
|
+
return template.render(code_variables=code_variables)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def process_imports(
|
|
402
|
+
plain_source: dict,
|
|
403
|
+
imports: list[str],
|
|
404
|
+
code_variables: dict,
|
|
405
|
+
template_dirs: list[str],
|
|
406
|
+
imported_modules: list[str],
|
|
407
|
+
modules_trace: list[str],
|
|
408
|
+
) -> list[str]:
|
|
409
|
+
required_concepts = list[str]()
|
|
410
|
+
for module_name in imports:
|
|
411
|
+
if module_name in modules_trace:
|
|
412
|
+
raise PlainSyntaxError(f"Plain syntax error: Circular import detected: {module_name}.")
|
|
413
|
+
|
|
414
|
+
if module_name in imported_modules:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
plain_file_parse_result = parse_plain_file(
|
|
418
|
+
module_name, code_variables, template_dirs, imported_modules, modules_trace
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if has_functional_specs_section(plain_file_parse_result.plain_source):
|
|
422
|
+
raise ImportedModuleWithFunctionalitiesError(
|
|
423
|
+
f"Module '{module_name}' is imported but contains functional specs. "
|
|
424
|
+
f"Imported modules may only provide definitions, implementation reqs, and test reqs — "
|
|
425
|
+
f"use 'requires' instead of 'import' if this module's functionalities should be built on."
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
for specification_heading in plain_file_parse_result.plain_source:
|
|
429
|
+
if specification_heading not in plain_spec.ALLOWED_IMPORT_SPECIFICATION_HEADINGS:
|
|
430
|
+
raise PlainSyntaxError(
|
|
431
|
+
f"Plain syntax error: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_IMPORT_SPECIFICATION_HEADINGS)}"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if plain_source[specification_heading] is None:
|
|
435
|
+
plain_source[specification_heading] = plain_file_parse_result.plain_source[specification_heading]
|
|
436
|
+
elif plain_file_parse_result.plain_source[specification_heading] is not None:
|
|
437
|
+
plain_source[specification_heading].children.extend(
|
|
438
|
+
plain_file_parse_result.plain_source[specification_heading].children
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if REQUIRED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata:
|
|
442
|
+
for item in plain_file_parse_result.plain_source_obj.metadata[REQUIRED_CONCEPTS_DIRECTIVE]:
|
|
443
|
+
assert isinstance(
|
|
444
|
+
item, str
|
|
445
|
+
), f"Syntax error: Invalid {REQUIRED_CONCEPTS_DIRECTIVE} metadata. Expected a string."
|
|
446
|
+
new_concepts, _ = concept_utils.extract_concepts_from_definition(item)
|
|
447
|
+
required_concepts.extend(new_concepts)
|
|
448
|
+
|
|
449
|
+
required_concepts.extend(plain_file_parse_result.required_concepts)
|
|
450
|
+
|
|
451
|
+
imported_modules.append(module_name)
|
|
452
|
+
|
|
453
|
+
return required_concepts
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def normalize_line_endings(plain_source_text: str) -> str:
|
|
457
|
+
return plain_source_text.replace("\r\n", "\n").replace("\r", "\n")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def restore_stripped_lines(plain_source_text: str, content: str) -> str:
|
|
461
|
+
stripped_source = plain_source_text.rstrip()
|
|
462
|
+
if not content or not stripped_source.endswith(content):
|
|
463
|
+
return content
|
|
464
|
+
|
|
465
|
+
stripped_line_count = stripped_source[: len(stripped_source) - len(content)].count("\n")
|
|
466
|
+
return "\n" * stripped_line_count + content
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def read_plain_source_metadata(plain_source_text):
|
|
470
|
+
try:
|
|
471
|
+
plain_source_obj = frontmatter.loads(plain_source_text)
|
|
472
|
+
except Exception as e:
|
|
473
|
+
raise PlainSyntaxError(f"Plain syntax error: Invalid frontmatter: {e}")
|
|
474
|
+
|
|
475
|
+
for directive in [EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]:
|
|
476
|
+
if directive in plain_source_obj.metadata:
|
|
477
|
+
assert isinstance(
|
|
478
|
+
plain_source_obj.metadata[directive], list
|
|
479
|
+
), f"Syntax error: Invalid {directive} metadata. Expected a list."
|
|
480
|
+
prepared_metadata = []
|
|
481
|
+
for item in plain_source_obj.metadata[directive]:
|
|
482
|
+
if isinstance(item, dict):
|
|
483
|
+
for k, v in item.items():
|
|
484
|
+
prepared_metadata.append(f"- {k}: {v}")
|
|
485
|
+
elif isinstance(item, str):
|
|
486
|
+
prepared_metadata.append(f"- {item}")
|
|
487
|
+
else:
|
|
488
|
+
raise PlainSyntaxError(
|
|
489
|
+
f"Plain syntax error: Invalid {directive} metadata. Expected a dictionary or a string."
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
plain_source_obj.metadata[directive] = prepared_metadata
|
|
493
|
+
|
|
494
|
+
return plain_source_obj
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def parse_plain_source( # noqa: C901
|
|
498
|
+
plain_source_text: str,
|
|
499
|
+
code_variables: dict,
|
|
500
|
+
template_dirs: list[str],
|
|
501
|
+
imported_modules: list[str],
|
|
502
|
+
modules_trace: list[str],
|
|
503
|
+
) -> PlainFileParseResult:
|
|
504
|
+
plain_source_text = normalize_line_endings(plain_source_text)
|
|
505
|
+
|
|
506
|
+
plain_source_obj = read_plain_source_metadata(plain_source_text)
|
|
507
|
+
|
|
508
|
+
plain_source = PLAIN_SOURCE_TEMPLATE.copy()
|
|
509
|
+
|
|
510
|
+
if IMPORT_DIRECTIVE in plain_source_obj.metadata:
|
|
511
|
+
required_concepts = process_imports(
|
|
512
|
+
plain_source,
|
|
513
|
+
plain_source_obj.metadata[IMPORT_DIRECTIVE],
|
|
514
|
+
code_variables,
|
|
515
|
+
template_dirs,
|
|
516
|
+
imported_modules,
|
|
517
|
+
modules_trace,
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
required_concepts = list[str]()
|
|
521
|
+
|
|
522
|
+
[_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text)
|
|
523
|
+
|
|
524
|
+
plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content)
|
|
525
|
+
|
|
526
|
+
plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables)
|
|
527
|
+
|
|
528
|
+
plain_file = mistletoe.Document(io.StringIO(plain_source_full_text))
|
|
529
|
+
|
|
530
|
+
remove_quotes(plain_file)
|
|
531
|
+
|
|
532
|
+
current_specification_heading = None
|
|
533
|
+
processed_specification_headings = set[str]()
|
|
534
|
+
for token in plain_file.children:
|
|
535
|
+
token_text = "".join(get_raw_text(token)).strip()
|
|
536
|
+
if isinstance(token, Paragraph):
|
|
537
|
+
paragraph_children = cast("Sequence[Token]", token.children)
|
|
538
|
+
emphasis_children = (
|
|
539
|
+
cast("Sequence[Token]", paragraph_children[0].children)
|
|
540
|
+
if paragraph_children and isinstance(paragraph_children[0], Emphasis)
|
|
541
|
+
else ()
|
|
542
|
+
)
|
|
543
|
+
if not (
|
|
544
|
+
len(paragraph_children) == 1
|
|
545
|
+
and isinstance(paragraph_children[0], Emphasis)
|
|
546
|
+
and len(emphasis_children) == 1
|
|
547
|
+
and isinstance(emphasis_children[0], Strong)
|
|
548
|
+
):
|
|
549
|
+
|
|
550
|
+
raise PlainSyntaxError(
|
|
551
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification (`{token_text}`)"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
strong_children = cast("Sequence[Token]", emphasis_children[0].children)
|
|
555
|
+
specification_heading = cast(RawText, strong_children[0]).content
|
|
556
|
+
|
|
557
|
+
if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING:
|
|
558
|
+
raise PlainSyntaxError(
|
|
559
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
|
|
563
|
+
raise PlainSyntaxError(
|
|
564
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
if (
|
|
568
|
+
specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS
|
|
569
|
+
and specification_heading not in plain_source
|
|
570
|
+
):
|
|
571
|
+
plain_source[specification_heading] = None
|
|
572
|
+
|
|
573
|
+
if specification_heading in processed_specification_headings:
|
|
574
|
+
raise PlainSyntaxError(
|
|
575
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Duplicate specification heading (`{specification_heading}`)"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if specification_heading == plain_spec.DEFINITIONS and current_specification_heading is not None:
|
|
579
|
+
raise PlainSyntaxError(
|
|
580
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section (`{token_text}`)"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
current_specification_heading = specification_heading
|
|
584
|
+
if plain_source[current_specification_heading] is None:
|
|
585
|
+
plain_source[current_specification_heading] = mistletoe.Document("")
|
|
586
|
+
|
|
587
|
+
processed_specification_headings.add(current_specification_heading)
|
|
588
|
+
elif isinstance(token, List):
|
|
589
|
+
if current_specification_heading is None:
|
|
590
|
+
raise PlainSyntaxError(
|
|
591
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Missing specification heading (`{token_text}`)"
|
|
592
|
+
)
|
|
593
|
+
plain_source[current_specification_heading].children.extend(token.children)
|
|
594
|
+
|
|
595
|
+
else:
|
|
596
|
+
raise PlainSyntaxError(
|
|
597
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# Acceptance tests are only allowed nested under functional specs. Reject them when
|
|
601
|
+
# they are nested under any other section (e.g. definitions, implementation reqs, test reqs).
|
|
602
|
+
for non_functional_heading in (
|
|
603
|
+
plain_spec.DEFINITIONS,
|
|
604
|
+
plain_spec.NON_FUNCTIONAL_REQUIREMENTS,
|
|
605
|
+
plain_spec.TEST_REQUIREMENTS,
|
|
606
|
+
):
|
|
607
|
+
section = plain_source.get(non_functional_heading)
|
|
608
|
+
if section is None:
|
|
609
|
+
continue
|
|
610
|
+
nested_acceptance_test_heading = _find_acceptance_test_heading(section)
|
|
611
|
+
if nested_acceptance_test_heading is not None:
|
|
612
|
+
raise PlainSyntaxError(
|
|
613
|
+
f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
defined_concepts = set[str]()
|
|
617
|
+
if plain_source[plain_spec.DEFINITIONS] is not None:
|
|
618
|
+
with PlainRenderer() as renderer:
|
|
619
|
+
for token in plain_source[plain_spec.DEFINITIONS].children:
|
|
620
|
+
rendered_token = renderer.render(token)
|
|
621
|
+
new_concepts, _ = concept_utils.extract_concepts_from_definition(rendered_token)
|
|
622
|
+
defined_concepts.update(new_concepts)
|
|
623
|
+
for concept in new_concepts:
|
|
624
|
+
if concept in required_concepts:
|
|
625
|
+
required_concepts.remove(concept)
|
|
626
|
+
|
|
627
|
+
if EXPORTED_CONCEPTS_DIRECTIVE in plain_source_obj.metadata:
|
|
628
|
+
module_name = modules_trace[-1] if modules_trace else None
|
|
629
|
+
subject = f"Module '{module_name}'" if module_name else "Module"
|
|
630
|
+
|
|
631
|
+
for entry in plain_source_obj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]:
|
|
632
|
+
exported_concepts, errors = concept_utils.extract_concepts_from_definition(entry)
|
|
633
|
+
if errors:
|
|
634
|
+
raise PlainSyntaxError(
|
|
635
|
+
f"Plain syntax error: {subject} exports an invalid concept (`{entry}`). "
|
|
636
|
+
f"Expected a concept token, e.g. :ConceptName:."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
for exported_concept in exported_concepts:
|
|
640
|
+
if exported_concept in concept_utils.DEFAULT_CONCEPTS:
|
|
641
|
+
raise PlainSyntaxError(
|
|
642
|
+
f"Plain syntax error: {subject} cannot export default concept {exported_concept}. "
|
|
643
|
+
f"Only user-defined concepts can be exported."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
if exported_concept not in defined_concepts:
|
|
647
|
+
message = f"Plain syntax error: {subject} exports undefined concept {exported_concept}."
|
|
648
|
+
if REQUIRES_DIRECTIVE in plain_source_obj.metadata:
|
|
649
|
+
message += (
|
|
650
|
+
" An exported concept must be defined in this module's definitions or in an "
|
|
651
|
+
"imported module; a concept inherited through requires cannot be re-exported."
|
|
652
|
+
)
|
|
653
|
+
raise PlainSyntaxError(message)
|
|
654
|
+
|
|
655
|
+
required_modules = []
|
|
656
|
+
if REQUIRES_DIRECTIVE in plain_source_obj.metadata:
|
|
657
|
+
required_modules = plain_source_obj.metadata[REQUIRES_DIRECTIVE]
|
|
658
|
+
|
|
659
|
+
return PlainFileParseResult(
|
|
660
|
+
plain_source=plain_source,
|
|
661
|
+
plain_source_obj=plain_source_obj,
|
|
662
|
+
required_modules=required_modules,
|
|
663
|
+
required_concepts=required_concepts,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def read_module_plain_source(module_name: str, template_dirs: list[str]) -> str:
|
|
668
|
+
plain_source_text = loaders.open_from(template_dirs, module_name + PLAIN_SOURCE_FILE_EXTENSION)
|
|
669
|
+
if plain_source_text is None:
|
|
670
|
+
raise ModuleDoesNotExistError(f"Module does not exist ({module_name}).")
|
|
671
|
+
|
|
672
|
+
blob = find_large_base64_blob(plain_source_text)
|
|
673
|
+
if blob is not None:
|
|
674
|
+
raise UnsupportedBase64Content(
|
|
675
|
+
f"Module '{module_name}' contains a base64-encoded blob ({len(blob)} characters) "
|
|
676
|
+
"inlined in the specification. This is not supported."
|
|
677
|
+
"Remove the base64 data from the .plain file or if necessary,"
|
|
678
|
+
"include the binary file path in the specification."
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
return plain_source_text
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def parse_plain_file(
|
|
685
|
+
module_name: str,
|
|
686
|
+
code_variables: dict,
|
|
687
|
+
template_dirs: list[str],
|
|
688
|
+
imported_modules: list[str],
|
|
689
|
+
modules_trace: list[str],
|
|
690
|
+
) -> PlainFileParseResult: # noqa: C901
|
|
691
|
+
plain_source_text = read_module_plain_source(module_name, template_dirs)
|
|
692
|
+
|
|
693
|
+
return parse_plain_source(
|
|
694
|
+
plain_source_text,
|
|
695
|
+
code_variables,
|
|
696
|
+
template_dirs,
|
|
697
|
+
imported_modules,
|
|
698
|
+
modules_trace + [module_name],
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def process_required_modules(
|
|
703
|
+
required_modules: list[str],
|
|
704
|
+
code_variables: dict,
|
|
705
|
+
template_dirs: list[str],
|
|
706
|
+
all_required_modules: list[str],
|
|
707
|
+
modules_trace: list[str],
|
|
708
|
+
) -> list[mistletoe.block_token.token]:
|
|
709
|
+
exported_definitions = list[mistletoe.block_token.token]()
|
|
710
|
+
for module_name in required_modules:
|
|
711
|
+
if module_name in modules_trace:
|
|
712
|
+
raise PlainSyntaxError(f"Plain syntax error: Circular required module detected: {module_name}.")
|
|
713
|
+
|
|
714
|
+
if len(all_required_modules) > 0 and module_name == all_required_modules[-1]:
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
plain_file_parse_result = parse_plain_file(
|
|
718
|
+
module_name, code_variables, template_dirs, imported_modules=[], modules_trace=[]
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
if len(plain_file_parse_result.required_modules) == 0:
|
|
722
|
+
if len(all_required_modules) > 0:
|
|
723
|
+
# For now we require that there is fixed order how required modules are dependent.
|
|
724
|
+
# In the future we will support the cases where required modules can be rendered independently
|
|
725
|
+
# and then merged (somehow).
|
|
726
|
+
raise PlainSyntaxError(
|
|
727
|
+
f"Plain syntax error: There must be a fixed order how required modules are dependent ({module_name})."
|
|
728
|
+
)
|
|
729
|
+
else:
|
|
730
|
+
process_required_modules(
|
|
731
|
+
plain_file_parse_result.required_modules,
|
|
732
|
+
code_variables,
|
|
733
|
+
template_dirs,
|
|
734
|
+
all_required_modules,
|
|
735
|
+
modules_trace + [module_name],
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
if EXPORTED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata:
|
|
739
|
+
exported_concepts = list[str]()
|
|
740
|
+
for concept in plain_file_parse_result.plain_source_obj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]:
|
|
741
|
+
exported_concepts.extend(concept_utils.extract_concepts_from_definition(concept)[0])
|
|
742
|
+
|
|
743
|
+
with PlainRenderer() as renderer:
|
|
744
|
+
for exported_concept in exported_concepts:
|
|
745
|
+
exported_definitions.extend(
|
|
746
|
+
concept_utils.find_concept_definitions_in_plain_source(
|
|
747
|
+
exported_concept, plain_file_parse_result.plain_source, renderer
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
all_required_modules.append(module_name)
|
|
752
|
+
|
|
753
|
+
return exported_definitions
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def process_exported_definitions(plain_source: dict, exported_definitions: list[mistletoe.block_token.token]) -> None:
|
|
757
|
+
if len(exported_definitions) == 0:
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
if plain_source[plain_spec.DEFINITIONS] is None:
|
|
761
|
+
plain_source[plain_spec.DEFINITIONS] = mistletoe.Document("")
|
|
762
|
+
|
|
763
|
+
with PlainRenderer() as renderer:
|
|
764
|
+
for exported_definition in exported_definitions:
|
|
765
|
+
add_defintion = True
|
|
766
|
+
|
|
767
|
+
exported_rendered_definition = renderer.render(exported_definition).strip()
|
|
768
|
+
for definition in plain_source[plain_spec.DEFINITIONS].children:
|
|
769
|
+
rendered_definition = renderer.render(definition).strip()
|
|
770
|
+
if exported_rendered_definition == rendered_definition:
|
|
771
|
+
add_defintion = False
|
|
772
|
+
break
|
|
773
|
+
|
|
774
|
+
if add_defintion:
|
|
775
|
+
plain_source[plain_spec.DEFINITIONS].children.append(exported_definition)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def plain_file_parser( # noqa: C901
|
|
779
|
+
plain_source_file_name: str,
|
|
780
|
+
template_dirs: list[str],
|
|
781
|
+
) -> tuple[str, dict, list[str]]:
|
|
782
|
+
# code_variables are used to pass code variables to the plain source
|
|
783
|
+
# they need to be passed as an argument to the function because they populated when liquid templating is applied
|
|
784
|
+
# and we need to pass them to the marshalled_plain_source_tree after it's rendered
|
|
785
|
+
plain_source_file_path = Path(plain_source_file_name)
|
|
786
|
+
if plain_source_file_path.suffix != PLAIN_SOURCE_FILE_EXTENSION:
|
|
787
|
+
raise PlainSyntaxError(
|
|
788
|
+
f"Plain syntax error: Invalid plain file extension: {plain_source_file_path.suffix}. Expected: {PLAIN_SOURCE_FILE_EXTENSION}."
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
module_name = (
|
|
792
|
+
plain_source_file_path.stem
|
|
793
|
+
if plain_source_file_path.is_absolute()
|
|
794
|
+
else plain_source_file_path.with_suffix("").as_posix()
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
code_variables = {}
|
|
798
|
+
|
|
799
|
+
plain_file_parse_result = parse_plain_file(
|
|
800
|
+
module_name,
|
|
801
|
+
code_variables,
|
|
802
|
+
template_dirs,
|
|
803
|
+
imported_modules=[],
|
|
804
|
+
modules_trace=[],
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
if len(plain_file_parse_result.required_concepts) > 0:
|
|
808
|
+
missing_required_concepts_msg = "Missing required concepts: "
|
|
809
|
+
missing_required_concepts_msg += ", ".join(plain_file_parse_result.required_concepts)
|
|
810
|
+
raise PlainSyntaxError(
|
|
811
|
+
f"Plain syntax error: Not all required concepts were defined. {missing_required_concepts_msg}."
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
if not has_functional_specs_section(plain_file_parse_result.plain_source):
|
|
815
|
+
# No ***functional specs*** section at all: valid as an import, but not renderable. Usage error.
|
|
816
|
+
raise MissingFunctionalitiesError(
|
|
817
|
+
f"Module '{module_name}' does not have any functionality specified. "
|
|
818
|
+
f"At least one functionality is required for rendering."
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
if count_functionalities(plain_file_parse_result.plain_source) == 0:
|
|
822
|
+
# ***functional specs*** section present but empty: invalid in every role. Syntax error.
|
|
823
|
+
raise PlainSyntaxError(
|
|
824
|
+
f"Plain syntax error: Module '{module_name}' has an empty "
|
|
825
|
+
f"'{plain_spec.FUNCTIONAL_REQUIREMENTS}' section. At least one functionality must be specified."
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
validate_functionalities_have_implementation_reqs(
|
|
829
|
+
plain_file_parse_result.plain_source,
|
|
830
|
+
module_name,
|
|
831
|
+
has_requires=bool(plain_file_parse_result.required_modules),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
exported_definitions = process_required_modules(
|
|
835
|
+
plain_file_parse_result.required_modules,
|
|
836
|
+
code_variables={},
|
|
837
|
+
template_dirs=template_dirs,
|
|
838
|
+
all_required_modules=[],
|
|
839
|
+
modules_trace=[],
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
process_exported_definitions(plain_file_parse_result.plain_source, exported_definitions)
|
|
843
|
+
|
|
844
|
+
process_acceptance_tests(plain_file_parse_result.plain_source)
|
|
845
|
+
|
|
846
|
+
check_for_linked_resources(plain_file_parse_result.plain_source)
|
|
847
|
+
|
|
848
|
+
marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source)
|
|
849
|
+
|
|
850
|
+
process_code_variables(marshalled_plain_source, code_variables)
|
|
851
|
+
|
|
852
|
+
validation_errors = concept_utils.validate_concepts(marshalled_plain_source)
|
|
853
|
+
if len(validation_errors) > 0:
|
|
854
|
+
errors_msg = "\n".join(validation_errors)
|
|
855
|
+
msg = f"Found {len(validation_errors)} errors in the plain file:\n{errors_msg}"
|
|
856
|
+
raise PlainSyntaxError(f"Plain syntax error: {msg}")
|
|
857
|
+
|
|
858
|
+
if plain_spec.DEFINITIONS in marshalled_plain_source:
|
|
859
|
+
concept_utils.sort_definitions(marshalled_plain_source[plain_spec.DEFINITIONS])
|
|
860
|
+
|
|
861
|
+
return module_name, marshalled_plain_source, plain_file_parse_result.required_modules
|