Add initial implementation of Python helpers and CLI tools
- Create .gitignore to exclude build artifacts - Add README.md with project description and installation instructions - Implement pyproject.toml for package metadata and script entry points - Add console output helpers for command-line scripts - Implement environment file loading functionality - Add JSON file I/O helpers - Create token decoder CLI for decoding JWT claims - Implement serve-markdown CLI for serving Markdown files as HTML
This commit is contained in:
Executable
+335
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Serve Markdown files under a directory as GitHub-styled HTML pages over a local web server."""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import http.server
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from python_helpers.console import COLOR_GREEN, COLOR_RESET, COLOR_YELLOW
|
||||
|
||||
GITHUB_MARKDOWN_CSS_URL = "https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css"
|
||||
MARKED_JS_URL = "https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"
|
||||
DOMPURIFY_JS_URL = "https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"
|
||||
HIGHLIGHT_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
|
||||
HIGHLIGHT_CSS_LIGHT_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css"
|
||||
HIGHLIGHT_CSS_DARK_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
|
||||
DEFAULT_LISTEN_ADDRESS = "127.0.0.1"
|
||||
DEFAULT_SERVE_PORT = 8000
|
||||
DEFAULT_CANDIDATES = ["index.md", "README.md"]
|
||||
DEFAULT_WATCH_INTERVAL_SECONDS = 1.0
|
||||
PYDOC_PREFIX = "pydoc:"
|
||||
|
||||
|
||||
class MarkdownServeError(Exception):
|
||||
"""A user-facing error."""
|
||||
|
||||
|
||||
def fail(message):
|
||||
"""Raise a MarkdownServeError with message.
|
||||
|
||||
Args:
|
||||
message: error text.
|
||||
"""
|
||||
raise MarkdownServeError(message)
|
||||
|
||||
|
||||
def find_index_file(directory):
|
||||
"""Return the first of DEFAULT_CANDIDATES that exists as a file under directory, or None.
|
||||
|
||||
Args:
|
||||
directory: directory to look in.
|
||||
"""
|
||||
return next(
|
||||
(path for path in (directory / candidate for candidate in DEFAULT_CANDIDATES) if path.is_file()),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_route(route, root_dir, default_file):
|
||||
"""Resolve a URL route to a Markdown file under root_dir, or None if it cannot be served.
|
||||
|
||||
Args:
|
||||
route: URL path, e.g. "/", "/mypage.md", "/hello/", "/hello/notes.md".
|
||||
root_dir: directory routes are resolved relative to.
|
||||
default_file: file to use for the "/" route, or None to fall back to DEFAULT_CANDIDATES.
|
||||
"""
|
||||
relative = urllib.parse.unquote(route).lstrip("/")
|
||||
if relative == "":
|
||||
return default_file if default_file is not None else find_index_file(root_dir)
|
||||
|
||||
if relative.endswith("/"):
|
||||
return find_index_file(root_dir / relative)
|
||||
|
||||
candidate = root_dir / relative
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
resolved.relative_to(root_dir.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return resolved if resolved.is_file() else None
|
||||
|
||||
|
||||
def render_pydoc_markdown(module_name):
|
||||
"""Render a dotted Python module name to a Markdown string using pydoc-markdown.
|
||||
|
||||
Args:
|
||||
module_name: dotted module name, e.g. "cloud_tools.az".
|
||||
"""
|
||||
try:
|
||||
from pydoc_markdown import PydocMarkdown
|
||||
from pydoc_markdown.contrib.loaders.python import PythonLoader
|
||||
except ImportError:
|
||||
fail("pydoc-markdown is not installed; install it to use pydoc: module paths")
|
||||
|
||||
session = PydocMarkdown()
|
||||
session.loaders = [PythonLoader(modules=[module_name])]
|
||||
modules = session.load_modules()
|
||||
session.process(modules)
|
||||
return session.renderer.render_to_string(modules)
|
||||
|
||||
|
||||
def render_page(title, content_query, watch_interval_ms):
|
||||
"""Build the HTML page shell that polls /content and renders it as Markdown.
|
||||
|
||||
Args:
|
||||
title: page title.
|
||||
content_query: query string (including leading '?') appended to the /content request.
|
||||
watch_interval_ms: milliseconds between polls of /content.
|
||||
"""
|
||||
body = f"""<article id="content" class="markdown-body">Loading...</article>
|
||||
<script>
|
||||
let lastMtime = null;
|
||||
function render(text) {{
|
||||
const target = document.getElementById("content");
|
||||
if (window.marked && window.DOMPurify) {{
|
||||
target.innerHTML = DOMPurify.sanitize(marked.parse(text, {{gfm: true}}));
|
||||
if (window.hljs) {{
|
||||
target.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
|
||||
}}
|
||||
}} else {{
|
||||
const pre = document.createElement("pre");
|
||||
pre.textContent = text;
|
||||
target.innerHTML = "";
|
||||
target.appendChild(pre);
|
||||
}}
|
||||
}}
|
||||
async function poll() {{
|
||||
let data;
|
||||
try {{
|
||||
data = await (await fetch("/content{content_query}")).json();
|
||||
}} catch (error) {{
|
||||
return;
|
||||
}}
|
||||
if (data.mtime !== null && data.mtime !== lastMtime) {{
|
||||
lastMtime = data.mtime;
|
||||
render(data.text);
|
||||
}} else if (data.mtime === null) {{
|
||||
render("(file not found: " + data.error + ")");
|
||||
}}
|
||||
}}
|
||||
poll();
|
||||
setInterval(poll, {watch_interval_ms});
|
||||
</script>"""
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="stylesheet" href="{GITHUB_MARKDOWN_CSS_URL}">
|
||||
<link rel="stylesheet" href="{HIGHLIGHT_CSS_LIGHT_URL}" media="(prefers-color-scheme: light)">
|
||||
<link rel="stylesheet" href="{HIGHLIGHT_CSS_DARK_URL}" media="(prefers-color-scheme: dark)">
|
||||
<script src="{MARKED_JS_URL}"></script>
|
||||
<script src="{DOMPURIFY_JS_URL}"></script>
|
||||
<script src="{HIGHLIGHT_JS_URL}"></script>
|
||||
<style>
|
||||
body {{ margin: 0; background-color: #ffffff; }}
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
body {{ background-color: #0d1117; }}
|
||||
}}
|
||||
.markdown-body {{ box-sizing: border-box; max-width: 980px; margin: 0 auto; padding: 45px; }}
|
||||
.markdown-body pre {{ white-space: pre; overflow-x: auto; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>
|
||||
""".encode("utf-8")
|
||||
|
||||
|
||||
class MarkdownRequestHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Serves a page shell for any Markdown file under root_dir, and its live content at /content.
|
||||
|
||||
root_dir, default_file, and watch_interval_ms are set as class attributes before the server
|
||||
starts, since HTTPServer instantiates request handlers itself with a fixed signature.
|
||||
"""
|
||||
|
||||
root_dir = None
|
||||
default_file = None
|
||||
pydoc_module = None
|
||||
watch_interval_ms = 0
|
||||
|
||||
def send_json(self, payload):
|
||||
content_bytes = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content_bytes)
|
||||
|
||||
def send_not_found(self, message):
|
||||
body = message.encode("utf-8")
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
|
||||
if self.pydoc_module is not None:
|
||||
if parsed.path == "/content":
|
||||
self.send_json({"mtime": time.time(), "text": render_pydoc_markdown(self.pydoc_module), "error": None})
|
||||
return
|
||||
content_query = "?" + urllib.parse.urlencode({"path": parsed.path})
|
||||
page_bytes = render_page(self.pydoc_module, content_query, self.watch_interval_ms)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(page_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(page_bytes)
|
||||
return
|
||||
|
||||
if parsed.path == "/content":
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
route = query.get("path", [""])[0]
|
||||
markdown_path = resolve_route(route, self.root_dir, self.default_file)
|
||||
if markdown_path is None:
|
||||
self.send_json({"mtime": None, "text": None, "error": f"no such file for route '{route}'"})
|
||||
return
|
||||
try:
|
||||
mtime = markdown_path.stat().st_mtime
|
||||
text = markdown_path.read_text(encoding="utf-8")
|
||||
self.send_json({"mtime": mtime, "text": text, "error": None})
|
||||
except OSError as error:
|
||||
self.send_json({"mtime": None, "text": None, "error": str(error)})
|
||||
return
|
||||
|
||||
markdown_path = resolve_route(parsed.path, self.root_dir, self.default_file)
|
||||
if markdown_path is None:
|
||||
self.send_not_found(f"no Markdown file found for '{parsed.path}'")
|
||||
return
|
||||
|
||||
content_query = "?" + urllib.parse.urlencode({"path": parsed.path})
|
||||
page_bytes = render_page(markdown_path.name, content_query, self.watch_interval_ms)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(page_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(page_bytes)
|
||||
|
||||
def log_message(self, format_str, *log_args):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point: parse args, resolve the document root, and serve Markdown files under it."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render Markdown files under a directory as GitHub-styled pages and serve them locally."
|
||||
)
|
||||
parser.add_argument(
|
||||
"path",
|
||||
nargs="?",
|
||||
help=(
|
||||
"Markdown file or directory to serve; defaults to the current directory. "
|
||||
"A directory is served at its own URL path, resolving to index.md or README.md within it. "
|
||||
f"Prefix with '{PYDOC_PREFIX}' and a dotted module name (e.g. '{PYDOC_PREFIX}cloud_tools.az') "
|
||||
"to render that module's docs with pydoc-markdown instead"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--listen-address",
|
||||
default=DEFAULT_LISTEN_ADDRESS,
|
||||
help=f"Address for the local web server to listen on (default: {DEFAULT_LISTEN_ADDRESS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=DEFAULT_SERVE_PORT,
|
||||
help=f"Port for the local web server (default: {DEFAULT_SERVE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--watch-interval",
|
||||
type=float,
|
||||
default=DEFAULT_WATCH_INTERVAL_SECONDS,
|
||||
help=(
|
||||
"Seconds between checks for changes to the file, polled by the browser page "
|
||||
f"(default: {DEFAULT_WATCH_INTERVAL_SECONDS})"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
default_file = None
|
||||
pydoc_module = None
|
||||
source_description = None
|
||||
if args.path and args.path.startswith(PYDOC_PREFIX):
|
||||
pydoc_module = args.path[len(PYDOC_PREFIX):]
|
||||
if not pydoc_module:
|
||||
fail("pydoc: requires a module name, e.g. 'pydoc:cloud_tools.az'")
|
||||
render_pydoc_markdown(pydoc_module)
|
||||
root_dir = None
|
||||
source_description = f"pydoc module '{pydoc_module}'"
|
||||
elif args.path:
|
||||
given_path = Path(args.path)
|
||||
if given_path.is_file():
|
||||
root_dir = given_path.parent
|
||||
default_file = given_path
|
||||
elif given_path.is_dir():
|
||||
root_dir = given_path
|
||||
else:
|
||||
fail(f"'{given_path}' does not exist or is not a file or directory")
|
||||
else:
|
||||
root_dir = Path.cwd()
|
||||
|
||||
if pydoc_module is None:
|
||||
if default_file is None and find_index_file(root_dir) is None:
|
||||
fail(f"no file given and none of {', '.join(DEFAULT_CANDIDATES)} found in '{root_dir}'")
|
||||
source_description = f"'{root_dir}'"
|
||||
|
||||
watch_interval_ms = round(args.watch_interval * 1000)
|
||||
|
||||
MarkdownRequestHandler.root_dir = root_dir
|
||||
MarkdownRequestHandler.default_file = default_file
|
||||
MarkdownRequestHandler.pydoc_module = pydoc_module
|
||||
MarkdownRequestHandler.watch_interval_ms = watch_interval_ms
|
||||
|
||||
try:
|
||||
server = http.server.HTTPServer((args.listen_address, args.port), MarkdownRequestHandler)
|
||||
except OSError as error:
|
||||
fail(f"could not start server on {args.listen_address}:{args.port} ({error}); try a different --listen-address/--port")
|
||||
|
||||
url = f"http://{args.listen_address}:{args.port}/"
|
||||
print(f"{COLOR_GREEN}Serving at {url} (Ctrl+C to stop){COLOR_RESET}")
|
||||
print(f"{COLOR_GREEN}Document root: {source_description}, watching for changes every {args.watch_interval}s{COLOR_RESET}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print(f"{COLOR_YELLOW}Stopping server{COLOR_RESET}")
|
||||
finally:
|
||||
server.server_close()
|
||||
except MarkdownServeError as error:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user