From 9f56b3ee19eb68b4f2727675274f28301beebe86 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 6 Sep 2026 19:15:54 +0200 Subject: [PATCH] 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 --- .gitignore | 4 + README.md | 82 +++++++ pyproject.toml | 16 ++ python_helpers/__init__.py | 1 + python_helpers/cli/__init__.py | 1 + python_helpers/cli/serve_markdown.py | 335 +++++++++++++++++++++++++++ python_helpers/cli/token_decoder.py | 73 ++++++ python_helpers/console.py | 47 ++++ python_helpers/env.py | 33 +++ python_helpers/files.py | 37 +++ 10 files changed, 629 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 python_helpers/__init__.py create mode 100644 python_helpers/cli/__init__.py create mode 100755 python_helpers/cli/serve_markdown.py create mode 100755 python_helpers/cli/token_decoder.py create mode 100644 python_helpers/console.py create mode 100644 python_helpers/env.py create mode 100644 python_helpers/files.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28d1e7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.egg-info/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..75ad85e --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Python Helpers + +Generic Python helpers and command-line tools with no cloud-specific logic. It has no external +dependencies. + +- `serve-markdown`: serve Markdown files as live-reloading GitHub-styled HTML pages +- `token-decoder`: decode a JWT's claims without verifying its signature + +## Installation + +Each command below is available on `PATH` directly (no `.py` extension, no `python3` prefix) +once the package is installed into the active virtual environment, by any of the methods below. + +### Local install + +From a checkout of this repository: + +```bash +pip install -e . # editable, for development: picks up source changes without reinstalling +pip install . # normal install: copies the package in +``` + +### Install from git + +```bash +pip install git+@main +``` + +### Running without installing + +From a repo checkout, run any command as a module with `-m` instead of installing the package: + +```bash +python3 -m python_helpers.cli.token_decoder --help +``` + +From elsewhere, set `PYTHONPATH` to the repo root instead: + +```bash +PYTHONPATH=/path/to/python-helpers python3 -m python_helpers.cli.token_decoder --help +``` + +The module path replaces the installed command's short hyphenated name; the underlying `main()` +and behavior are identical. + +## Library + +- `console`: colored console output and the `fail()` helper used to report errors and exit +- `env`: loading docker-style `NAME=VALUE` environment files into the process environment +- `files`: JSON file load/save helpers + +## Commands + +### `token-decoder` + +Decode a JWT's claims, without verifying its signature. Reads from a file argument, or stdin. + +Example: + +```bash +token-decoder token.txt +echo "$TOKEN" | token-decoder +``` + +### `serve-markdown` + +Render Markdown files under a directory as GitHub-styled pages and serve them locally, live-reloading +on change. + +Parameters: + +- `path` (optional, positional): 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. Prefix with `pydoc:` and a dotted module name (e.g. `pydoc:python_helpers.console`) to render that module's docs with `pydoc-markdown` instead; requires the `pydoc-markdown` package to be installed +- `--listen-address` (optional): Address for the local web server to listen on (default: `127.0.0.1`) +- `--port` (optional): Port for the local web server (default: `8000`) +- `--watch-interval` (optional): Seconds between checks for changes to the file, polled by the browser page (default: `1.0`) + +Example: + +```bash +serve-markdown README.md +serve-markdown pydoc:python_helpers.console +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1772c1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-helpers" +version = "1.0.0" +description = "Generic Python helpers and command-line tools with no cloud-specific logic" +requires-python = ">=3.9" + +[project.scripts] +serve-markdown = "python_helpers.cli.serve_markdown:main" +token-decoder = "python_helpers.cli.token_decoder:main" + +[tool.setuptools.packages.find] +include = ["python_helpers*"] diff --git a/python_helpers/__init__.py b/python_helpers/__init__.py new file mode 100644 index 0000000..fb14ec8 --- /dev/null +++ b/python_helpers/__init__.py @@ -0,0 +1 @@ +"""python_helpers - generic Python helpers with no cloud-specific logic.""" diff --git a/python_helpers/cli/__init__.py b/python_helpers/cli/__init__.py new file mode 100644 index 0000000..809a4fb --- /dev/null +++ b/python_helpers/cli/__init__.py @@ -0,0 +1 @@ +"""python_helpers.cli - console-script entry points for the generic commands.""" diff --git a/python_helpers/cli/serve_markdown.py b/python_helpers/cli/serve_markdown.py new file mode 100755 index 0000000..39fd418 --- /dev/null +++ b/python_helpers/cli/serve_markdown.py @@ -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"""
Loading...
+""" + return f""" + + + +{html.escape(title)} + + + + + + + + + +{body} + + +""".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() diff --git a/python_helpers/cli/token_decoder.py b/python_helpers/cli/token_decoder.py new file mode 100755 index 0000000..d303d9b --- /dev/null +++ b/python_helpers/cli/token_decoder.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +"""Decode a JWT's claims, without verifying its signature.""" + +import argparse +import base64 +import json +import sys + +from python_helpers.console import fail + +EXAMPLES = """ +Examples: + %(prog)s token.txt + echo "$TOKEN" | %(prog)s + +This command will: + 1. Read a JWT from the given file, or from stdin if no file is given + 2. Decode its payload claims, without verifying its signature + 3. Print the claims as JSON +""" + + +def decode_jwt_claims(token): + """Decode a JWT's payload claims, without verifying its signature. + + Args: + token: a JSON Web Token (JWT) string. + + Returns: + The decoded claims as a dict. + """ + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + +def build_parser(): + """Build the argparse parser for this script.""" + parser = argparse.ArgumentParser( + description="Decode a JWT's claims, without verifying its signature.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=EXAMPLES, + ) + parser.add_argument( + "token_file", + nargs="?", + help="Path to a file containing a JWT; reads from stdin if omitted", + ) + return parser + + +def main(): + """Parse arguments and print the decoded claims of the given (or piped-in) token.""" + args = build_parser().parse_args() + + if args.token_file: + try: + with open(args.token_file, encoding="utf-8") as token_file: + token = token_file.read().strip() + except OSError as error: + fail(f"Could not read '{args.token_file}': {error}") + else: + token = sys.stdin.read().strip() + + if not token: + fail("No token given") + + print(json.dumps(decode_jwt_claims(token), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/python_helpers/console.py b/python_helpers/console.py new file mode 100644 index 0000000..50811d6 --- /dev/null +++ b/python_helpers/console.py @@ -0,0 +1,47 @@ +"""Colored console output helpers for command-line scripts.""" + +import sys + +if sys.stdout.isatty(): + COLOR_RESET = "\033[0m" + COLOR_RED = "\033[0;31m" + COLOR_GREEN = "\033[0;32m" + COLOR_YELLOW = "\033[0;33m" + COLOR_BLUE = "\033[0;34m" + COLOR_CYAN = "\033[0;36m" +else: + COLOR_RESET = COLOR_RED = COLOR_GREEN = COLOR_YELLOW = COLOR_BLUE = COLOR_CYAN = "" + + +def fail(message): + """Print an error message to stderr and exit with status 1. + + Args: + message: error text to print. + """ + print(f"{COLOR_RED}Error: {message}{COLOR_RESET}", file=sys.stderr) + sys.exit(1) + + +def print_banner(title, title_color=COLOR_CYAN): + """Print a cyan banner line, the given title, and another banner line. + + Args: + title: text to print between the banner lines. + title_color: color to print the title in; the banner lines themselves are always + cyan. Defaults to cyan, matching most banners; pass COLOR_GREEN for a + "Setup/Cleanup Complete!" style closing banner. + """ + print(f"{COLOR_CYAN}=========================================={COLOR_RESET}") + print(f"{title_color}{title}{COLOR_RESET}") + print(f"{COLOR_CYAN}=========================================={COLOR_RESET}") + + +def print_summary_counts(pairs): + """Print a flat "Label: count" line for each given (label, count) pair. + + Args: + pairs: iterable of (label, count) tuples. + """ + for label, count in pairs: + print(f"{label}: {count}") diff --git a/python_helpers/env.py b/python_helpers/env.py new file mode 100644 index 0000000..2995e85 --- /dev/null +++ b/python_helpers/env.py @@ -0,0 +1,33 @@ +"""Environment file helpers for command-line scripts.""" + +import os + +from python_helpers.console import fail + + +def load_env_file(path): + """Load a docker-style environment file into the process environment. + + Blank lines and lines whose first non-blank character is # are ignored; a # anywhere + else is part of the value. A NAME=VALUE line sets the variable, overriding any value + already in the environment; a bare NAME line leaves the inherited value in place. + Values are taken literally, without quote removal or variable interpolation. An empty + name, or one containing whitespace, is an error. + + Args: + path: path to the environment file. + """ + try: + with open(path, encoding="utf-8") as env_file: + lines = env_file.read().splitlines() + except OSError as error: + fail(f"Could not read '{path}': {error}") + + for number, line in enumerate(lines, start=1): + if not line.strip() or line.lstrip().startswith("#"): + continue + name, separator, value = line.partition("=") + if not name or any(character.isspace() for character in name): + fail(f"{path}: line {number}: variable '{name}' is empty or contains whitespace") + if separator: + os.environ[name] = value diff --git a/python_helpers/files.py b/python_helpers/files.py new file mode 100644 index 0000000..e97b9ed --- /dev/null +++ b/python_helpers/files.py @@ -0,0 +1,37 @@ +"""JSON file I/O helpers for command-line scripts.""" + +import json +from pathlib import Path + +from python_helpers.console import fail + + +def load_json(path, missing_hint=None): + """Load and parse a JSON file, failing with a consistent message if it is missing. + + Args: + path: path to the JSON file. + missing_hint: optional extra line of guidance to print if the file is missing. + + Returns: + The parsed JSON content. + """ + if not Path(path).exists(): + message = f"{path} not found!" + if missing_hint: + message += f"\n{missing_hint}" + fail(message) + with open(path, "r") as f: + return json.load(f) + + +def save_json(path, data, indent=2): + """Save data to a file as JSON. + + Args: + path: path to write to. + data: JSON-serializable data to write. + indent: indentation level to format the JSON with. + """ + with open(path, "w") as f: + json.dump(data, f, indent=indent)