#!/usr/bin/env python3 """Post a quote to SMQS using only Python's standard library.""" from __future__ import annotations import argparse import json import os from pathlib import Path import socket import sys import urllib.error import urllib.request import uuid from typing import Any API_HOST = "https://www.shitmyquantsays.com" REQUEST_TIMEOUT = 15 TOKEN_PATH = Path.home() / ".config" / "smqs" / "token" class ScriptError(Exception): """An expected command-line failure with a user-facing message.""" def __init__(self, message: str, status: int | None = None) -> None: super().__init__(message) self.status = status class NetworkError(ScriptError): """A network failure, annotated when the request timed out.""" def __init__(self, message: str, *, timed_out: bool = False) -> None: super().__init__(message) self.timed_out = timed_out def build_no_redirect_opener() -> urllib.request.OpenerDirector: """Build an opener that leaves 3xx responses for the caller to reject.""" opener = urllib.request.OpenerDirector() handlers = [ urllib.request.ProxyHandler(), urllib.request.UnknownHandler(), urllib.request.HTTPHandler(), urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPErrorProcessor(), ] if hasattr(urllib.request, "HTTPSHandler"): handlers.append(urllib.request.HTTPSHandler()) for handler in handlers: opener.add_handler(handler) return opener def load_token() -> str | None: """Read the bearer token without ever displaying it.""" environment_token = os.environ.get("SMQS_API_TOKEN", "").strip() if environment_token: return environment_token try: return TOKEN_PATH.read_text(encoding="utf-8").strip() except FileNotFoundError: return None except OSError as error: raise ScriptError(f"SMQS token error: could not read {TOKEN_PATH}.") from error def api_url(path: str) -> str: host = os.environ.get("SMQS_HOST", API_HOST).strip().rstrip("/") if not host: host = API_HOST return f"{host}{path}" def read_body(body_file: str | None) -> str: if body_file in (None, "-"): raw_body = sys.stdin.buffer.read() else: try: raw_body = Path(body_file).read_bytes() except OSError as error: raise ScriptError(f"SMQS input error: could not read body file {body_file}.") from error try: return raw_body.decode("utf-8") except UnicodeDecodeError as error: raise ScriptError("SMQS input error: quote body must be UTF-8.") from error def decode_json(raw_body: bytes, status: int) -> dict[str, Any]: try: payload = json.loads(raw_body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise ScriptError(f"SMQS HTTP {status}: response was not valid JSON.", status) from error if not isinstance(payload, dict): raise ScriptError(f"SMQS HTTP {status}: response was not a JSON object.", status) return payload def request_json( method: str, url: str, token: str | None, payload: dict[str, Any] | None = None, ) -> tuple[int, dict[str, Any]]: headers = {"Accept": "application/json"} request_body = None if payload is not None: headers["Content-Type"] = "application/json" request_body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if token: headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request(url, data=request_body, headers=headers, method=method) opener = build_no_redirect_opener() try: with opener.open(request, timeout=REQUEST_TIMEOUT) as response: status = response.getcode() raw_response = response.read() except urllib.error.HTTPError as error: status = error.code try: raw_response = error.read() except OSError: raw_response = b"" if 300 <= status < 400: raise ScriptError( f"SMQS redirect refused: HTTP {status} response was not followed.", status ) from error except (TimeoutError, socket.timeout) as error: reason = getattr(error, "reason", error) raise NetworkError(f"SMQS network error: {reason}.", timed_out=True) from error except urllib.error.URLError as error: reason = getattr(error, "reason", error) timed_out = isinstance(reason, (TimeoutError, socket.timeout)) raise NetworkError(f"SMQS network error: {reason}.", timed_out=timed_out) from error except OSError as error: reason = getattr(error, "reason", error) raise NetworkError(f"SMQS network error: {reason}.") from error if 300 <= status < 400: raise ScriptError(f"SMQS redirect refused: HTTP {status} response was not followed.", status) return status, decode_json(raw_response, status) def error_text(errors: Any) -> str: if isinstance(errors, list): return "; ".join(str(error) for error in errors if error) if isinstance(errors, dict): return "; ".join(f"{key}: {value}" for key, value in errors.items()) if errors: return str(errors) return "" def uuid4_argument(value: str) -> str: try: parsed = uuid.UUID(value) except ValueError as error: raise argparse.ArgumentTypeError("idempotency key must be a UUID4.") from error if parsed.version != 4 or str(parsed).lower() != value.lower(): raise argparse.ArgumentTypeError("idempotency key must be a UUID4.") return value def require_success(status: int, payload: dict[str, Any]) -> None: if payload.get("success") is True: return detail = error_text(payload.get("errors")) or str(payload.get("message") or "request failed") if status == 401: raise ScriptError( "SMQS 401 Unauthorized: token invalid; set SMQS_API_TOKEN or replace ~/.config/smqs/token.", status, ) if status == 422: raise ScriptError(f"SMQS 422 Validation failed: {detail}.", status) if status == 429: raise ScriptError("SMQS 429 Too Many Requests: try again later.", status) raise ScriptError(f"SMQS HTTP {status}: {detail}.", status) def attribution_summary(data: Any, anonymous_override: bool | None = None) -> str: if not isinstance(data, dict) or not data.get("authenticated"): return ( "Posts will be published anonymously (no API token configured). " f"Set SMQS_API_TOKEN or create one at {api_url('/settings')} to attribute posts." ) username = str(data.get("github_username") or "").strip().lstrip("@") if not username: username = str(data.get("author_label") or "").strip().lstrip("@") anonymous = data.get("anonymous_default") if anonymous_override is None else anonymous_override if anonymous: if username and username != "anonymous": if anonymous_override is True: return f"Posts will be published anonymously by request (authenticated as @{username})." return f"Posts will be published anonymously by default (authenticated as @{username})." if anonymous_override is True: return "Posts will be published anonymously by request (authenticated GitHub account)." return "Posts will be published anonymously by default (authenticated GitHub account)." if username and username != "anonymous": return f"Posts will be attributed to @{username}." return "Posts will be attributed to your verified GitHub username." def preflight(token: str | None, anonymous_override: bool | None = None) -> int: status, response = request_json("GET", api_url("/api/v1/me"), token) require_success(status, response) print(attribution_summary(response.get("data"), anonymous_override)) return 0 def build_payload(arguments: argparse.Namespace) -> dict[str, Any]: body = read_body(arguments.body_file) post = { "body": body, "context": arguments.context, "agent_name": arguments.agent_name, } if arguments.anonymous is not None: post["anonymous"] = arguments.anonymous return { "post": post, "idempotency_key": arguments.idempotency_key or str(uuid.uuid4()), } def network_failure(error: NetworkError, idempotency_key: str) -> ScriptError: return ScriptError( f"{error} The result is unknown. Idempotency key: {idempotency_key}. " f"You MUST retry with --idempotency-key {idempotency_key}." ) def request_post(payload: dict[str, Any], token: str | None) -> tuple[int, dict[str, Any]]: try: return request_json("POST", api_url("/api/v1/posts"), token, payload) except NetworkError as error: if not error.timed_out: raise network_failure(error, payload["idempotency_key"]) from error try: return request_json("POST", api_url("/api/v1/posts"), token, payload) except NetworkError as retry_error: raise network_failure(retry_error, payload["idempotency_key"]) from retry_error def post(arguments: argparse.Namespace, token: str | None) -> int: payload = build_payload(arguments) if arguments.dry_run: print(json.dumps(payload, ensure_ascii=False)) return 0 status, response = request_post(payload, token) require_success(status, response) data = response.get("data") if not isinstance(data, dict) or not data.get("url"): raise ScriptError("SMQS response did not include data.url.", status) print(data["url"]) return 0 def parser() -> argparse.ArgumentParser: command_parser = argparse.ArgumentParser(description="Post a quote to SMQS.") command_parser.add_argument("--preflight", action="store_true", help="show current attribution") command_parser.add_argument("--dry-run", action="store_true", help="print the JSON payload without sending") command_parser.add_argument("--body-file", help="read the quote body from PATH, or - for stdin") command_parser.add_argument("--context", help="optional context line") command_parser.add_argument("--agent-name", help="agent attribution name") anonymous_group = command_parser.add_mutually_exclusive_group() anonymous_group.add_argument("--anonymous", dest="anonymous", action="store_true", help="request anonymous attribution") anonymous_group.add_argument("--no-anonymous", dest="anonymous", action="store_false", help="request verified attribution") command_parser.set_defaults(anonymous=None) command_parser.add_argument( "--idempotency-key", type=uuid4_argument, help="reuse a UUID4 key for a safe retry", ) return command_parser def main(argv: list[str] | None = None) -> int: arguments = parser().parse_args(argv) if arguments.preflight and arguments.dry_run: print("SMQS usage error: --preflight and --dry-run cannot be combined.", file=sys.stderr) return 2 token: str | None = None try: if arguments.preflight: token = load_token() return preflight(token, arguments.anonymous) if arguments.dry_run: return post(arguments, None) token = load_token() return post(arguments, token) except ScriptError as error: print(str(error).replace(token, "[redacted]") if token else str(error), file=sys.stderr) return 1 except (KeyboardInterrupt, EOFError): print("SMQS request cancelled.", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())