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)