stahg-gopher.py - stahg-gopher - Static Mercurial page generator for gopher
(HTM) hg clone https://bitbucket.org/iamleot/stahg-gopher
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) README
(DIR) LICENSE
---
stahg-gopher.py
---
1 #!/usr/bin/env python3.7
2
3
4 import os
5 import shutil
6 import stat
7
8 import hglib
9
10
11 LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'COPYING']
12 README_FILES = ['README', 'README.md']
13
14
15 def gph_escape_entry(text):
16 """Render text entry `[...]' by escaping/translating characters"""
17 escaped_text = text.expandtabs().replace('|', '\\|')
18
19 return escaped_text
20
21
22 def gph_escape_text(text):
23 """Render text to .gph by escaping/translating characters"""
24 escaped_text = []
25
26 for line in text.expandtabs().splitlines():
27 # add leading 't' if needed
28 if len(line) > 0 and line[0] == 't':
29 line = 't' + line
30
31 escaped_text.append(line)
32
33 return '\n'.join(escaped_text)
34
35
36 def shorten(text, n=80):
37 """Shorten text to the first `n' character of first line"""
38 s, _, _ = text.partition('\n')
39
40 if len(s) > n:
41 s = s[:n - 1] + '…'
42
43 return s
44
45
46 def rshorten(text, n=80):
47 """Shorten text to the last `n' character of first line"""
48 s, _, _ = text.partition('\n')
49
50 if len(s) > n:
51 s = '…' + s[- (n - 1):]
52
53 return s
54
55
56 def author_name(author):
57 """Given an author `Name <email>' extract their name"""
58 name, _, _ = author.rpartition(' <')
59
60 return name
61
62
63 def author_email(author):
64 """Given an author `Name <email>' extract their email"""
65 _, _, email = author.rpartition(' <')
66 email = email.rstrip('>')
67
68 return email
69
70
71 class Stahg:
72 def __init__(self, base_prefix='', limit=None):
73 self.base_prefix = base_prefix
74 self.client = None
75 self.description = ''
76 self.license = None
77 self.limit = limit
78 self.readme = None
79 self.repodir = ''
80 self.repository = ''
81 self.url = ''
82
83
84 def open(self, repodir):
85 """Open repository in repodir"""
86 self.repodir = os.path.normpath(repodir)
87 self.client = hglib.open(self.repodir)
88 self.base_prefix = base_prefix
89 self.repository = os.path.basename(self.repodir)
90
91 try:
92 for _, k, value in self.client.config([b'web']):
93 if k.decode() == 'description':
94 self.description = value.decode()
95 elif k.decode() == 'url':
96 self.url = value.decode()
97 except:
98 self.description = \
99 "Unnamed repository, adjust .hg/hgrc `[web]' section, `description' key"
100
101 # XXX: For repository with a lot of files this is suboptimal...
102 # XXX: Is there a simpler way to check for that?
103 for e in self.client.manifest(rev=b'tip'):
104 fpath = e[4].decode()
105
106 # file paths are sorted, break as soon as possible
107 if fpath > max(LICENSE_FILES) and fpath > max(README_FILES):
108 break
109
110 if fpath in LICENSE_FILES:
111 self.license = fpath
112 if fpath in README_FILES:
113 self.readme = fpath
114
115
116 def close(self):
117 """Close repository"""
118 self.client.close()
119
120
121 def menu(self):
122 """Generate menu for .gph files"""
123 bp = gph_escape_entry(self.base_prefix)
124
125 m = ''
126
127 if self.url:
128 m += '[h|{desc}|{path}|server|port]\n'.format(
129 desc=gph_escape_entry('hg clone {url}'.format(url=self.url)),
130 path='URL:{url}'.format(url=self.url))
131
132 m += '[1|Log|' + bp + '/log.gph|server|port]\n' + \
133 '[1|Files|' + bp + '/files.gph|server|port]\n' + \
134 '[1|Refs|' + bp + '/refs.gph|server|port]'
135
136 if self.readme:
137 m += '\n[1|README|' + bp + '/file/{file}.gph|server|port]'.format(
138 file=self.readme)
139
140 if self.license:
141 m += '\n[1|LICENSE|' + bp + '/file/{file}.gph|server|port]'.format(
142 file=self.license)
143
144 return m
145
146
147 def title(self, text):
148 """Generate title for .gph files"""
149 return gph_escape_text(
150 ' - '.join([text, self.repository, self.description]))
151
152
153 def log(self):
154 """Generate log.gph with latest commits"""
155 bp = gph_escape_entry(self.base_prefix)
156 fname = 'log.gph'
157
158 with open(fname, 'w') as f:
159 print(self.title('Log'), file=f)
160 print(self.menu(), file=f)
161 print('---', file=f)
162
163 print('{:16} {:40} {:20}'.format(
164 'Date', 'Commit message', 'Author').strip(), file=f)
165 for i, e in enumerate(self.client.log()):
166 if self.limit and i > self.limit:
167 print(' More commits remaining [...]',
168 file=f)
169 break
170 print('[1|{desc}|{path}|server|port]'.format(
171 desc=gph_escape_entry(
172 '{date:16} {commit_message:40} {author:20}'.format(
173 date=e.date.strftime('%Y-%m-%d %H:%M'),
174 commit_message=shorten(e.desc.decode(), 40),
175 author=shorten(author_name(e.author.decode()), 20),
176 ).strip()),
177 path='{base_path}/commit/{changeset}.gph'.format(
178 base_path=bp,
179 changeset=e.node.decode())), file=f)
180
181
182 def files(self):
183 """Generate files.gph with links to all files in `tip'"""
184 bp = gph_escape_entry(self.base_prefix)
185 fname = 'files.gph'
186
187 with open(fname, 'w') as f:
188 print(self.title('Files'), file=f)
189 print(self.menu(), file=f)
190 print('---', file=f)
191
192 print('{:10} {:68}'.format('Mode', 'Name').strip(), file=f)
193
194 for e in self.client.manifest(rev=b'tip'):
195 print('[1|{desc}|{path}|server|port]'.format(
196 desc=gph_escape_entry('{mode:10} {name:68}'.format(
197 mode='-' + stat.filemode(int(e[1].decode(), base=8))[1:],
198 name=e[4].decode()).strip()),
199 path=gph_escape_entry('{base_path}/file/{file}.gph'.format(
200 base_path=bp,
201 file=e[4].decode()))), file=f)
202
203
204 def refs(self):
205 """Generate refs.gph listing all branches and tags"""
206 fname = 'refs.gph'
207
208 with open(fname, 'w') as f:
209 print(self.title('Files'), file=f)
210 print(self.menu(), file=f)
211 print('---', file=f)
212
213 print('Branches', file=f)
214 print(' {:32} {:16} {:26}'.format(
215 'Name', 'Last commit date', 'Author').rstrip(), file=f)
216 for name, _, changeset in self.client.branches():
217 print(
218 gph_escape_text(' {name:32} {date:16} {author:26}'.format(
219 name=shorten(name.decode(), 32),
220 date=self.client[changeset].date().strftime('%Y-%m-%d %H:%M'),
221 author=shorten(author_name(self.client[changeset].author().decode()), 26)
222 ).rstrip()),
223 file=f)
224
225 print(file=f)
226
227 print('Tags', file=f)
228 print(' {:32} {:16} {:26}'.format(
229 'Name', 'Last commit date', 'Author').rstrip(), file=f)
230 for name, _, changeset, _ in self.client.tags():
231 print(
232 gph_escape_text(' {name:32} {date:16} {author:26}'.format(
233 name=shorten(name.decode(), 32),
234 date=self.client[changeset].date().strftime('%Y-%m-%d %H:%M'),
235 author=shorten(author_name(self.client[changeset].author().decode()), 26)
236 ).rstrip()),
237 file=f)
238
239
240 def commit(self, changeset):
241 """Generate commit/<changeset>.gph with commit message and diff"""
242 bp = gph_escape_entry(self.base_prefix)
243 c = self.client[changeset]
244 fname = 'commit/{changeset}.gph'.format(changeset=c.node().decode())
245
246 with open(fname, 'w') as f:
247 print(self.title(shorten(c.description().decode(), 80)), file=f)
248 print(self.menu(), file=f)
249 print('---', file=f)
250
251 print('[1|{desc}|{path}|server|port]'.format(
252 desc='changeset {changeset}'.format(changeset=c.node().decode()),
253 path='{base_path}/commit/{changeset}.gph'.format(
254 base_path=bp,
255 changeset=c.node().decode())), file=f)
256
257 for p in c.parents():
258 if p.node() == b'0000000000000000000000000000000000000000':
259 continue
260 print('[1|{desc}|{path}|server|port]'.format(
261 desc='parent {changeset}'.format(changeset=p.node().decode()),
262 path='{base_path}/commit/{changeset}.gph'.format(
263 base_path=bp,
264 changeset=p.node().decode())), file=f)
265
266 print('[h|Author: {author}|URL:mailto:{email}|server|port]'.format(
267 author=gph_escape_entry(c.author().decode()),
268 email=gph_escape_entry(author_email(c.author().decode()))), file=f)
269
270 print('Date: {date}'.format(
271 date=c.date().strftime('%a, %e %b %Y %H:%M:%S %z')), file=f)
272
273 print(file=f)
274 print(gph_escape_text(c.description().decode()), file=f)
275 print(file=f)
276
277 print('Diffstat:', file=f)
278 print(gph_escape_text(self.client.diff(change=c.node(), stat=True).decode().rstrip()),
279 file=f)
280 print('---', file=f)
281
282 print(gph_escape_text(self.client.diff(change=c.node()).decode()),
283 file=f)
284
285
286 def file(self, file):
287 """Generate file/<file>.gph listing <file> at `tip'"""
288 fname = 'file/{file}.gph'.format(file=file.decode())
289 os.makedirs(os.path.dirname(fname), exist_ok=True)
290
291 with open(fname, 'w') as f:
292 print(self.title(os.path.basename(file.decode())), file=f)
293 print(self.menu(), file=f)
294 print('---', file=f)
295
296 print('{filename}'.format(
297 filename=os.path.basename(file.decode())), file=f)
298 print('---', file=f)
299
300 files = [self.client.root() + os.sep.encode() + file]
301 try:
302 content = self.client.cat(files).decode()
303 for num, line in enumerate(content.splitlines(), start=1):
304 print(gph_escape_text('{num:6d} {line}'.format(
305 num=num,
306 line=line.expandtabs())), file=f)
307 except:
308 print('Binary file.', file=f)
309
310
311 if __name__ == '__main__':
312 import getopt
313 import sys
314
315 def usage():
316 print('usage: {} [-b baseprefix] [-l commits] repodir'.format(
317 sys.argv[0]))
318 exit(1)
319
320 try:
321 opts, args = getopt.getopt(sys.argv[1:], 'b:l:')
322 except:
323 usage()
324
325 if len(args) != 1:
326 usage()
327
328 base_prefix = ''
329 limit = None
330 for o, a in opts:
331 if o == '-b':
332 base_prefix = a
333 elif o == '-l':
334 limit = int(a)
335
336 repodir = args[0]
337
338 sh = Stahg(base_prefix=base_prefix, limit=limit)
339 sh.open(repodir)
340
341 sh.log()
342 sh.files()
343 sh.refs()
344
345 if not os.path.exists('commit/{changeset}.gph'.format(
346 changeset=sh.client['tip'].node().decode())):
347 shutil.rmtree('file', ignore_errors=True)
348 os.makedirs('file', exist_ok=True)
349 for e in sh.client.manifest(rev=b'tip'):
350 sh.file(e[4])
351
352 os.makedirs('commit', exist_ok=True)
353 for e in sh.client.log():
354 if os.path.exists('commit/{changeset}.gph'.format(changeset=e.node.decode())):
355 break
356 sh.commit(e.node)