Skip to content
This repository was archived by the owner on Aug 25, 2024. It is now read-only.

Commit 07c271f

Browse files
committed
util: testing: consoletest: parser: Add basic .rst parser
Signed-off-by: John Andersen <johnandersenpdx@gmail.com>
1 parent 5cc9725 commit 07c271f

File tree

2 files changed

+295
-0
lines changed

2 files changed

+295
-0
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import dataclasses
2+
from typing import Dict, List, Union, Any
3+
4+
5+
@dataclasses.dataclass
6+
class Node:
7+
directive: str
8+
content: str
9+
options: Dict[str, Union[bool, str]]
10+
node: Dict[str, Any]
11+
12+
13+
class ParseSingleNodeFailure(Exception):
14+
"""
15+
Raised when a node could not be parsed
16+
"""
17+
18+
19+
class ParseNodesFailure(Exception):
20+
"""
21+
Raised when text could not be parsed
22+
"""
23+
24+
25+
def get_indent(line) -> str:
26+
return line[: len(line) - len(line.lstrip())]
27+
28+
29+
def remove_indent(lines: str) -> List[str]:
30+
# Find the lowest indentation level and remove it from all lines
31+
indents = list(
32+
map(
33+
lambda line: len(get_indent(line)),
34+
filter(lambda line: bool(line.strip()), lines),
35+
)
36+
)
37+
if not indents:
38+
return lines
39+
40+
lowest_ident = min(indents)
41+
for i, line in enumerate(lines):
42+
lines[i] = line[lowest_ident:]
43+
44+
return lines
45+
46+
47+
def _parse_nodes(text: str) -> List[Node]:
48+
"""
49+
Quick and dirty implementation of a .rst parser to extract directives
50+
"""
51+
nodes = []
52+
53+
c_indent = ""
54+
in_section = []
55+
directive = ""
56+
last_directive = ""
57+
args = ""
58+
last_args = ""
59+
text_split = text.split("\n")
60+
for i, line in enumerate(text_split):
61+
indent = get_indent(line)
62+
63+
if line.strip().startswith(".. ") and "::" in line:
64+
last_directive = directive
65+
directive = line[line.index(".. ") + 3 : line.index("::")]
66+
if not last_directive:
67+
last_directive = directive
68+
last_args = args
69+
args = line[line.index("::") + 2 :]
70+
if not last_args:
71+
last_args = args
72+
if in_section:
73+
if not in_section[-1]:
74+
in_section.pop()
75+
nodes.append((last_directive, last_args, in_section,))
76+
in_section = [""]
77+
c_indent = indent
78+
elif in_section:
79+
if len(indent) <= len(c_indent) and line.strip():
80+
if not in_section[-1]:
81+
in_section.pop()
82+
nodes.append((directive, args, in_section,))
83+
if line.strip().startswith(".. ") and "::" in line:
84+
last_directive = directive
85+
directive = line[line.index(".. ") + 3 : line.index("::")]
86+
if not last_directive:
87+
last_directive = directive
88+
last_args = args
89+
args = line[line.index("::") + 2 :]
90+
if not last_args:
91+
last_args = args
92+
in_section = [""]
93+
c_indent = indent
94+
else:
95+
in_section = []
96+
c_indent = ""
97+
else:
98+
in_section.append(line)
99+
100+
if in_section:
101+
if not in_section[-1]:
102+
in_section.pop()
103+
nodes.append((directive, args, in_section,))
104+
105+
# Remove first blank line
106+
nodes = [
107+
(
108+
directive,
109+
list(map(lambda i: i.strip(), filter(bool, args.split()))),
110+
in_section[1:],
111+
)
112+
for directive, args, in_section in nodes
113+
]
114+
115+
new_nodes = []
116+
117+
for directive, args, old_node in nodes:
118+
new_node = Node(directive=directive, options={}, content="", node={})
119+
option_lines = []
120+
121+
try:
122+
if directive == "code-block":
123+
new_node.content = old_node[old_node.index("") + 1 :]
124+
option_lines = old_node[: old_node.index("")]
125+
elif directive == "literalinclude":
126+
option_lines = old_node
127+
new_node.node["source"] = args[0]
128+
129+
# Parse the options
130+
if option_lines:
131+
option_lines = remove_indent(option_lines)
132+
for option in option_lines:
133+
option_split = option.split(" ", maxsplit=1)
134+
if len(option_split) == 1:
135+
option_split.append(True)
136+
new_node.options[option_split[0][1:-1]] = option_split[1]
137+
138+
new_node.content = remove_indent(new_node.content)
139+
140+
except Exception as error:
141+
raise ParseSingleNodeFailure(
142+
f"Failed to parse directive({directive}), args({args}), old_node: {old_node}"
143+
) from error
144+
145+
new_nodes.append(new_node)
146+
147+
return new_nodes
148+
149+
150+
def parse_nodes(text: str) -> List[Node]:
151+
try:
152+
return _parse_nodes(text)
153+
except Exception as error:
154+
raise ParseNodesFailure(f"Failed to parse: {text}") from error

tests/docs/test_consoletest.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import sys
3+
import inspect
34
import pathlib
45
import tempfile
56
import unittest
@@ -10,6 +11,7 @@
1011
from dffml.util.asynctestcase import AsyncTestCase
1112

1213
from dffml.util.testing.consoletest.commands import *
14+
from dffml.util.testing.consoletest.parser import parse_nodes, Node
1315

1416

1517
# Root of DFFML source tree
@@ -170,6 +172,145 @@ def test_find_name(self):
170172
)
171173

172174

175+
class TestParser(unittest.TestCase):
176+
def test_parse_nodes(self):
177+
self.maxDiff = None
178+
self.assertListEqual(
179+
list(
180+
filter(
181+
lambda node: node.directive
182+
in {"code-block", "literalinclude"},
183+
parse_nodes(
184+
inspect.cleandoc(
185+
r"""
186+
.. code-block:: console
187+
:test:
188+
189+
$ echo -e 'Hello\n\n\nWorld'
190+
Hello
191+
192+
193+
World
194+
195+
.. literalinclude:: some/file.py
196+
:filepath: myfile.py
197+
:test:
198+
199+
.. note::
200+
201+
.. note::
202+
203+
.. code-block:: console
204+
:test:
205+
:daemon: 8080
206+
207+
$ echo -e 'Hello\n\n\n World\n\n\nTest'
208+
Hello
209+
210+
211+
World
212+
213+
214+
Test
215+
216+
.. code-block:: console
217+
218+
$ echo -e 'Hello\n\n\n World\n\n\n\n'
219+
Hello
220+
221+
222+
World
223+
224+
225+
226+
$ echo 'feedface'
227+
feedface
228+
229+
.. note::
230+
231+
.. code-block:: console
232+
:test:
233+
234+
$ echo feedface
235+
feedface
236+
237+
.. code-block:: console
238+
:test:
239+
240+
$ echo feedface
241+
feedface
242+
"""
243+
)
244+
),
245+
)
246+
),
247+
[
248+
Node(
249+
directive="code-block",
250+
content=[
251+
r"$ echo -e 'Hello\n\n\nWorld'",
252+
"Hello",
253+
"",
254+
"",
255+
"World",
256+
],
257+
options={"test": True},
258+
node={},
259+
),
260+
Node(
261+
directive="literalinclude",
262+
content="",
263+
options={"filepath": "myfile.py", "test": True},
264+
node={"source": "some/file.py"},
265+
),
266+
Node(
267+
directive="code-block",
268+
content=[
269+
r"$ echo -e 'Hello\n\n\n World\n\n\nTest'",
270+
"Hello",
271+
"",
272+
"",
273+
" World",
274+
"",
275+
"",
276+
"Test",
277+
],
278+
options={"test": True, "daemon": "8080"},
279+
node={},
280+
),
281+
Node(
282+
directive="code-block",
283+
content=[
284+
r"$ echo -e 'Hello\n\n\n World\n\n\n\n'",
285+
"Hello",
286+
"",
287+
"",
288+
" World",
289+
"",
290+
"",
291+
"",
292+
"$ echo 'feedface'",
293+
"feedface",
294+
],
295+
options={},
296+
node={},
297+
),
298+
Node(
299+
directive="code-block",
300+
content=["$ echo feedface", "feedface",],
301+
options={"test": True},
302+
node={},
303+
),
304+
Node(
305+
directive="code-block",
306+
content=["$ echo feedface", "feedface",],
307+
options={"test": True},
308+
node={},
309+
),
310+
],
311+
)
312+
313+
173314
ROOT_PATH = pathlib.Path(__file__).parent.parent.parent
174315
DOCS_PATH = ROOT_PATH / "docs"
175316

0 commit comments

Comments
 (0)