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.
@@ -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