-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathgen_justfile_reference.py
executable file
·242 lines (199 loc) · 8.76 KB
/
gen_justfile_reference.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""A utility to inject/update an HTML-format reference for a Justfile into
README.md so it's easy to keep documentation current.
"""
# TODO: RIIR
from __future__ import (absolute_import, division, print_function,
with_statement, unicode_literals)
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
__appname__ = "HTML Justfile Reference Generator"
__version__ = "0.1"
__license__ = "Apache-2.0 OR MIT"
import logging, os, re, shutil, subprocess, tempfile
from collections import OrderedDict
from textwrap import TextWrapper
RE_STRONG = re.compile(r'\*\*([^*]*?)\*\*')
RE_BACKTICKS = re.compile(r'`([^`]*?)`')
RE_HYPERLINK = re.compile(r'\[(?P<title>[^]]+)\]\((?P<url>[^)]*)\)')
RE_TARGET_BLOCK = re.compile(
r'(?P<tag_start><!-- BEGIN JUSTFILE TABLE: (?P<tid_start>\S+) -->\n*)'
r'(?P<content>.*?)'
r'(?P<tag_end>\n*<!-- END JUSTFILE TABLE: (?P<tid_end>\S+) -->)',
re.DOTALL)
RE_VARIABLE = re.compile(r'^\s*(?P<key>\S*)\s*=\s*"(?P<value>.*?)"\s*$')
RE_GROUP = re.compile(r"^#\s*--+\s+(?P<title>.*?)\s+--+\s*$")
RE_COMMAND = re.compile(r"^@?(?P<name>\S+)\s*(?P<args>[^:]*?):[^:\n]*$")
RE_VARIABLE_RAW = re.compile(
r"^(export\s*)?(?P<key>\S+)\s*=\s*(?P<value>.*?)\s*$")
log = logging.getLogger(__name__)
wrapper = TextWrapper(width=80, expand_tabs=False,
replace_whitespace=False, drop_whitespace=False,
break_long_words=False, break_on_hyphens=False)
class Row(list):
"""List subclass which can also have attributes as a convenience"""
uses_variables = False
def get_evaluated_variables(include_private=False, cwd=None):
"""Call `just --evaluate` and parse it into a list of tuples"""
results = {}
for line in subprocess.check_output(['just', '--evaluate'],
cwd=cwd).split(b'\n'):
line = line.decode('utf8').strip()
if not line or (line.startswith('_') and not include_private):
continue # Skip "private" variables
match = RE_VARIABLE.match(line)
if match:
results[match.group('key')] = match.group('value')
else:
log.warning("Unexpected line: %r", line)
return results
def parse_justfile(justfile, evaluated=None):
"""Parse a justfile into a grouped set of rows, but substitute `evaluated`
if provided.
"""
current_group = ''
description = ''
last_command = None
data = {'variables': (('Variable', 'Default Value', 'Description'),
OrderedDict()),
'commands': (('Command', 'Arguments', 'Description'),
OrderedDict())}
# Reminder: Do *not* strip. Leading whitespace is significant.
for line in justfile.split('\n'):
# Let empty lines mark boundaries of doc-comments
if not line.strip():
description = ""
continue
# Persist the most recent group header
group_match = RE_GROUP.match(line)
if group_match:
description = ""
current_group = group_match.group('title').strip()
continue
# Accumulate potential doc comments
if line.startswith('#'):
description += ' ' + line.lstrip('#').strip()
continue
# Add variables to the current group
var_match = RE_VARIABLE_RAW.match(line)
if var_match:
key, value = var_match.group('key'), var_match.group('value')
# Skip private/internal variables
if key.startswith('_'):
continue
data['variables'][1].setdefault(current_group, []).append(
Row((key, evaluated.get(key, value), description.strip())))
description = ''
continue
# Add commands to the current group
cmd_match = RE_COMMAND.match(line)
if cmd_match:
name, args = cmd_match.group('name'), cmd_match.group('args')
if not last_command:
current_group = ''
last_command = Row((name, args, description.strip()))
data['commands'][1].setdefault(current_group, []).append(
last_command)
continue
if last_command and line.startswith('\t') and (
'{{' in line or '$' in line):
last_command.uses_variables = True
# Keep the groups in the order they were discovered, but sort the entries
for d_type in data.values():
for group in d_type[1].values():
group.sort(key=lambda x: x[0])
return data
def render_table(headers, groups):
"""Render a set of rows into a table with *exactly* the formatting
I used to maintain by hand.
"""
result = "<table>\n<tr>"
for title in headers:
result += "<th>{}</th>".format(title)
result += "</tr>\n"
for title, rows in groups.items():
if title:
result += '<tr><th colspan="{}">{}</th></tr>\n'.format(
len(headers), RE_BACKTICKS.sub(r'<code>\1</code>', title))
for row in rows:
result += "<tr>\n"
for idx, cell in enumerate(row):
if cell.strip():
if cell.strip() == '+args=""':
cell = "args (optional)"
elif idx in (0, 1):
cell = '<code>{}</code>'.format(cell)
else:
cell = RE_STRONG.sub(r'<strong>\1</strong>', cell)
cell = RE_HYPERLINK.sub(r'<a href="\2">\1</a>', cell)
cell = RE_BACKTICKS.sub(r'<code>\1</code>', cell)
cell = '\n '.join(
x.strip() for x in wrapper.wrap(cell))
#if idx == 1 and row.uses_variables:
# cell += "<sub>†</sub>"
result += " <td>{}</td>\n".format(cell)
result += "</tr>\n"
result += "</table>"
return result
def update_readme(tables):
"""Update README.md with the parsed justfile tables"""
with open('README.md') as fobj:
readme = fobj.read()
def matcher(match_obj):
"""Matcher to insert/update table blocks"""
tid_start = match_obj.group('tid_start')
tid_end = match_obj.group('tid_end')
assert tid_start == tid_end, "{} != {}".format(tid_start, tid_end)
return '{}{}{}'.format(
match_obj.group('tag_start'),
render_table(*tables[tid_start]),
match_obj.group('tag_end'))
readme = RE_TARGET_BLOCK.sub(matcher, readme)
# Atomically replace the original README.md
tmp_dir = tempfile.mkdtemp(dir=os.getcwd())
tmp_path = os.path.join(tmp_dir, 'README.md')
try:
with open(tmp_path, 'w') as fobj:
fobj.write(readme)
if os.name == 'nt':
# os.rename for overwrite will fail on Windows
os.remove('README.md')
os.rename(tmp_path, 'README.md')
finally:
shutil.rmtree(tmp_dir)
def main():
"""The main entry point, compatible with setuptools entry points."""
# If we're running on Python 2, take responsibility for preventing
# output from causing UnicodeEncodeErrors. (Done here so it should only
# happen when not being imported by some other program.)
import sys
if sys.version_info.major < 3:
# pylint: disable=undefined-variable
reload(sys) # NOQA
sys.setdefaultencoding('utf-8') # pylint: disable=no-member
from argparse import ArgumentParser, RawDescriptionHelpFormatter
parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter,
description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0])
parser.add_argument('--version', action='version',
version="%%(prog)s v%s" % __version__)
parser.add_argument('-v', '--verbose', action="count",
default=2, help="Increase the verbosity. Use twice for extra effect.")
parser.add_argument('-q', '--quiet', action="count",
default=0, help="Decrease the verbosity. Use twice for extra effect.")
# Reminder: %(default)s can be used in help strings.
args = parser.parse_args()
# Set up clean logging to stderr
log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING,
logging.INFO, logging.DEBUG]
args.verbose = min(args.verbose - args.quiet, len(log_levels) - 1)
args.verbose = max(args.verbose, 0)
logging.basicConfig(level=log_levels[args.verbose],
format='%(levelname)s: %(message)s')
os.chdir(os.path.dirname(__file__))
with open(os.path.join('template', 'justfile')) as fobj:
justfile = fobj.read()
tables = parse_justfile(justfile, get_evaluated_variables(cwd='template'))
update_readme(tables)
if __name__ == '__main__':
main()
# vim: set sw=4 sts=4 expandtab :