Florian Zumpe 95026aac6e
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 22s
fixed sonarqube scan issues
2026-06-17 15:12:46 +02:00

311 lines
11 KiB
Python

#!/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