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