moved from Flask to http.server to support multiple clients
This commit is contained in:
@@ -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))
|
||||||
|
Reference in New Issue
Block a user