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:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
@@ -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+<repository-url>@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
|
||||||
|
```
|
||||||
@@ -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*"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""python_helpers - generic Python helpers with no cloud-specific logic."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""python_helpers.cli - console-script entry points for the generic commands."""
|
||||||
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()
|
||||||
Executable
+73
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user