#!/usr/bin/env python3 """Gemeinsame Funktionen für die Wegwichtel-API-Werkzeuge.""" from __future__ import annotations import argparse import base64 import getpass import json import os import sys import traceback import uuid from dataclasses import dataclass from pathlib import Path from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.parse import urljoin from urllib.request import Request, urlopen DEFAULT_BASE_URL = "https://wegwichtel.de" API_USER_ENV = "API_USER" API_PASSWORD_ENV = "API_PASSWORD" APPLICATION_JSON = "application/json" MAX_AUTH_ATTEMPTS = 3 REQUEST_TIMEOUT_SECONDS = 120 class ApiError(RuntimeError): """Fehlerantwort der REST-API einschließlich HTTP-Status und Nutzdaten.""" def __init__(self, status: int, message: str, payload: Any = None): super().__init__(f"HTTP {status}: {message}") self.status = status self.payload = payload @dataclass(frozen=True) class Credentials: """Zugangsdaten für eine nach HTTP 401 angeforderte Basic-Authentifizierung.""" username: str password: str def credentials_from_environment() -> Credentials | None: """Liest CI-Credentials, ohne den ersten Request vorab zu authentifizieren.""" username = os.getenv(API_USER_ENV, "").strip() password = os.getenv(API_PASSWORD_ENV, "").strip() if username and password: return Credentials(username, password) return None def _json_bytes(value: dict[str, Any]) -> bytes: return json.dumps(value, ensure_ascii=False).encode("utf-8") def _request_payload( json_body: dict[str, Any] | None, fields: dict[str, Any] | None, files: dict[str, Path] | None, ) -> tuple[bytes | None, dict[str, str]]: headers = {"Accept": APPLICATION_JSON} if files: data, content_type = encode_multipart(fields or {}, files) headers["Content-Type"] = content_type return data, headers if json_body is not None: headers["Content-Type"] = APPLICATION_JSON return _json_bytes(json_body), headers if fields: headers["Content-Type"] = APPLICATION_JSON return _json_bytes(fields), headers return None, headers class ApiClient: """Kleine HTTP-Schicht mit dynamischer Basic-Auth-Nachfrage bei HTTP 401.""" def __init__(self, base_url: str, username: str | None = None, password: str | None = None): self.base_url = base_url.rstrip("/") + "/" self.credentials = Credentials(username.strip(), password.strip()) if username and password else None def prompt_credentials(self) -> None: environment_credentials = credentials_from_environment() if environment_credentials: self.credentials = environment_credentials return default_user = self.credentials.username if self.credentials else os.getenv(API_USER_ENV, "").strip() suffix = f" [{default_user}]" if default_user else "" username = input(f"Benutzername{suffix}: ").strip() or default_user if not username: raise ApiError(401, "Für den geschützten API-Zugriff ist ein Benutzername erforderlich.") password = getpass.getpass("Passwort: ").strip() if not password: raise ApiError(401, "Für den geschützten API-Zugriff ist ein Passwort erforderlich.") self.credentials = Credentials(username, password) def _authorization_header(self) -> str | None: if not self.credentials: return None value = f"{self.credentials.username}:{self.credentials.password}".encode("utf-8") token = base64.b64encode(value).decode("ascii") return f"Basic {token}" def _request_headers(self, headers: dict[str, str]) -> dict[str, str]: request_headers = dict(headers) authorization = self._authorization_header() if authorization: request_headers["Authorization"] = authorization return request_headers def _perform_request( self, method: str, url: str, data: bytes | None, headers: dict[str, str], expected: tuple[int, ...], ) -> Any: request = Request( url, data=data, headers=self._request_headers(headers), method=method.upper(), ) with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: raw = response.read() if response.status not in expected: raise ApiError(response.status, decode_error(raw), parse_json(raw)) return parse_response(raw, response.headers.get("Content-Type", "")) def _handle_http_error(self, error: HTTPError, attempt: int) -> bool: if error.code != 401 or attempt >= MAX_AUTH_ATTEMPTS: return False error.read() error.close() print("Die API verlangt Zugangsdaten (HTTP 401).", file=sys.stderr) self.prompt_credentials() return True def request( self, method: str, path: str, *, json_body: dict[str, Any] | None = None, fields: dict[str, Any] | None = None, files: dict[str, Path] | None = None, expected: tuple[int, ...] = (200, 201), ) -> Any: data, headers = _request_payload(json_body, fields, files) url = urljoin(self.base_url, path.lstrip("/")) attempt = 0 while True: try: return self._perform_request(method, url, data, headers, expected) except HTTPError as error: if self._handle_http_error(error, attempt): attempt += 1 continue raw = error.read() raise ApiError(error.code, decode_error(raw), parse_json(raw)) from error except URLError as error: raise RuntimeError(f"API nicht erreichbar: {error.reason}") from error def encode_multipart(fields: dict[str, Any], files: dict[str, Path]) -> tuple[bytes, str]: """Erzeugt multipart/form-data; Dateitypen werden absichtlich nicht mitgesendet.""" boundary = f"----wegwichtel-{uuid.uuid4().hex}" chunks: list[bytes] = [] for name, value in fields.items(): if value is None: continue chunks.extend([ f"--{boundary}\r\n".encode(), f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(), str(value).encode("utf-8"), b"\r\n", ]) for name, file_path in files.items(): path = Path(file_path).expanduser().resolve() if not path.is_file(): raise FileNotFoundError(f"Datei nicht gefunden: {path}") safe_name = path.name.replace('"', "_") chunks.extend([ f"--{boundary}\r\n".encode(), f'Content-Disposition: form-data; name="{name}"; filename="{safe_name}"\r\n\r\n'.encode(), path.read_bytes(), b"\r\n", ]) chunks.append(f"--{boundary}--\r\n".encode()) return b"".join(chunks), f"multipart/form-data; boundary={boundary}" def parse_json(raw: bytes) -> Any: if not raw: return None try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): return None def parse_response(raw: bytes, content_type: str) -> Any: if not raw: return None if "json" in content_type.lower(): parsed = parse_json(raw) if parsed is not None: return parsed return raw.decode("utf-8", errors="replace") def decode_error(raw: bytes) -> str: parsed = parse_json(raw) if isinstance(parsed, dict): return str(parsed.get("message") or parsed.get("error") or parsed) text = raw.decode("utf-8", errors="replace").strip() return text or "Unbekannter API-Fehler" def add_common_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--base-url", help="API-Basisadresse, Standard: WEGWICHTEL_URL oder https://wegwichtel.de") parser.add_argument("--username", help="Benutzername; Passwort wird bei HTTP 401 sicher abgefragt") def client_from_args(args: argparse.Namespace) -> ApiClient: base_url = args.base_url or os.getenv("WEGWICHTEL_URL") or DEFAULT_BASE_URL return ApiClient(base_url, args.username) def ask(label: str, value: Any = None, *, required: bool = False, cast: Callable[[str], Any] = str) -> Any: if value is not None: return cast(str(value)) while True: raw = input(f"{label}: ").strip() if raw: try: return cast(raw) except (TypeError, ValueError) as error: print(f"Ungültiger Wert: {error}", file=sys.stderr) continue if not required: return None print("Dieses Feld ist erforderlich.", file=sys.stderr) def ask_with_default(label: str, current: Any, cast: Callable[[str], Any] = str) -> Any: shown = "" if current is None else str(current) while True: raw = input(f"{label} [{shown}]: ").strip() if not raw: return current try: return cast(raw) except (TypeError, ValueError) as error: print(f"Ungültiger Wert: {error}", file=sys.stderr) def ask_path(label: str, value: str | None = None, *, required: bool = True) -> Path | None: if value is not None: path = Path(value).expanduser().resolve() if not path.is_file(): raise FileNotFoundError(f"Datei nicht gefunden: {path}") return path while True: raw = input(f"{label}: ").strip() if not raw and not required: return None if not raw: print("Dieses Feld ist erforderlich.", file=sys.stderr) continue path = Path(raw).expanduser().resolve() if path.is_file(): return path print(f"Datei nicht gefunden: {path}", file=sys.stderr) def confirm(question: str) -> bool: return input(f"{question} [j/N]: ").strip().lower() in {"j", "ja", "y", "yes"} def print_result(result: Any) -> None: print(json.dumps(result, ensure_ascii=False, indent=2) if not isinstance(result, str) else result) def _print_failure(error: BaseException) -> None: print(f"Fehler: {type(error).__name__}: {error}", file=sys.stderr) traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) def run(action: Callable[[], Any]) -> None: """Führt eine Werkzeugaktion aus und gibt Fehler samt vollständigem Stacktrace aus.""" try: print_result(action()) except KeyboardInterrupt as error: _print_failure(error) raise SystemExit(130) from error except Exception as error: _print_failure(error) raise SystemExit(1) from error