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 #!/usr/bin/env python3
import argparse import argparse
import hashlib
import sys import sys
import threading
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from os.path import basename, dirname, abspath
from urllib.parse import urlparse
import markdown import markdown
from os.path import basename, dirname
import json
TEMPLATE = """ TEMPLATE = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link href="http://netdna.bootstrapcdn.com/twitter-bootstrap/2.3.0/css/bootstrap-combined.min.css" rel="stylesheet"> {css}
<style> <style>
body {{ body {{
font-family: sans-serif; font-family: sans-serif;
@@ -30,10 +34,25 @@ TEMPLATE = """
</head> </head>
<body> <body>
<script type=\"text/javascript\"> <script type=\"text/javascript\">
let eventSource = new EventSource("/stream"); function req(first) {{
eventSource.addEventListener('reload', function(e) {{ var xmlhttp = new XMLHttpRequest();
window.location.reload(true); 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> </script>
<div class="container"> <div class="container">
{content} {content}
@@ -42,20 +61,144 @@ TEMPLATE = """
</html> </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): class MarkdownHTTPServer(ThreadingHTTPServer):
self.id = id
self.event = event
self.data = json.dumps(data)
self.retry = retry
def encode(self): def __init__(self, mdfile, extensions=(), stylesheet=(), handler=BaseHTTPRequestHandler, interface="127.0.0.1", port=8080):
if not self.data: import inotify
return "" import inotify.adapters
lines = [f"{key}: {value}" for key, value in vars(self).items() if value] import signal
return "%s\n\n" % "\n".join(lines)
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): def parse_args(args=None):
parser = argparse.ArgumentParser(description='Make a complete, styled HTML document from a Markdown file.') parser = argparse.ArgumentParser(description='Make a complete, styled HTML document from a Markdown file.')
@@ -68,7 +211,6 @@ def parse_args(args=None):
try: try:
import inotify import inotify
import flask
import gevent import gevent
import signal import signal
parser.add_argument('-w', '--watch', action='store_true', 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)') help='Specify http server port (defaults to 5000)')
parser.add_argument('-i', '--interface', default='', parser.add_argument('-i', '--interface', default='',
help='Specify http server listen interface (defaults to localhost)') 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: except ImportError:
pass pass
return parser.parse_args(args) 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): def write_html(out=None, **kwargs):
doc = compile_html(**kwargs) doc = compile_html(**kwargs)
with (out and open(out, 'w')) or sys.stdout as outstream: with (out and open(out, 'w')) or sys.stdout as outstream:
outstream.write(doc) outstream.write(doc)
def main(args=None): def main(args=None):
import signal
args = parse_args(args) 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: if hasattr(args, 'watch') and args.watch:
import threading server = MarkdownHTTPServer(args.mdfile,
from flask import Flask, Response extensions=args.extensions,
from gevent.pywsgi import WSGIServer stylesheet=args.stylesheet,
condition_variable = threading.Condition() interface=args.interface,
def watch_file(): port=args.port,
import inotify.adapters handler=MarkdownRequestHandler)
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.serve_forever() server.serve_forever()
else: else:
write_html(**vars(args)) write_html(**vars(args))