stahg-gopher.py - stahg-gopher - Static Mercurial page generator for gopher HTML 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)