plain-parser 0.1.1.dev2__py3-none-any.whl → 0.1.2__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/concept_utils.py +49 -20
- plain_parser/graph.py +164 -0
- plain_parser/loaders.py +33 -4
- plain_parser/plain_file.py +28 -17
- plain_parser/plain_spec.py +1 -1
- {plain_parser-0.1.1.dev2.dist-info → plain_parser-0.1.2.dist-info}/METADATA +1 -2
- plain_parser-0.1.2.dist-info/RECORD +14 -0
- plain_parser-0.1.1.dev2.dist-info/RECORD +0 -13
- {plain_parser-0.1.1.dev2.dist-info → plain_parser-0.1.2.dist-info}/WHEEL +0 -0
- {plain_parser-0.1.1.dev2.dist-info → plain_parser-0.1.2.dist-info}/entry_points.txt +0 -0
- {plain_parser-0.1.1.dev2.dist-info → plain_parser-0.1.2.dist-info}/licenses/LICENSE +0 -0
plain_parser/concept_utils.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from copy import deepcopy
|
|
3
|
+
from graphlib import CycleError, TopologicalSorter
|
|
3
4
|
from typing import Optional
|
|
4
5
|
|
|
5
6
|
import mistletoe.block_token
|
|
6
|
-
import networkx as nx
|
|
7
7
|
|
|
8
|
-
from plain_parser import plain_spec
|
|
8
|
+
from plain_parser import graph, plain_spec
|
|
9
9
|
from plain_parser.exceptions import PlainSyntaxError
|
|
10
10
|
|
|
11
11
|
# These are reserved concept names that are pre-defined and must not be redefined in the definitions section.
|
|
@@ -186,34 +186,63 @@ def build_adjacency_list(definitions: Optional[list[dict]]) -> tuple[dict, dict]
|
|
|
186
186
|
return adjacency_list, concept_definitions
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
MAX_REPORTED_CYCLES = 10
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def format_cycles_error(adjacency_list: dict[str, list[str]], concept_definitions: dict[str, dict]) -> str:
|
|
193
|
+
"""
|
|
194
|
+
List the elementary cycles in the graph, each as a "Cycle N: :A: -> :B: -> :A:" line
|
|
195
|
+
followed by the definition of every concept in the cycle. Arrows point from a definition
|
|
196
|
+
to a concept it mentions, each cycle starts at its earliest-defined concept, and cycles
|
|
197
|
+
are ordered by definition position, so the report reads top to bottom like the file.
|
|
198
|
+
At most MAX_REPORTED_CYCLES are listed; the search stops once one more than that is found.
|
|
199
|
+
"""
|
|
200
|
+
position = {concept: index for index, concept in enumerate(concept_definitions)}
|
|
201
|
+
cycles = []
|
|
202
|
+
# key the graph in definition order so a search cut short by the limit has found the earliest cycles
|
|
203
|
+
by_position = {concept: adjacency_list[concept] for concept in [*concept_definitions, *adjacency_list]}
|
|
204
|
+
for cycle in graph.simple_cycles(by_position, limit=MAX_REPORTED_CYCLES + 1):
|
|
205
|
+
# reverse since simple_cycles edges point from a used concept to the one defined with it
|
|
206
|
+
cycle = cycle[::-1]
|
|
207
|
+
first = min(range(len(cycle)), key=lambda i: position[cycle[i]])
|
|
208
|
+
cycles.append(cycle[first:] + cycle[:first])
|
|
209
|
+
cycles.sort(key=lambda cycle: (position[cycle[0]], len(cycle), [position[c] for c in cycle]))
|
|
210
|
+
|
|
211
|
+
if len(cycles) > MAX_REPORTED_CYCLES:
|
|
212
|
+
cycles = cycles[:MAX_REPORTED_CYCLES]
|
|
213
|
+
lines = [
|
|
214
|
+
f"Plain syntax error: Found more than {MAX_REPORTED_CYCLES} cycles in concept definitions. Cycles are not allowed.",
|
|
215
|
+
f"Showing the first {MAX_REPORTED_CYCLES}.",
|
|
216
|
+
]
|
|
217
|
+
else:
|
|
218
|
+
lines = [f"Plain syntax error: Found {len(cycles)} cycle(s) in concept definitions. Cycles are not allowed."]
|
|
219
|
+
for number, cycle in enumerate(cycles, start=1):
|
|
220
|
+
lines.append("")
|
|
221
|
+
lines.append(f"Cycle {number}: " + " -> ".join(cycle + [cycle[0]]))
|
|
222
|
+
# concepts defined on one line can share a cycle; print that line once
|
|
223
|
+
lines.extend(dict.fromkeys(concept_definitions[concept]["markdown"] for concept in cycle))
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
189
227
|
def sort_definitions(definitions: list[dict]) -> list[dict]:
|
|
190
228
|
if len(definitions) <= 1:
|
|
191
229
|
return
|
|
192
230
|
|
|
193
231
|
adjacency_list, concept_definitions = build_adjacency_list(definitions)
|
|
194
232
|
|
|
195
|
-
|
|
233
|
+
# adjacency_list maps a used concept to the concepts defined in terms of it,
|
|
234
|
+
# so the used concept is a predecessor of each of them
|
|
235
|
+
sorter: TopologicalSorter[str] = TopologicalSorter()
|
|
196
236
|
for node, neighbors in adjacency_list.items():
|
|
237
|
+
sorter.add(node)
|
|
197
238
|
for neighbour in neighbors:
|
|
198
|
-
|
|
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"
|
|
239
|
+
sorter.add(neighbour, node)
|
|
213
240
|
|
|
214
|
-
|
|
241
|
+
try:
|
|
242
|
+
order = list(sorter.static_order())
|
|
243
|
+
except CycleError:
|
|
244
|
+
raise PlainSyntaxError(format_cycles_error(adjacency_list, concept_definitions)) from None
|
|
215
245
|
|
|
216
|
-
order = list(nx.topological_sort(concept_graph))
|
|
217
246
|
if len(order) > 0:
|
|
218
247
|
definition_to_order_idx = {
|
|
219
248
|
plain_spec.hash_text(str(concept_definitions[concept])): idx for idx, concept in enumerate(order)
|
plain_parser/graph.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Cycle search over a directed graph given as a mapping from node to its successors.
|
|
2
|
+
|
|
3
|
+
Nodes that appear only as successors are treated as having no outgoing edges.
|
|
4
|
+
|
|
5
|
+
The search is Johnson's algorithm, written from the pseudocode in the paper, with the
|
|
6
|
+
strongly connected components found by Tarjan's algorithm as given on Wikipedia.
|
|
7
|
+
|
|
8
|
+
- D. B. Johnson, "Finding all the elementary circuits of a directed graph",
|
|
9
|
+
SIAM J. Comput. 4(1):77-84, 1975, doi:10.1137/0204007
|
|
10
|
+
- R. Tarjan, "Depth-first search and linear graph algorithms",
|
|
11
|
+
SIAM J. Comput. 1(2):146-160, 1972, doi:10.1137/0201010
|
|
12
|
+
- https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
|
|
13
|
+
|
|
14
|
+
Both searches keep their own stack of frames instead of recursing, so the depth of a
|
|
15
|
+
tangle is not limited by Python's recursion limit.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import heapq
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from collections.abc import Hashable, Iterable, Iterator, Mapping
|
|
21
|
+
from typing import Optional, TypeVar
|
|
22
|
+
|
|
23
|
+
Node = TypeVar("Node", bound=Hashable)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _successors(graph: Mapping[Node, Iterable[Node]]) -> dict[Node, list[Node]]:
|
|
27
|
+
# a repeated edge would otherwise yield every cycle through it once per repeat
|
|
28
|
+
successors = {node: list(dict.fromkeys(targets)) for node, targets in graph.items()}
|
|
29
|
+
for targets in list(successors.values()):
|
|
30
|
+
for target in targets:
|
|
31
|
+
successors.setdefault(target, [])
|
|
32
|
+
return successors
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def strongly_connected_components(graph: Mapping[Node, Iterable[Node]]) -> list[set[Node]]:
|
|
36
|
+
"""Tarjan: one depth-first walk, each node stamped with its visit index and the lowest
|
|
37
|
+
index it can reach; a node whose lowest reachable index is its own closes a component."""
|
|
38
|
+
successors = _successors(graph)
|
|
39
|
+
index: dict[Node, int] = {}
|
|
40
|
+
lowlink: dict[Node, int] = {}
|
|
41
|
+
stack: list[Node] = []
|
|
42
|
+
on_stack: set[Node] = set()
|
|
43
|
+
components: list[set[Node]] = []
|
|
44
|
+
|
|
45
|
+
def visit(node: Node) -> tuple[Node, Iterator[Node]]:
|
|
46
|
+
index[node] = lowlink[node] = len(index)
|
|
47
|
+
stack.append(node)
|
|
48
|
+
on_stack.add(node)
|
|
49
|
+
return node, iter(successors[node])
|
|
50
|
+
|
|
51
|
+
for root in successors:
|
|
52
|
+
if root in index:
|
|
53
|
+
continue
|
|
54
|
+
frames = [visit(root)]
|
|
55
|
+
while frames:
|
|
56
|
+
node, targets = frames[-1]
|
|
57
|
+
for target in targets:
|
|
58
|
+
if target not in index:
|
|
59
|
+
frames.append(visit(target))
|
|
60
|
+
break
|
|
61
|
+
if target in on_stack:
|
|
62
|
+
lowlink[node] = min(lowlink[node], index[target])
|
|
63
|
+
else:
|
|
64
|
+
frames.pop()
|
|
65
|
+
if frames:
|
|
66
|
+
parent = frames[-1][0]
|
|
67
|
+
lowlink[parent] = min(lowlink[parent], lowlink[node])
|
|
68
|
+
if lowlink[node] == index[node]:
|
|
69
|
+
component: set[Node] = set()
|
|
70
|
+
while True:
|
|
71
|
+
member = stack.pop()
|
|
72
|
+
on_stack.discard(member)
|
|
73
|
+
component.add(member)
|
|
74
|
+
if member == node:
|
|
75
|
+
break
|
|
76
|
+
components.append(component)
|
|
77
|
+
return components
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cycles_through(adjacency: dict[Node, list[Node]], s: Node) -> Iterator[list[Node]]:
|
|
81
|
+
"""Johnson's CIRCUIT: every elementary cycle through `s` in one strongly connected component.
|
|
82
|
+
|
|
83
|
+
A node is blocked while it is on the path, and stays blocked after backtracking until a
|
|
84
|
+
cycle is found through some node it was waiting on."""
|
|
85
|
+
blocked: set[Node] = set()
|
|
86
|
+
blocked_map: dict[Node, set[Node]] = defaultdict(set)
|
|
87
|
+
|
|
88
|
+
def unblock(node: Node) -> None:
|
|
89
|
+
pending = [node]
|
|
90
|
+
while pending:
|
|
91
|
+
current = pending.pop()
|
|
92
|
+
blocked.discard(current)
|
|
93
|
+
pending.extend(waiting for waiting in blocked_map.pop(current, ()) if waiting in blocked)
|
|
94
|
+
|
|
95
|
+
path = [s]
|
|
96
|
+
blocked.add(s)
|
|
97
|
+
frames = [iter(adjacency[s])]
|
|
98
|
+
found_below = [False]
|
|
99
|
+
while frames:
|
|
100
|
+
node = path[-1]
|
|
101
|
+
for target in frames[-1]:
|
|
102
|
+
if target == s:
|
|
103
|
+
yield list(path)
|
|
104
|
+
found_below[-1] = True
|
|
105
|
+
elif target not in blocked:
|
|
106
|
+
path.append(target)
|
|
107
|
+
blocked.add(target)
|
|
108
|
+
frames.append(iter(adjacency[target]))
|
|
109
|
+
found_below.append(False)
|
|
110
|
+
break
|
|
111
|
+
else:
|
|
112
|
+
frames.pop()
|
|
113
|
+
path.pop()
|
|
114
|
+
if found_below.pop():
|
|
115
|
+
unblock(node)
|
|
116
|
+
if found_below:
|
|
117
|
+
found_below[-1] = True
|
|
118
|
+
else:
|
|
119
|
+
for target in adjacency[node]:
|
|
120
|
+
blocked_map[target].add(node)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _all_cycles(graph: Mapping[Node, Iterable[Node]]) -> Iterator[list[Node]]:
|
|
124
|
+
"""Johnson's main loop: every cycle lies inside one strongly connected component, and
|
|
125
|
+
every cycle inside a component either passes through its least node or survives that
|
|
126
|
+
node's removal. So search each component from its least node, drop the node, and split
|
|
127
|
+
what is left into components again. Components are taken least node first, so a caller
|
|
128
|
+
that stops early has seen the cycles through the earliest nodes."""
|
|
129
|
+
successors = _successors(graph)
|
|
130
|
+
for node, targets in successors.items():
|
|
131
|
+
if node in targets:
|
|
132
|
+
yield [node]
|
|
133
|
+
successors = {node: [t for t in targets if t != node] for node, targets in successors.items()}
|
|
134
|
+
position = {node: i for i, node in enumerate(successors)}
|
|
135
|
+
|
|
136
|
+
# components are disjoint, so no two entries share a least position and the sets are never compared
|
|
137
|
+
components: list[tuple[int, set[Node]]] = []
|
|
138
|
+
|
|
139
|
+
def push_components(adjacency: Mapping[Node, Iterable[Node]]) -> None:
|
|
140
|
+
for component in strongly_connected_components(adjacency):
|
|
141
|
+
if len(component) >= 2:
|
|
142
|
+
heapq.heappush(components, (min(map(position.__getitem__, component)), component))
|
|
143
|
+
|
|
144
|
+
push_components(successors)
|
|
145
|
+
while components:
|
|
146
|
+
_, component = heapq.heappop(components)
|
|
147
|
+
s = min(component, key=position.__getitem__)
|
|
148
|
+
adjacency = {n: [t for t in successors[n] if t in component] for n in component}
|
|
149
|
+
yield from _cycles_through(adjacency, s)
|
|
150
|
+
del adjacency[s]
|
|
151
|
+
for targets in adjacency.values():
|
|
152
|
+
if s in targets:
|
|
153
|
+
targets.remove(s)
|
|
154
|
+
push_components(adjacency)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def simple_cycles(graph: Mapping[Node, Iterable[Node]], limit: Optional[int] = None) -> Iterator[list[Node]]:
|
|
158
|
+
"""Yield the elementary cycles of `graph`, each as its nodes in edge order, stopping
|
|
159
|
+
after `limit` cycles when one is given. Self-loops come first; longer cycles follow in
|
|
160
|
+
order of their earliest node in `graph`'s iteration order."""
|
|
161
|
+
for found, cycle in enumerate(_all_cycles(graph), start=1):
|
|
162
|
+
if limit is not None and found > limit:
|
|
163
|
+
return
|
|
164
|
+
yield cycle
|
plain_parser/loaders.py
CHANGED
|
@@ -3,10 +3,10 @@ import re
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
5
|
from liquid2 import Environment, FileSystemLoader, StrictUndefined
|
|
6
|
-
from liquid2.exceptions import UndefinedError
|
|
6
|
+
from liquid2.exceptions import LiquidSyntaxError, UndefinedError
|
|
7
7
|
|
|
8
8
|
from plain_parser import plain_spec
|
|
9
|
-
from plain_parser.exceptions import UnsupportedBase64Content, UnsupportedResourceType
|
|
9
|
+
from plain_parser.exceptions import PlainSyntaxError, UnsupportedBase64Content, UnsupportedResourceType
|
|
10
10
|
from plain_parser.liquid_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
|
|
11
11
|
|
|
12
12
|
MAX_BASE64_BLOB_LENGTH = 8192
|
|
@@ -36,6 +36,20 @@ def open_from(dirs, file_name):
|
|
|
36
36
|
return None
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def resolve_linked_resource(dirs, file_name):
|
|
40
|
+
"""Return the first existing path for file_name across dirs, or None if not found.
|
|
41
|
+
|
|
42
|
+
Mirrors the search order used by open_from so that validating a linked resource and
|
|
43
|
+
loading it can never disagree.
|
|
44
|
+
"""
|
|
45
|
+
for dir in dirs:
|
|
46
|
+
full_file_name = os.path.join(dir, file_name)
|
|
47
|
+
if os.path.exists(full_file_name):
|
|
48
|
+
return full_file_name
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
39
53
|
def load_linked_resources(template_dirs: list[str], resources_list, module_name: str):
|
|
40
54
|
linked_resources = {}
|
|
41
55
|
|
|
@@ -81,7 +95,20 @@ class TrackingFileSystemLoader(Plain2CodeLoaderMixin, FileSystemLoader):
|
|
|
81
95
|
return source
|
|
82
96
|
|
|
83
97
|
|
|
84
|
-
def
|
|
98
|
+
def liquid_error_message(error: LiquidSyntaxError) -> str:
|
|
99
|
+
# Built from attributes: str() of some liquid2 syntax errors (e.g. an unterminated `{{ x`) raises ValueError.
|
|
100
|
+
template_name = error.template_name or None
|
|
101
|
+
token = error.token
|
|
102
|
+
if token is None or token.start < 0:
|
|
103
|
+
where = f" in {template_name}" if template_name else ""
|
|
104
|
+
else:
|
|
105
|
+
line = token.source[: token.start].count("\n") + 1
|
|
106
|
+
column = token.start - (token.source.rfind("\n", 0, token.start) + 1)
|
|
107
|
+
where = f" at {template_name}:{line}:{column}" if template_name else f" at {line}:{column}"
|
|
108
|
+
return f"Plain syntax error: Invalid Liquid{where}: {error.message}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_loaded_templates(source_path, plain_source, template_name=None):
|
|
85
112
|
# Render the plain source with Liquid templating engine
|
|
86
113
|
# to identify the templates that are being loaded
|
|
87
114
|
|
|
@@ -92,9 +119,11 @@ def get_loaded_templates(source_path, plain_source):
|
|
|
92
119
|
liquid_env.filters["code_variable"] = plain_spec.code_variable_liquid_filter
|
|
93
120
|
liquid_env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter
|
|
94
121
|
|
|
95
|
-
plain_source_template = liquid_env.from_string(plain_source)
|
|
96
122
|
try:
|
|
123
|
+
plain_source_template = liquid_env.from_string(plain_source, name=template_name or "")
|
|
97
124
|
plain_source = plain_source_template.render()
|
|
125
|
+
except LiquidSyntaxError as e:
|
|
126
|
+
raise PlainSyntaxError(liquid_error_message(e))
|
|
98
127
|
except UndefinedError as e:
|
|
99
128
|
raise Exception(f"Undefined liquid variable: {str(e)}")
|
|
100
129
|
|
plain_parser/plain_file.py
CHANGED
|
@@ -88,7 +88,7 @@ def remove_quotes(token):
|
|
|
88
88
|
token.children = tuple(new_children)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
def check_section_for_linked_resources(section):
|
|
91
|
+
def check_section_for_linked_resources(section, template_dirs):
|
|
92
92
|
linked_resources = []
|
|
93
93
|
for link in traverse(section, klass=Link):
|
|
94
94
|
parsed_url = urlparse(link.node.target)
|
|
@@ -97,11 +97,19 @@ def check_section_for_linked_resources(section):
|
|
|
97
97
|
f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})."
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
resolved_target = loaders.resolve_linked_resource(template_dirs, link.node.target)
|
|
101
|
+
if resolved_target is None:
|
|
102
|
+
searched_dirs = "\n".join(f" {position}. {dir}" for position, dir in enumerate(template_dirs, start=1))
|
|
103
|
+
raise PlainSyntaxError(
|
|
104
|
+
f"Plain syntax error: Link {link.node.target} does not exist. "
|
|
105
|
+
f"Linked resources are looked up relative to the following directories "
|
|
106
|
+
f"(highest to lowest precedence):\n{searched_dirs}"
|
|
107
|
+
)
|
|
102
108
|
|
|
103
|
-
if not os.path.isfile(
|
|
104
|
-
raise PlainSyntaxError(
|
|
109
|
+
if not os.path.isfile(resolved_target):
|
|
110
|
+
raise PlainSyntaxError(
|
|
111
|
+
f"Plain syntax error: Link {link.node.target} must be a file (resolved to {resolved_target})."
|
|
112
|
+
)
|
|
105
113
|
|
|
106
114
|
if len(link.node.children) != 1:
|
|
107
115
|
raise PlainSyntaxError(f"Plain syntax error: Link must have text specified (link: {link.node.target}).")
|
|
@@ -120,15 +128,15 @@ def check_section_for_linked_resources(section):
|
|
|
120
128
|
section.linked_resources = linked_resources
|
|
121
129
|
|
|
122
130
|
|
|
123
|
-
def check_for_linked_resources(plain_source):
|
|
131
|
+
def check_for_linked_resources(plain_source, template_dirs):
|
|
124
132
|
for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
|
|
125
133
|
if specification_heading in plain_source and hasattr(plain_source[specification_heading], "children"):
|
|
126
134
|
for requirement in plain_source[specification_heading].children:
|
|
127
|
-
check_section_for_linked_resources(requirement)
|
|
135
|
+
check_section_for_linked_resources(requirement, template_dirs)
|
|
128
136
|
|
|
129
137
|
if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS):
|
|
130
138
|
for acceptance_test in requirement.acceptance_tests:
|
|
131
|
-
check_section_for_linked_resources(acceptance_test)
|
|
139
|
+
check_section_for_linked_resources(acceptance_test, template_dirs)
|
|
132
140
|
|
|
133
141
|
|
|
134
142
|
def process_section_code_variables(section, code_variables):
|
|
@@ -232,7 +240,7 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]:
|
|
|
232
240
|
content = strong_children[0].content.strip()
|
|
233
241
|
if content == plain_spec.ACCEPTANCE_TEST_HEADING:
|
|
234
242
|
return True, None
|
|
235
|
-
problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}
|
|
243
|
+
problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}`."
|
|
236
244
|
return False, problem
|
|
237
245
|
|
|
238
246
|
|
|
@@ -500,6 +508,7 @@ def parse_plain_source( # noqa: C901
|
|
|
500
508
|
template_dirs: list[str],
|
|
501
509
|
imported_modules: list[str],
|
|
502
510
|
modules_trace: list[str],
|
|
511
|
+
module_name: str | None = None,
|
|
503
512
|
) -> PlainFileParseResult:
|
|
504
513
|
plain_source_text = normalize_line_endings(plain_source_text)
|
|
505
514
|
|
|
@@ -519,7 +528,7 @@ def parse_plain_source( # noqa: C901
|
|
|
519
528
|
else:
|
|
520
529
|
required_concepts = list[str]()
|
|
521
530
|
|
|
522
|
-
[_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text)
|
|
531
|
+
[_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text, module_name)
|
|
523
532
|
|
|
524
533
|
plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content)
|
|
525
534
|
|
|
@@ -577,7 +586,7 @@ def parse_plain_source( # noqa: C901
|
|
|
577
586
|
|
|
578
587
|
if specification_heading == plain_spec.DEFINITIONS and current_specification_heading is not None:
|
|
579
588
|
raise PlainSyntaxError(
|
|
580
|
-
f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section
|
|
589
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section (`{token_text}`)"
|
|
581
590
|
)
|
|
582
591
|
|
|
583
592
|
current_specification_heading = specification_heading
|
|
@@ -594,7 +603,7 @@ def parse_plain_source( # noqa: C901
|
|
|
594
603
|
|
|
595
604
|
else:
|
|
596
605
|
raise PlainSyntaxError(
|
|
597
|
-
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure
|
|
606
|
+
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)"
|
|
598
607
|
)
|
|
599
608
|
|
|
600
609
|
# Acceptance tests are only allowed nested under functional specs. Reject them when
|
|
@@ -673,8 +682,8 @@ def read_module_plain_source(module_name: str, template_dirs: list[str]) -> str:
|
|
|
673
682
|
if blob is not None:
|
|
674
683
|
raise UnsupportedBase64Content(
|
|
675
684
|
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,"
|
|
685
|
+
"inlined in the specification. This is not supported. "
|
|
686
|
+
"Remove the base64 data from the .plain file or if necessary, "
|
|
678
687
|
"include the binary file path in the specification."
|
|
679
688
|
)
|
|
680
689
|
|
|
@@ -696,6 +705,7 @@ def parse_plain_file(
|
|
|
696
705
|
template_dirs,
|
|
697
706
|
imported_modules,
|
|
698
707
|
modules_trace + [module_name],
|
|
708
|
+
module_name=module_name,
|
|
699
709
|
)
|
|
700
710
|
|
|
701
711
|
|
|
@@ -756,7 +766,7 @@ def process_required_modules(
|
|
|
756
766
|
)
|
|
757
767
|
|
|
758
768
|
marshalled_plain_source = validate_and_marshall_module(
|
|
759
|
-
plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables
|
|
769
|
+
plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables, template_dirs
|
|
760
770
|
)
|
|
761
771
|
if module_name not in (chain_module_name for chain_module_name, _ in chain):
|
|
762
772
|
chain.append((module_name, marshalled_plain_source))
|
|
@@ -793,6 +803,7 @@ def validate_and_marshall_module(
|
|
|
793
803
|
module_name: str,
|
|
794
804
|
exported_definitions: list[mistletoe.block_token.token],
|
|
795
805
|
code_variables: dict,
|
|
806
|
+
template_dirs: list[str],
|
|
796
807
|
) -> dict:
|
|
797
808
|
"""Every check a module must pass after parsing, and its marshalled plain source tree."""
|
|
798
809
|
if len(plain_file_parse_result.required_concepts) > 0:
|
|
@@ -826,7 +837,7 @@ def validate_and_marshall_module(
|
|
|
826
837
|
|
|
827
838
|
process_acceptance_tests(plain_file_parse_result.plain_source)
|
|
828
839
|
|
|
829
|
-
check_for_linked_resources(plain_file_parse_result.plain_source)
|
|
840
|
+
check_for_linked_resources(plain_file_parse_result.plain_source, template_dirs)
|
|
830
841
|
|
|
831
842
|
marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source)
|
|
832
843
|
|
|
@@ -881,7 +892,7 @@ def _parse_module(
|
|
|
881
892
|
)
|
|
882
893
|
|
|
883
894
|
marshalled_plain_source = validate_and_marshall_module(
|
|
884
|
-
plain_file_parse_result, module_name, exported_definitions, code_variables
|
|
895
|
+
plain_file_parse_result, module_name, exported_definitions, code_variables, template_dirs
|
|
885
896
|
)
|
|
886
897
|
|
|
887
898
|
return module_name, marshalled_plain_source, plain_file_parse_result.required_modules, chain
|
plain_parser/plain_spec.py
CHANGED
|
@@ -126,7 +126,7 @@ def collect_linked_resources(
|
|
|
126
126
|
):
|
|
127
127
|
|
|
128
128
|
if not isinstance(plain_source_tree, dict):
|
|
129
|
-
raise ValueError("
|
|
129
|
+
raise ValueError("plain_source_tree must be a dictionary.")
|
|
130
130
|
|
|
131
131
|
if frid is not None:
|
|
132
132
|
functional_requirements = get_frids(plain_source_tree)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plain-parser
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Parser for ***plain specification files
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -10,7 +10,6 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Classifier: Topic :: Software Development :: Compilers
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Requires-Dist: mistletoe<2,>=1.6.0
|
|
13
|
-
Requires-Dist: networkx<4,>=3.6.1
|
|
14
13
|
Requires-Dist: python-frontmatter<2,>=1.3.0
|
|
15
14
|
Requires-Dist: python-liquid2<0.5,>=0.4.0
|
|
16
15
|
Requires-Dist: pyyaml<7,>=6
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
plain_parser/__init__.py,sha256=RzRI_EVcrPnPT_BzoYtrw09-HUzKGrW4HT-GTd8GrTU,725
|
|
2
|
+
plain_parser/cli.py,sha256=oJL_23l_K3ZWpJLRROGjFo2UOsJv7uUoP0RbfL8oit4,4682
|
|
3
|
+
plain_parser/concept_utils.py,sha256=8NZf5OAKVfz_VvTd1Dn7yiGLv-VFVMjto9YW49xKFjI,9916
|
|
4
|
+
plain_parser/exceptions.py,sha256=WcHK5g9K6bsE-ZZBKWl0lZtiMTQ6fif8-zdEx9qjmRw,795
|
|
5
|
+
plain_parser/graph.py,sha256=xZfgzAXoL_hMfqre0Vj4kdvCC8vzfxZgvYptEK9W0Ho,6843
|
|
6
|
+
plain_parser/liquid_nodes.py,sha256=CYmujdPJMK9hLjfFPmnSoKL3Ps4sb-2OOhH-fZUssis,3917
|
|
7
|
+
plain_parser/loaders.py,sha256=o-I7u1NL8hZdTWztqwO7zw072aGxXTBBYSAFDBRG4q0,5136
|
|
8
|
+
plain_parser/plain_file.py,sha256=NnPB9-xp2BIwJIaKmZnjTVjYCZPhlh4RLakR1CV6tUU,39867
|
|
9
|
+
plain_parser/plain_spec.py,sha256=Z79Vi8bdRiOiM53QyWS8OLNSlGPrQObSZx11Eh8i8Mk,15308
|
|
10
|
+
plain_parser-0.1.2.dist-info/METADATA,sha256=aJCTy3bMU2f17OFWQM1TxaCJjcgvUYRNZOCCD8LzLyQ,2642
|
|
11
|
+
plain_parser-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
plain_parser-0.1.2.dist-info/entry_points.txt,sha256=pA31GGUyYrmZSpH1CuSzhjrv8pX5ac5HI5hFop1B4eI,55
|
|
13
|
+
plain_parser-0.1.2.dist-info/licenses/LICENSE,sha256=QClWKncxgRjNUVOW8Axi7zK9HtsMdJEtUSMA4LEe8fo,1071
|
|
14
|
+
plain_parser-0.1.2.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
plain_parser/__init__.py,sha256=RzRI_EVcrPnPT_BzoYtrw09-HUzKGrW4HT-GTd8GrTU,725
|
|
2
|
-
plain_parser/cli.py,sha256=oJL_23l_K3ZWpJLRROGjFo2UOsJv7uUoP0RbfL8oit4,4682
|
|
3
|
-
plain_parser/concept_utils.py,sha256=BJzddKxrwg2KbV-58R74ihIJKcv-CGA4B5NZyhOixQ8,8100
|
|
4
|
-
plain_parser/exceptions.py,sha256=WcHK5g9K6bsE-ZZBKWl0lZtiMTQ6fif8-zdEx9qjmRw,795
|
|
5
|
-
plain_parser/liquid_nodes.py,sha256=CYmujdPJMK9hLjfFPmnSoKL3Ps4sb-2OOhH-fZUssis,3917
|
|
6
|
-
plain_parser/loaders.py,sha256=5drZ7OOPJQjEitjWL5W5NfJ0CUatxKHU1UOwm_xhKKE,3850
|
|
7
|
-
plain_parser/plain_file.py,sha256=cddrat_XLi72q00ZRAANjwIMMXrQnEUYdKaT8qo-y1s,39206
|
|
8
|
-
plain_parser/plain_spec.py,sha256=31mLXDldi9zOXT9aK3QhD2mbxbph4ArFU_qgWYd2nwc,15309
|
|
9
|
-
plain_parser-0.1.1.dev2.dist-info/METADATA,sha256=MHdortiS_vYocCr4P9r0SkxKnFq2k4fmrP3f6QLNwfw,2681
|
|
10
|
-
plain_parser-0.1.1.dev2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
-
plain_parser-0.1.1.dev2.dist-info/entry_points.txt,sha256=pA31GGUyYrmZSpH1CuSzhjrv8pX5ac5HI5hFop1B4eI,55
|
|
12
|
-
plain_parser-0.1.1.dev2.dist-info/licenses/LICENSE,sha256=QClWKncxgRjNUVOW8Axi7zK9HtsMdJEtUSMA4LEe8fo,1071
|
|
13
|
-
plain_parser-0.1.1.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|