moved from Flask to http.server to support multiple clients

This commit is contained in:
2018-09-19 07:31:02 +01:00
parent 514b50ff88
commit 482fdc3da9

View File

@@ -1,16 +1,20 @@
#!/usr/bin/env python3
import argparse
import hashlib
import sys
import threading
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from os.path import basename, dirname, abspath
from urllib.parse import urlparse
import markdown
from os.path import basename, dirname
import json
TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<link href="http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.0/css/bootstrap-combined.min.css" rel="stylesheet">
{css}
<style>
body {{
font-family: sans-serif;
@@ -30,10 +34,25 @@ TEMPLATE = """
</head>
<body>
<script type=\"text/javascript\">
let eventSource = new EventSource("/stream");
eventSource.addEventListener('reload', function(e) {{
window.location.reload(true);
}});
function req(first) {{
var xmlhttp = new XMLHttpRequest();
xmlhttp.onload = function() {{
if (xmlhttp.status == 200) {{
document.querySelector("div.container").innerHTML = xmlhttp.responseText;
}} else if(xmlhttp.status == 304) {{
}} else {{
console.log(xmlhttp.status, xmlhttp.statusText);
}}
req(false);
}};
xmlhttp.onerror = function() {{
console.log(xmlhttp.status, xmlhttp.statusText);
setTimeout(req, 1000, false);
}};
xmlhttp.open("GET", first ? "/markdown" : "/reload", true);
xmlhttp.send();
}}
req(true);
</script>
<div class="container">
{content}
@@ -42,20 +61,144 @@ TEMPLATE = """
</html>
"""
def create_css_tag(url_list):
result = ''
for url in url_list:
result += '<link href="%s" rel="stylesheet">' % url
return result
class ServerSentEvent(object):
def compile_html(mdfile=None, extensions=None, raw=None, stylesheet=(), **kwargs):
html = None
with mdfile and open(mdfile, 'r') or sys.stdin as instream:
html = markdown.markdown(instream.read(), extensions=extensions, output_format='html5')
if raw:
doc = html
else:
doc = TEMPLATE.format(content=html, css=create_css_tag(stylesheet))
return doc
def __init__(self, id=None, event=None, data=None, retry=1000):
self.id = id
self.event = event
self.data = json.dumps(data)
self.retry = retry
class MarkdownHTTPServer(ThreadingHTTPServer):
def encode(self):
if not self.data:
return ""
lines = [f"{key}: {value}" for key, value in vars(self).items() if value]
return "%s\n\n" % "\n".join(lines)
def __init__(self, mdfile, extensions=(), stylesheet=(), handler=BaseHTTPRequestHandler, interface="127.0.0.1", port=8080):
import inotify
import inotify.adapters
import signal
self.stop = False
def sigint_handler(signum, frame):
self.stop = True
handlers = (sigint_handler, signal.getsignal(signal.SIGINT))
signal.signal(signal.SIGINT, lambda signum, frame: [handler(signum, frame) for handler in handlers])
self.mdfile = mdfile
self.extensions = extensions
self.stylesheet = stylesheet
self.condition_variable = threading.Condition()
self.hash = None
self.etag = None
def watch_file():
watcher = inotify.adapters.Inotify()
watcher.add_watch(dirname(abspath(self.mdfile)))
target_file = basename(self.mdfile)
while True:
if self.stop:
break
for event in watcher.event_gen(yield_nones=True, timeout_s=1):
if not event:
continue
(_, event_type, path, filename) = event
if filename == target_file and len(set(event_type).intersection(
{'IN_CLOSE_WRITE'})):
self.condition_variable.acquire()
if self.update_file_digest():
self.condition_variable.notify_all()
self.condition_variable.release()
file_watcher = threading.Thread(target=watch_file)
file_watcher.start()
super().__init__((interface, port), handler)
def update_file_digest(self):
md5 = hashlib.md5()
with open(self.mdfile, 'rb') as mdfile:
md5.update(mdfile.read())
digest = md5.digest()
if not self.hash or self.hash != digest:
self.hash = digest
self.etag = md5.hexdigest()
return True
else:
return False
class MarkdownRequestHandler(BaseHTTPRequestHandler):
status_map = {
200: "OK",
204: "No Content",
304: "Not Modified",
400: "Bad Request",
401: "Unauthorized",
404: "Not Found",
499: "Service Error",
500: "Internal Server Error",
501: "Not Implemented",
503: "Service Unavailable"
}
def answer(self, code, reply=None, content_type="text/plain",
headers=()):
output = self.wfile
if not reply:
reply = MarkdownRequestHandler.status_map[code]
try:
self.send_response(code, MarkdownRequestHandler.status_map[code])
for header in headers:
self.send_header(*header)
self.send_header("Content-Type", content_type)
self.send_header('Content-Length', len(reply))
self.end_headers()
output.write(reply.encode("UTF-8"))
output.flush()
except BrokenPipeError:
pass
def markdown_answer(self):
if not self.server.etag:
self.server.condition_variable.acquire()
self.server.update_file_digest()
self.server.condition_variable.release()
self.answer(200, headers=(('Etag', self.server.etag),),
reply=compile_html(mdfile=self.server.mdfile, extensions=self.server.extensions, raw=True),
content_type='text/html')
def do_GET(self):
path = urlparse(self.path)
if path.path == '/':
self.answer(200, reply=TEMPLATE.format(content='', css=create_css_tag(self.server.stylesheet)),
content_type='text/html')
elif path.path == '/markdown':
self.markdown_answer()
elif path.path == '/reload':
if 'If-None-Match' not in self.headers or self.headers['If-None-Match'] != self.server.etag:
self.markdown_answer()
else:
self.server.condition_variable.acquire()
self.server.condition_variable.wait(timeout=10)
self.server.condition_variable.release()
if self.server.stop:
self.answer(503)
elif self.headers['If-None-Match'] == self.server.etag:
self.answer(304)
else:
self.answer(200, headers=(('Etag', self.server.etag),),
reply=compile_html(mdfile=self.server.mdfile,
extensions=self.server.extensions,
raw=True),
content_type='text/html')
else:
self.answer(404)
def parse_args(args=None):
parser = argparse.ArgumentParser(description='Make a complete, styled HTML document from a Markdown file.')
@@ -68,7 +211,6 @@ def parse_args(args=None):
try:
import inotify
import flask
import gevent
import signal
parser.add_argument('-w', '--watch', action='store_true',
@@ -77,77 +219,27 @@ def parse_args(args=None):
help='Specify http server port (defaults to 5000)')
parser.add_argument('-i', '--interface', default='',
help='Specify http server listen interface (defaults to localhost)')
parser.add_argument('-s', '--stylesheet', nargs='+',
default=['http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.0/css/bootstrap-combined.min.css'],
help='Specify a list of stylesheet URLs to add to the html page')
except ImportError:
pass
return parser.parse_args(args)
def compile_html(mdfile=None, extensions=None, raw=None, **kwargs):
html = None
with mdfile and open(mdfile, 'r') or sys.stdin as instream:
html = markdown.markdown(instream.read(), extensions=extensions, output_format='html5')
if raw:
doc = html
else:
doc = TEMPLATE.format(**dict(content=html))
return doc
def write_html(out=None, **kwargs):
doc = compile_html(**kwargs)
with (out and open(out, 'w')) or sys.stdout as outstream:
outstream.write(doc)
def main(args=None):
import signal
args = parse_args(args)
exit = False
def sigint_handler(signum, frame):
nonlocal exit
exit = True
handlers = (sigint_handler, signal.getsignal(signal.SIGINT))
signal.signal(signal.SIGINT, lambda signum, frame: [handler(signum, frame) for handler in handlers])
if hasattr(args, 'watch') and args.watch:
import threading
from flask import Flask, Response
from gevent.pywsgi import WSGIServer
condition_variable = threading.Condition()
def watch_file():
import inotify.adapters
nonlocal condition_variable, exit
watcher = inotify.adapters.Inotify()
watcher.add_watch(dirname(args.mdfile))
target_file = basename(args.mdfile)
while True:
if exit:
break
for event in watcher.event_gen(yield_nones=True, timeout_s=1):
if not event:
continue
(_, event_type, path, filename) = event
if filename == target_file and len(set(event_type).intersection(
{'IN_CREATE', 'IN_MODIFY', 'IN_CLOSE_WRITE'})):
condition_variable.acquire()
condition_variable.notify_all()
condition_variable.release()
file_watcher = threading.Thread(target=watch_file)
file_watcher.start()
app = Flask(__name__)
@app.route('/')
def get():
return Response(compile_html(**vars(args)), mimetype='text/html')
@app.route("/stream")
def stream():
nonlocal condition_variable
def gen():
while True:
condition_variable.acquire()
condition_variable.wait()
sse = ServerSentEvent(event='reload')
return sse.encode()
return Response(gen(), mimetype="text/event-stream")
server = WSGIServer((args.interface, args.port), app, environ={'wsgi.multithread': True})
server = MarkdownHTTPServer(args.mdfile,
extensions=args.extensions,
stylesheet=args.stylesheet,
interface=args.interface,
port=args.port,
handler=MarkdownRequestHandler)
server.serve_forever()
else:
write_html(**vars(args))