Skip to content

Commit 1d7eedd

Browse files
vuldercjdb
authored andcommitted
Adds initial test version of topic updater script
1 parent 3740d2f commit 1d7eedd

File tree

1 file changed

+326
-0
lines changed

1 file changed

+326
-0
lines changed

topic_updater.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""
2+
Small tool for generating and fixing teaching topics.
3+
4+
Verify if the layout of all topics matches the skeleton.
5+
> ./topic_updater.py --check
6+
7+
Update all topic files according to the skeleton.
8+
> ./topic_updater.py --update
9+
10+
Update a specific topic file according to the skeleton.
11+
> ./topic_updater.py --update functions/user-defined-literals.md
12+
"""
13+
14+
import os
15+
import typing as tp
16+
from argparse import ArgumentParser
17+
from copy import copy
18+
from pathlib import Path
19+
20+
21+
def _cli_yn_choice(question: str, default: str = 'y') -> bool:
22+
"""Ask the user to make a y/n decision on the cli."""
23+
choices = 'Y/n' if default.lower() in ('y', 'yes') else 'y/N'
24+
choice: str = str(
25+
input("{message} ({choices}) ".format(message=question,
26+
choices=choices)))
27+
values: tp.Union[tp.Tuple[str, str],
28+
tp.Tuple[str, str,
29+
str]] = ('y', 'yes',
30+
'') if choices == 'Y/n' else ('y',
31+
'yes')
32+
return choice.strip().lower() in values
33+
34+
35+
class SectionHeading:
36+
""" A header of section in the topic document. """
37+
def __init__(self, title_text: str) -> None:
38+
self.__title_text = title_text
39+
self.__meta_text: tp.List[str] = []
40+
41+
@property
42+
def header_text(self) -> str:
43+
"""Text of the section header."""
44+
return self.__title_text
45+
46+
@property
47+
def meta_text(self) -> tp.List[str]:
48+
"""Meta text of the section, represented in italics."""
49+
return self.__meta_text
50+
51+
def append_meta_text(self, meta_text_line: str) -> None:
52+
"""
53+
Append a line to the meta text section of this section heading.
54+
55+
Args:
56+
meta_text_line: new meta text line to append
57+
"""
58+
self.__meta_text.append(meta_text_line)
59+
60+
def convert_meta_text_to_lines(self) -> tp.List[str]:
61+
"""
62+
Convert the sections meta text into separate lines for the document.
63+
"""
64+
heading_lines = []
65+
heading_lines.append(os.linesep)
66+
67+
meta_text = "".join(self.meta_text)
68+
# Guarantee exactly one empty line after meta text
69+
meta_text = meta_text.rstrip() + os.linesep * 2
70+
heading_lines.append(meta_text)
71+
72+
return heading_lines
73+
74+
def __str__(self) -> str:
75+
heading_text = f"{self.header_text}\n{''.join(self.meta_text)}"
76+
return heading_text
77+
78+
79+
class Skeleton:
80+
"""
81+
The topic skeleton which defines the layout and meta text of a topic file.
82+
"""
83+
def __init__(self, skeleton_file_path: Path) -> None:
84+
self.__skeleton_file_path = skeleton_file_path
85+
self.__headings: tp.List[SectionHeading] = []
86+
self.__parse_headings()
87+
88+
@property
89+
def headings(self) -> tp.List[SectionHeading]:
90+
"""All headings in this skeleton."""
91+
return self.__headings
92+
93+
def lookup_heading(self, start_heading_line: str) -> SectionHeading:
94+
"""
95+
Looks up a heading from the skeleton.
96+
97+
Args:
98+
start_heading_line: start of full string of the heading line
99+
100+
Returns: the section heading that starts like the heading line
101+
"""
102+
for heading in self.headings:
103+
if start_heading_line.startswith(
104+
heading.header_text.split(":")[0]):
105+
return heading
106+
raise LookupError(
107+
f"Could not find heading that starts with: {start_heading_line}")
108+
109+
def get_title_heading(self) -> SectionHeading:
110+
""" The Title heading of the document. """
111+
if not self.headings[0].header_text.startswith("# "):
112+
raise AssertionError(
113+
"First heading in the skeleton was not the title.")
114+
return self.headings[0]
115+
116+
def check_if_topic_file_matches(self, topic_file_path: Path) -> bool:
117+
"""
118+
Checks if the topic file headings and meta text matches the skeleton.
119+
Prints the differences between the topic file and the skeleton, if a
120+
miss mach is detected.
121+
122+
Args:
123+
topic_file_path: path to the topic file to check
124+
125+
Returns: `True` if the topic matches, otherwise, `False`
126+
"""
127+
with open(topic_file_path, "r") as topic_file:
128+
current_heading_iter = iter(self.headings)
129+
current_heading = None
130+
expected_meta_text: tp.List[str] = []
131+
132+
for line in topic_file.readlines():
133+
if line.startswith("#"):
134+
if current_heading is not None and expected_meta_text:
135+
print("Found missing italics:")
136+
print(f"Expected: {expected_meta_text[0]}")
137+
return False
138+
139+
current_heading = next(current_heading_iter)
140+
expected_meta_text = copy(current_heading.meta_text)
141+
if line.startswith("_"):
142+
if not expected_meta_text:
143+
print("Did not expect further italics but found:")
144+
print(line)
145+
return False
146+
147+
if line == expected_meta_text[0]:
148+
expected_meta_text.pop(0)
149+
else:
150+
print("Found italics text did not match the skeleton:")
151+
print(f"Found:\n{line}")
152+
print(f"Expected:\n{expected_meta_text[0]}")
153+
return False
154+
155+
return True
156+
157+
def update_topic_meta_text(self, topic_file_path: Path) -> None:
158+
"""
159+
Update the meta text of a topic file according to this skeleton.
160+
161+
Args:
162+
topic_file_path: path to the topic file
163+
"""
164+
doc_lines = []
165+
headings_iter = iter(self.headings)
166+
167+
with open(topic_file_path, "r") as topic_file:
168+
emitting_doc_text = True
169+
for line in topic_file.readlines():
170+
if line.startswith("##"):
171+
next_heading = next(headings_iter)
172+
current_heading = self.lookup_heading(line.split(":")[0])
173+
174+
# Add headers that are completely missing
175+
while (current_heading.header_text !=
176+
next_heading.header_text):
177+
print(f"Could not find section "
178+
f"({next_heading.header_text}) before section "
179+
f"({current_heading.header_text}).")
180+
if _cli_yn_choice("Should I insert it before?"):
181+
doc_lines.append(next_heading.header_text)
182+
doc_lines.extend(
183+
next_heading.convert_meta_text_to_lines())
184+
185+
next_heading = next(headings_iter)
186+
187+
emitting_doc_text = False
188+
189+
# Write out heading
190+
doc_lines.append(line)
191+
doc_lines.extend(
192+
current_heading.convert_meta_text_to_lines())
193+
194+
elif line.startswith("#"):
195+
# Verify that the title heading has correct meta text
196+
emitting_doc_text = False
197+
next_heading = next(headings_iter)
198+
doc_lines.append(line)
199+
doc_lines.extend(
200+
self.get_title_heading().convert_meta_text_to_lines())
201+
elif line.startswith("_") or line.strip().endswith("_"):
202+
# Ignore meta lines
203+
continue
204+
elif emitting_doc_text or line != "\n":
205+
# Skip new lines if we aren't emitting normal document text
206+
emitting_doc_text = True
207+
doc_lines.append(line)
208+
209+
# Add missing section headings at the end
210+
try:
211+
while True:
212+
next_heading = next(headings_iter)
213+
doc_lines.append(next_heading.header_text + os.linesep)
214+
doc_lines.extend(next_heading.convert_meta_text_to_lines())
215+
except StopIteration:
216+
pass
217+
218+
# Remove excessive newlines
219+
doc_lines[-1] = doc_lines[-1].rstrip() + os.linesep
220+
221+
with open(topic_file_path, "w") as tmp_file:
222+
for line in doc_lines:
223+
tmp_file.write(line)
224+
225+
def __parse_headings(self) -> None:
226+
with open(self.__skeleton_file_path, "r") as skeleton_file:
227+
for line in skeleton_file.readlines():
228+
if line.startswith("#"):
229+
self.headings.append(SectionHeading(line.strip()))
230+
elif line.startswith("_") or line.strip().endswith("_"):
231+
if not self.headings:
232+
raise AssertionError(
233+
"Found italics skeleton text before first heading."
234+
)
235+
self.headings[-1].append_meta_text(line)
236+
237+
def __str__(self) -> str:
238+
skeleton_text = ""
239+
for heading in self.headings:
240+
skeleton_text += str(heading) + "\n"
241+
return skeleton_text
242+
243+
244+
def check_skeletons(skeleton: Skeleton,
245+
topic_paths: tp.Iterator[Path]) -> None:
246+
"""
247+
Check of the topics files match the skeleton.
248+
249+
Args:
250+
skeleton: base skeleton to compare the topics against
251+
topic_paths: list of paths to topic files
252+
"""
253+
for topic_path in topic_paths:
254+
if skeleton.check_if_topic_file_matches(topic_path):
255+
print(f"All meta-text in {topic_path} matched the skeleton.")
256+
257+
258+
def update_skeletons(skeleton: Skeleton,
259+
topic_paths: tp.Iterator[Path]) -> None:
260+
"""
261+
Update the topics files to match the skeleton.
262+
263+
Args:
264+
skeleton: base skeleton, used as a ground truth
265+
topic_paths: list of paths to topic files
266+
"""
267+
for path in topic_paths:
268+
skeleton.update_topic_meta_text(path)
269+
270+
271+
def main() -> None:
272+
""" Driver function for the topic fixer tool. """
273+
parser = ArgumentParser("topic_updater")
274+
parser.add_argument("--check",
275+
action="store_true",
276+
default=False,
277+
help="Check if the topic files match the skeleton.")
278+
parser.add_argument("--update",
279+
action="store_true",
280+
default=False,
281+
help="Update topic files according to the skeleton.")
282+
parser.add_argument("--skeleton-file",
283+
type=Path,
284+
default=Path("skeleton.md"),
285+
help="Provide alternative skeleton file.")
286+
parser.add_argument("topic_paths",
287+
nargs="*",
288+
default=None,
289+
type=Path,
290+
help="List of paths to topic files, if no paths "
291+
"are provided all topic files are used.")
292+
293+
args = parser.parse_args()
294+
295+
if not args.skeleton_file.exists():
296+
print(f"Could not find skeleton file {args.skeleton_file}")
297+
return
298+
skeleton = Skeleton(args.skeleton_file)
299+
300+
if not args.topic_paths:
301+
302+
def exclude_non_topic_files(path: Path) -> bool:
303+
return not (path.name == "skeleton.md"
304+
or str(path).startswith(".github"))
305+
306+
topic_paths = filter(exclude_non_topic_files,
307+
Path(".").glob("**/*.md"))
308+
else:
309+
topic_paths = args.topic_paths
310+
311+
# Verify that all topic paths exist
312+
for topic_path in topic_paths:
313+
if not topic_path.exists():
314+
print(f"Could not find topic file {topic_path}")
315+
return
316+
317+
if args.check:
318+
check_skeletons(skeleton, topic_paths)
319+
elif args.update:
320+
update_skeletons(skeleton, topic_paths)
321+
else:
322+
parser.print_help()
323+
324+
325+
if __name__ == "__main__":
326+
main()

0 commit comments

Comments
 (0)