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
|
#!/usr/bin/python3
# SPDX-License-Identifier: BSD-3-Clause
"""
This script builds the HTML documentation of the containing project, and serves
it from a trivial built-in web-server. It then watches the project source code
for changes, and rebuilds the documentation as necessary. Options are available
to specify the build output directory, the build command, and the paths to
watch for changes. Default options can be specified in the containing project's
setup.cfg under [{SETUP_SECTION}]
"""
from __future__ import annotations
import os
import sys
assert sys.version_info >= (3, 6), 'Script requires Python 3.6+'
import time
import shlex
import socket
import traceback
import typing as t
import subprocess as sp
import multiprocessing as mp
from pathlib import Path
from functools import partial
from configparser import ConfigParser
from argparse import ArgumentParser, Namespace
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
PROJECT_ROOT = Path(__file__).parent / '..'
SETUP_SECTION = str(Path(__file__).name) + ':settings'
def main(args: t.List[str] = None):
if args is None:
args = sys.argv[1:]
config = get_config(args)
queue: mp.Queue = mp.Queue()
builder_proc = mp.Process(target=builder, args=(config, queue), daemon=True)
server_proc = mp.Process(target=server, args=(config, queue), daemon=True)
builder_proc.start()
server_proc.start()
exc, value, tb = queue.get()
server_proc.terminate()
builder_proc.terminate()
traceback.print_exception(exc, value, tb)
def get_config(args: t.List[str]) -> Namespace:
config = ConfigParser(
defaults={
'command': 'make doc',
'html': 'build/html',
'watch': '',
'ignore': '\n'.join(['*.swp', '*.bak', '*~', '.*']),
'bind': '0.0.0.0',
'port': '8000',
},
delimiters=('=',), default_section=SETUP_SECTION,
empty_lines_in_values=False, interpolation=None,
converters={'list': lambda s: s.strip().splitlines()})
config.read(PROJECT_ROOT / 'setup.cfg')
sect = config[SETUP_SECTION]
# Resolve html and watch defaults relative to setup.cfg
if sect['html']:
sect['html'] = str(PROJECT_ROOT / sect['html'])
if sect['watch']:
sect['watch'] = '\n'.join(
str(PROJECT_ROOT / watch)
for watch in sect.getlist('watch')
)
parser = ArgumentParser(description=__doc__.format(**globals()))
parser.add_argument(
'html', default=sect['html'], type=Path, nargs='?',
help="The base directory (relative to the project's root) which you "
"wish to server over HTTP. Default: %(default)s")
parser.add_argument(
'-c', '--command', default=sect['command'],
help="The command to run (relative to the project root) to regenerate "
"the HTML documentation. Default: %(default)s")
parser.add_argument(
'-w', '--watch', action='append', default=sect.getlist('watch'),
help="Can be specified multiple times to append to the list of source "
"patterns (relative to the project's root) to watch for changes. "
"Default: %(default)s")
parser.add_argument(
'-i', '--ignore', action='append', default=sect.getlist('ignore'),
help="Can be specified multiple times to append to the list of "
"patterns to ignore. Default: %(default)s")
parser.add_argument(
'--bind', metavar='ADDR', default=sect['bind'],
help="The address to listen on. Default: %(default)s")
parser.add_argument(
'--port', metavar='PORT', default=sect['port'],
help="The port to listen on. Default: %(default)s")
ns = parser.parse_args(args)
ns.command = shlex.split(ns.command)
if not ns.watch:
parser.error('You must specify at least one --watch')
ns.watch = [
str(Path(watch).relative_to(Path.cwd()))
for watch in ns.watch
]
return ns
class DevRequestHandler(SimpleHTTPRequestHandler):
server_version = 'DocsPreview/1.0'
protocol_version = 'HTTP/1.0'
class DevServer(ThreadingHTTPServer):
allow_reuse_address = True
base_path = None
def get_best_family(host: t.Union[str, None], port: t.Union[str, int, None])\
-> t.Tuple[
socket.AddressFamily,
t.Union[t.Tuple[str, int], t.Tuple[str, int, int, int]]
]:
infos = socket.getaddrinfo(
host, port,
type=socket.SOCK_STREAM,
flags=socket.AI_PASSIVE)
for family, type, proto, canonname, sockaddr in infos:
return family, sockaddr
def server(config: Namespace, queue: mp.Queue = None):
try:
DevServer.address_family, addr = get_best_family(config.bind, config.port)
handler = partial(DevRequestHandler, directory=str(config.html))
with DevServer(addr[:2], handler) as httpd:
host, port = httpd.socket.getsockname()[:2]
hostname = socket.gethostname()
print(f'Serving {config.html} HTTP on {host} port {port}')
print(f'http://{hostname}:{port}/ ...')
# XXX Wait for queue message to indicate time to start?
httpd.serve_forever()
except:
if queue is not None:
queue.put(sys.exc_info())
raise
def get_stats(config: Namespace) -> t.Dict[Path, os.stat_result]:
return {
path: path.stat()
for watch_pattern in config.watch
for path in Path('.').glob(watch_pattern)
if not any(path.match(ignore_pattern)
for ignore_pattern in config.ignore)
}
def get_changes(old_stats: t.Dict[Path, os.stat_result],
new_stats: t.Dict[Path, os.stat_result])\
-> t.Tuple[t.Set[Path], t.Set[Path], t.Set[Path]]:
# Yes, this is crude and could be more efficient but it's fast enough on a
# Pi so it'll be fast enough on anything else
return (
new_stats.keys() - old_stats.keys(), # new
old_stats.keys() - new_stats.keys(), # deleted
{ # modified
filepath
for filepath in old_stats.keys() & new_stats.keys()
if new_stats[filepath].st_mtime > old_stats[filepath].st_mtime
}
)
def rebuild(config: Namespace) -> t.Dict[Path, os.stat_result]:
print('Rebuilding...')
sp.run(config.command, cwd=PROJECT_ROOT)
return get_stats(config)
def builder(config: Namespace, queue: mp.Queue = None):
try:
old_stats = rebuild(config)
print('Watching for changes in:')
print('\n'.join(config.watch))
# XXX Add some message to the queue to indicate first build done and
# webserver can start? And maybe launch webbrowser too?
while True:
new_stats = get_stats(config)
created, deleted, modified = get_changes(old_stats, new_stats)
if created or deleted or modified:
for filepath in created:
print(f'New file, {filepath}')
for filepath in deleted:
print(f'Deleted file, {filepath}')
for filepath in modified:
print(f'Changed detected in {filepath}')
old_stats = rebuild(config)
else:
time.sleep(0.5) # make sure we're not a busy loop
except:
if queue is not None:
queue.put(sys.exc_info())
raise
if __name__ == '__main__':
sys.exit(main())
|