""" ===================================================================== HEGA GmbH - Portal-API - Python-Referenzclient ===================================================================== Lauffaehiges Beispiel fuer die Anbindung an die HEGA Portal-API. Bewusst OHNE externe Abhaengigkeiten - es wird nur die Standardbibliothek benutzt (urllib). `pip install` ist nicht noetig, das Modul laeuft auch in abgeschotteten Umgebungen. Voraussetzungen : Python 3.8 oder neuer (geprueft mit 3.8.10 und 3.13.7) Dokumentation : https://api.hega.net/docs (Swagger UI) https://api.hega.net/docs/v1/docs.json (OpenAPI-Spec) Handbuch : https://api.hega.net/HEGA-Anbindung-Handbuch.pdf Support : it@hega.net Sie duerfen diese Datei frei kopieren und anpassen. Sie ist als Startpunkt gedacht, nicht als fertige Integration - Protokollierung, Wiederaufsetzen und Fehler-Reporting richten sich nach Ihrem System. Wer lieber `requests` benutzt, kann die Methode `_http` ersetzen; die uebrige Logik bleibt unveraendert. Der Client deckt die Punkte ab, an denen Erstanbindungen erfahrungsgemaess haengen bleiben: 1. Token-Lebenszyklus - der Access-Token gilt 30 Minuten, es gibt KEINE Kulanzzeit. Das Feld `expiresAt` der Login-Antwort bezieht sich auf das Refresh-Token und ist NICHT der Ablauf des Access-Tokens. Dieser Client liest den tatsaechlichen Ablauf aus dem JWT-Claim `exp`. 2. `POST /api/Account/refresh-token` ist selbst abgesichert - der noch gueltige Access-Token muss mitgeschickt werden. Nach Ablauf hilft nur ein neuer Login (macht der Client automatisch). 3. Es gibt zwei verschiedene Fehlerschemas - siehe `HegaApiError`. 4. Gemischte Pfade: nur `DeliveryNote` und `Invoice` laufen mit `/api/v1/`, alle uebrigen Endpunkte OHNE `v1` - sonst HTTP 404. 5. Rate-Limit (HTTP 429) -> Wiederholung mit wachsender Wartezeit. 6. Das Anlegen einer Bestellung ist NICHT idempotent - nach einem Timeout niemals blind erneut senden (siehe `order_add_safe`). Verwendung als Bibliothek: from hega_api_client import HegaApiClient Das Demo am Dateiende laeuft nur beim direkten Aufruf des Skripts. Stand: August 2026 """ import base64 import json import os import ssl import sys import time import urllib.error import urllib.parse import urllib.request from typing import Any, Callable, Dict, List, Optional, Tuple BASIS_TEST = "https://test.api.hega.net" BASIS_LIVE = "https://api.hega.net" class HegaApiError(Exception): """ Fehler der HEGA-API. WICHTIG - je nach Pruefebene antwortet die API mit einem von ZWEI Fehlerschemas. Diese Klasse vereinheitlicht beide: a) Fachliche Pruefung (Bestell-Endpunkte): {"errorCode": "INVALID_QUANTITY", "message": "...", "details": {...}, "errors": [{..., "details": {"Position": "2"}}]} -> `error_code` ist gesetzt, `errors` enthaelt ALLE fehlerhaften Positionen auf einmal. b) Formale Feldpruefung (greift VORHER, Standardformat von ASP.NET Core): {"title": "One or more validation errors occurred.", "status": 400, "errors": {"Vorname": ["The field ... is required."]}} -> `error_code` ist None, die Meldungen stehen je Feld in `field_errors`. Wer ausschliesslich auf `error_code` prueft, uebersieht Fall (b) vollstaendig und sieht nur einen nackten HTTP 400. """ def __init__( self, meldung: str, status: int, error_code: Optional[str] = None, errors: Optional[List[Dict[str, Any]]] = None, field_errors: Optional[Dict[str, Any]] = None, raw: Any = None, ) -> None: super().__init__(meldung) self.status = status # 0 = keine Verbindung zustande gekommen self.error_code = error_code self.errors = errors or [] self.field_errors = field_errors or {} self.raw = raw @property def ist_nicht_freigeschaltet(self) -> bool: """HTTP 403 heisst hier in aller Regel NICHT 'Token ungueltig', sondern 'Funktion fuer Ihr Konto nicht freigeschaltet'.""" return self.status == 403 or self.error_code == "API_NOT_ENABLED" @property def ist_doublette(self) -> bool: """Dieselbe Bestellnummer ging innerhalb von 24 Stunden schon ein.""" return self.status == 409 or self.error_code == "DUPLICATE_ORDER" class HegaApiClient: """Client fuer die HEGA Portal-API (Dropshipping & B2B).""" #: Sekunden vor dem tatsaechlichen Ablauf, ab denen erneuert wird. TOKEN_PUFFER_SEK = 120 #: Maximale Anzahl Versuche je Aufruf (inkl. Erstversuch). MAX_VERSUCHE = 3 def __init__( self, base_url: str, api_mail: str, password: str, kundennummer: str, timeout: int = 60, logger: Optional[Callable[[str], None]] = None, ) -> None: self.base_url = base_url.rstrip("/") self._api_mail = api_mail self._password = password self._kundennummer = str(kundennummer) self._timeout = timeout self._logger = logger self._access_token: Optional[str] = None self._refresh_token: Optional[str] = None # Unix-Zeit des tatsaechlichen Access-Token-Ablaufs (JWT-Claim `exp`) self._access_token_ablauf = 0.0 # Kein eigener SSL-Kontext noetig - der Standard prueft das Zertifikat. # Die Pruefung bitte niemals abschalten. self._ssl_kontext = ssl.create_default_context() # ----------------------------------------------------------------- # Bestellung # ----------------------------------------------------------------- def order_validate(self, bestellung: Dict[str, Any]) -> Dict[str, Any]: """ Vorab-Pruefung (Dry-Run): prueft Adresse, Artikel und Mengen, legt aber NICHTS an. Funktioniert bewusst auch, bevor die Bestellfunktion fuer Ihr Konto final freigeschaltet ist - ideal, um Ihr Feldmapping zu testen. """ rumpf = self.request("POST", "/api/DSOrder/OrderValidate/", body=bestellung)[1] if isinstance(rumpf, dict): return rumpf return {"valid": False, "errorCount": 1, "errors": []} def order_add(self, bestellung: Dict[str, Any]) -> str: """ Legt eine Dropshipping-Bestellung an. :raises HegaApiError: bei fachlichen Fehlern (400/403/409) """ rumpf = self.request("POST", "/api/DSOrder/OrderAdd/", body=bestellung)[1] return rumpf if isinstance(rumpf, str) else json.dumps(rumpf, ensure_ascii=False) def order_add_safe(self, bestellung: Dict[str, Any]) -> str: """ `order_add` mit sicherer Behandlung von Timeouts. Das Anlegen ist nicht vollstaendig idempotent: Bricht die Verbindung ab, kann die Bestellung trotzdem angekommen sein. Statt blind zu wiederholen wird erst der Status der Bestellnummer abgefragt - so entstehen keine Doubletten. """ bestellnummer = str(bestellung.get("bestellnummer", "")) try: return self.order_add(bestellung) except HegaApiError as fehler: # Doublette: die Bestellung liegt bereits vor - kein Fehlerfall. if fehler.ist_doublette: return "Bereits vorhanden: " + bestellnummer # Verbindungsabbruch oder Serverfehler: erst nachsehen, dann entscheiden. if fehler.status == 0 or fehler.status >= 500: self._log("OrderAdd unklar (Status %d) - pruefe Bestellnummer ..." % fehler.status) if self.order_status(bestellnummer): return "Bereits angekommen: " + bestellnummer return self.order_add(bestellung) # sicher nicht angekommen raise def order_status(self, bestellnummer: str) -> List[Dict[str, Any]]: """ Status einer Bestellung ueber Ihre eigene Bestellnummer. Leere Liste = nicht gefunden. Bei Teillieferungen kommen mehrere Eintraege zurueck. Hinweis: `menge` und `orderMenge` sind erst ab den Status `Dispatched` / `Invoiced` aussagekraeftig - vorher stimmen sie zwangslaeufig ueberein und taugen nicht als Hinweis auf eine gekuerzte Menge. """ if not bestellnummer: return [] try: rumpf = self.request( "GET", "/api/DSOrder/Customer/" + urllib.parse.quote(bestellnummer, safe=""), idempotent=True, )[1] return rumpf if isinstance(rumpf, list) else [] except HegaApiError as fehler: if fehler.status == 404: return [] raise # ----------------------------------------------------------------- # Artikel & Bestand # ----------------------------------------------------------------- def artikel(self, artikelnummer: str, kompakt: bool = True) -> Optional[Dict[str, Any]]: """ Einzelnen Artikel abrufen - vor allem wegen `anzahlartve` (Stueck je Verkaufseinheit) fuer die VE-Umrechnung. :return: None, wenn der Artikel fuer Ihr Konto nicht bestellbar ist (HTTP 404) """ pfad = "/api/DSArtikelDaten/" + ("light/" if kompakt else "") pfad += urllib.parse.quote(artikelnummer, safe="") try: rumpf = self.request("GET", pfad, idempotent=True)[1] except HegaApiError as fehler: if fehler.status == 404: return None raise # Manche Endpunkte antworten mit einer Liste aus einem Element. if isinstance(rumpf, list): return rumpf[0] if rumpf and isinstance(rumpf[0], dict) else None return rumpf if isinstance(rumpf, dict) else None def bestand(self, artikelnummer: str) -> Optional[Dict[str, Any]]: """ Warenbestand eines Artikels. `bestandstatus` bitte nur auf die Werte 0 und 1 auswerten. `verfuegbar_ab` mit dem Datum 01.01.1899 bedeutet "kein abweichendes Datum bekannt". """ try: rumpf = self.request( "GET", "/api/DSArtikelBestand/" + urllib.parse.quote(artikelnummer, safe=""), idempotent=True, )[1] return rumpf if isinstance(rumpf, dict) else None except HegaApiError as fehler: if fehler.status == 404: return None raise def artikel_daten_kompakt_alle(self, page_size: int = 500) -> List[Dict[str, Any]]: """ Sortiment seitenweise abrufen (kompakte Variante) - schonender als ein Vollabzug in einer einzigen Antwort. WICHTIG: Durch die kontospezifische Freischaltung kann eine Seite WENIGER als `page_size` Artikel enthalten. Bei einer "kurzen" Seite darf deshalb NICHT abgebrochen werden - es wird weitergezaehlt, bis die Summe den Wert aus dem Header `X-Total-Count` erreicht. """ alle: List[Dict[str, Any]] = [] seite = 1 gesamt: Optional[int] = None # aus dem Header (nur auf Seite 1 gesetzt) max_seiten = 10000 # Notbremse gegen Endlosschleifen while seite <= max_seiten: status, rumpf, header = self.request( "GET", "/api/DSArtikelDaten/light", query={"page": seite, "pageSize": page_size}, idempotent=True, ) if gesamt is None and header.get("x-total-count"): gesamt = int(header["x-total-count"]) self._log("Sortiment gesamt: %d Artikel" % gesamt) seiten_daten = rumpf if isinstance(rumpf, list) else [] alle.extend(seiten_daten) self._log("Seite %d: %d Artikel (Summe %d)" % (seite, len(seiten_daten), len(alle))) if not seiten_daten: # leere Seite = wirklich zu Ende break if gesamt is not None and len(alle) >= gesamt: break seite += 1 return alle # ----------------------------------------------------------------- # Belege # ----------------------------------------------------------------- def beleg_pdf(self, art: str, nummer: str) -> bytes: """ Beleg-PDF als Binaerdaten. ACHTUNG: Diese beiden Endpunkte laufen als einzige MIT `/api/v1/` im Pfad. Es werden nur Belege der letzten 12 Monate geliefert. :param art: 'DeliveryNote' (Lieferschein) oder 'Invoice' (Rechnung) """ rumpf = self.request( "GET", "/api/v1/%s/%s/pdf" % (art, urllib.parse.quote(nummer, safe="")), idempotent=True, binaer=True, )[1] return rumpf if isinstance(rumpf, bytes) else b"" # ----------------------------------------------------------------- # Authentifizierung # ----------------------------------------------------------------- def login(self) -> None: """Meldet an und legt Access- und Refresh-Token ab.""" status, rumpf, _ = self._http( "POST", "/api/Account/login", body={ "apimail": self._api_mail, "password": self._password, "kdnnr": self._kundennummer, }, query={"api-version": "1.0"}, mit_token=False, ) if status != 200 or not isinstance(rumpf, dict) or not rumpf.get("accessToken"): raise self._baue_fehler(status, rumpf, "Login fehlgeschlagen") self._uebernehme_token(rumpf) self._log("Login erfolgreich (Kundennummer %s)" % rumpf.get("kundenNummer", "?")) def logout(self) -> None: """Meldet ab und gibt das Refresh-Token zurueck.""" if self._access_token: self._http("POST", "/api/Account/logout", query={"api-version": "1.0"}, mit_token=True) self._access_token = None self._refresh_token = None self._access_token_ablauf = 0.0 def _stelle_token_sicher(self) -> None: """ Sorgt dafuer, dass ein gueltiger Access-Token vorliegt. Reihenfolge: noch frisch -> nichts tun - laeuft demnaechst ab -> erneuern - Erneuerung nicht moeglich -> neuer Login. """ jetzt = time.time() if self._access_token and jetzt < self._access_token_ablauf - self.TOKEN_PUFFER_SEK: return if self._access_token and self._refresh_token and jetzt < self._access_token_ablauf: # Die Erneuerung setzt einen noch gueltigen Access-Token voraus. if self._token_erneuern(): return self.login() def _token_erneuern(self) -> bool: """ Erneuert den Access-Token. Kann regulaer fehlschlagen - etwa wenn die Sitzung serverseitig nicht mehr bekannt ist (z. B. nach einer Wartung). Der Aufrufer faellt dann automatisch auf einen neuen Login zurueck; das ist ein normaler Betriebszustand und kein Fehler. """ status, rumpf, _ = self._http( "POST", "/api/Account/refresh-token", body={"refreshToken": self._refresh_token}, query={"api-version": "1.0"}, mit_token=True, ) if status == 200 and isinstance(rumpf, dict) and rumpf.get("accessToken"): self._uebernehme_token(rumpf) self._log("Access-Token erneuert") return True self._log("Erneuerung nicht moeglich (Status %d) - neuer Login folgt" % status) return False def _uebernehme_token(self, rumpf: Dict[str, Any]) -> None: """ Uebernimmt die Token aus einer Login- bzw. Erneuerungs-Antwort. Der Ablauf wird bewusst NICHT aus dem Feld `expiresAt` gelesen - dieses bezieht sich auf das Refresh-Token und liegt spaeter als der Ablauf des Access-Tokens. Massgeblich ist allein der Claim `exp` im JWT selbst. """ self._access_token = str(rumpf["accessToken"]) self._refresh_token = rumpf.get("refreshToken") exp = self._jwt_ablauf(self._access_token) # Konservativer Ersatzwert, falls sich das Tokenformat einmal aendert. self._access_token_ablauf = float(exp) if exp is not None else time.time() + 25 * 60 @staticmethod def _jwt_ablauf(jwt: str) -> Optional[int]: """ Liest den Claim `exp` aus einem JWT. Ohne Signaturpruefung - die Gueltigkeit prueft ausschliesslich der Server. """ teile = jwt.split(".") if len(teile) != 3: return None nutzlast = teile[1] nutzlast += "=" * ((4 - len(nutzlast) % 4) % 4) try: daten = json.loads(base64.urlsafe_b64decode(nutzlast.encode("ascii")).decode("utf-8")) except (ValueError, UnicodeDecodeError): return None exp = daten.get("exp") if isinstance(daten, dict) else None return int(exp) if isinstance(exp, (int, float, str)) and str(exp).isdigit() else None # ----------------------------------------------------------------- # HTTP-Schicht # ----------------------------------------------------------------- def request( self, methode: str, pfad: str, body: Optional[Dict[str, Any]] = None, query: Optional[Dict[str, Any]] = None, idempotent: bool = False, binaer: bool = False, ) -> Tuple[int, Any, Dict[str, str]]: """ Aufruf inkl. Token-Verwaltung, Wiederholungen und Fehlerauswertung. :param idempotent: Nur bei True wird nach Timeout/5xx wiederholt. Fuer das Anlegen von Bestellungen IMMER False! :param binaer: True = Antwort nicht als JSON auswerten (PDF/CSV) :return: (status, rumpf, header) :raises HegaApiError: """ self._stelle_token_sicher() alle_parameter: Dict[str, Any] = {"api-version": "1.0"} alle_parameter.update(query or {}) wartezeit = 1 for versuch in range(1, self.MAX_VERSUCHE + 1): status, rumpf, header = self._http( methode, pfad, body=body, query=alle_parameter, mit_token=True, binaer=binaer ) # 401 - Sitzung abgelaufen: einmal neu anmelden und erneut versuchen. if status == 401 and versuch < self.MAX_VERSUCHE: self._log("HTTP 401 - melde neu an") self._access_token = None self.login() continue # 429 - Rate-Limit: warten und erneut versuchen. if status == 429 and versuch < self.MAX_VERSUCHE: pause = wartezeit if header.get("retry-after", "").strip().isdigit(): pause = max(1, int(header["retry-after"].strip())) self._log("HTTP 429 (Rate-Limit) - warte %d s" % pause) time.sleep(pause) wartezeit *= 2 continue # Verbindungsabbruch (0) oder Serverfehler (5xx): # nur bei nachweislich wiederholbaren Aufrufen erneut senden. if (status == 0 or status >= 500) and idempotent and versuch < self.MAX_VERSUCHE: self._log("Status %d - Wiederholung in %d s" % (status, wartezeit)) time.sleep(wartezeit) wartezeit *= 2 continue if not 200 <= status < 300: raise self._baue_fehler(status, rumpf) return status, rumpf, header raise HegaApiError( "Aufruf nach %d Versuchen nicht erfolgreich: %s %s" % (self.MAX_VERSUCHE, methode, pfad), 0, ) def _http( self, methode: str, pfad: str, body: Optional[Dict[str, Any]] = None, query: Optional[Dict[str, Any]] = None, mit_token: bool = True, binaer: bool = False, ) -> Tuple[int, Any, Dict[str, str]]: """Ein einzelner HTTP-Aufruf ohne Wiederholungslogik.""" url = self.base_url + pfad if query: url += "?" + urllib.parse.urlencode(query) kopf = {"Accept": "application/json"} if mit_token and self._access_token: kopf["Authorization"] = "Bearer " + self._access_token daten = None if body is not None: # json.dumps maskiert Umlaute standardmaessig als \uXXXX - das ist # gueltiges JSON und wird von der API korrekt gelesen. Das Feld # "straße" funktioniert damit ohne Zusatzoption. daten = json.dumps(body).encode("utf-8") kopf["Content-Type"] = "application/json" anfrage = urllib.request.Request(url, data=daten, headers=kopf, method=methode) try: with urllib.request.urlopen(anfrage, timeout=self._timeout, context=self._ssl_kontext) as antwort: return ( antwort.status, self._rumpf_lesen(antwort.read(), binaer), self._header_lesen(antwort.headers), ) except urllib.error.HTTPError as fehler: # HTTPError ist zugleich die Antwort - Rumpf und Header sind lesbar. return ( fehler.code, self._rumpf_lesen(fehler.read(), binaer), self._header_lesen(fehler.headers), ) except (urllib.error.URLError, OSError) as fehler: # Kein HTTP-Kontakt: Timeout, DNS, Verbindung abgelehnt, TLS. return 0, str(getattr(fehler, "reason", fehler)), {} @staticmethod def _rumpf_lesen(roh: bytes, binaer: bool) -> Any: """Dekodiert JSON; ist die Antwort kein JSON, kommt der Rohtext zurueck.""" if binaer: return roh if not roh: return None text = roh.decode("utf-8", errors="replace") try: return json.loads(text) except ValueError: return text @staticmethod def _header_lesen(header: Any) -> Dict[str, str]: """Header-Namen in Kleinbuchstaben, damit der Zugriff eindeutig ist.""" return {str(k).lower(): str(v) for k, v in header.items()} def _baue_fehler(self, status: int, rumpf: Any, praefix: str = "API-Fehler") -> HegaApiError: """ Baut aus einer Fehlerantwort einen `HegaApiError` und wertet dabei BEIDE Fehlerschemas aus (siehe Kommentar an der Klasse). """ if status == 0: return HegaApiError("%s - keine Verbindung: %s" % (praefix, rumpf), 0, raw=rumpf) if isinstance(rumpf, dict): # Schema (a): fachlicher Fehler mit Fehlercode if "errorCode" in rumpf: einzelfehler = rumpf.get("errors") if isinstance(rumpf.get("errors"), list) else [] text = str(rumpf.get("message", "")) if einzelfehler: text += " (%d fehlerhafte Position(en))" % len(einzelfehler) return HegaApiError( "%s (HTTP %d) [%s] %s" % (praefix, status, rumpf["errorCode"], text), status, error_code=str(rumpf["errorCode"]), errors=einzelfehler, raw=rumpf, ) # Schema (b): formale Feldpruefung if "title" in rumpf and isinstance(rumpf.get("errors"), dict): felder = [] for feld, meldungen in rumpf["errors"].items(): if isinstance(meldungen, (list, tuple)): felder.append("%s: %s" % (feld, " / ".join(str(m) for m in meldungen))) else: felder.append("%s: %s" % (feld, meldungen)) return HegaApiError( "%s (HTTP %d) Feldpruefung: %s" % (praefix, status, " | ".join(felder)), status, field_errors=rumpf["errors"], raw=rumpf, ) if "message" in rumpf: return HegaApiError( "%s (HTTP %d) %s" % (praefix, status, rumpf["message"]), status, raw=rumpf ) if isinstance(rumpf, str) and rumpf: return HegaApiError("%s (HTTP %d) %s" % (praefix, status, rumpf), status, raw=rumpf) return HegaApiError("%s (HTTP %d)" % (praefix, status), status, raw=rumpf) def _log(self, zeile: str) -> None: if self._logger is not None: self._logger("[HEGA] " + zeile) # ===================================================================== # Demo - laeuft nur beim direkten Aufruf: # # python hega_api_client.py # # Zugangsdaten nicht im Quelltext hinterlegen, sondern z. B. aus # Umgebungsvariablen lesen (siehe unten). # ===================================================================== def _demo() -> int: client = HegaApiClient( BASIS_TEST, # erst Test, spaeter BASIS_LIVE os.environ.get("HEGA_API_MAIL", "filiale@example.com"), os.environ.get("HEGA_API_PASSWORT", "***"), os.environ.get("HEGA_KUNDENNUMMER", "1234567"), timeout=60, logger=lambda zeile: print(zeile, file=sys.stderr), ) # Beispielbestellung. Die Feldnamen sind exakt so zu verwenden - # das Strassenfeld heisst in der API tatsaechlich "straße". bestellung = { "bestellnummer": "BESTELL-2026-0001", # Ihre Referenz, max. 20 Zeichen "vorname": "Maria", "nachname": "Schmidt", "straße": "Musterweg", "hausnummer": "42a", "postleitzahl": "50667", "ort": "Köln", "laenderkennzeichen": "DE", # Grossbuchstaben, case-sensitive "versender": "DPD", # "DHL" oder "DPD", case-sensitive "emailadresse": "maria.schmidt@example.com", # bei DPD Pflicht (alternativ mobilnummer) "dsorderzeiledto": [ {"artnr": "12345", "menge": 2}, # Menge in VE {"artnr": "9110378", "menge": 12, "mengeneinheit": "Stueck"}, # HEGA rechnet auf VE um ], } try: # 1) Immer zuerst der Dry-Run - meldet alle Probleme auf einmal. pruefung = client.order_validate(bestellung) if not pruefung.get("valid"): print("Bestellung fehlerhaft (%d Problem(e)):" % pruefung.get("errorCount", 0)) for fehler in pruefung.get("errors", []): position = fehler.get("details", {}).get("Position") print(" - [%s] %s %s" % ( fehler.get("errorCode", "?"), fehler.get("message", ""), "(Position %s)" % position if position else "", )) return 1 # 2) Erst danach wirklich anlegen. print(client.order_add_safe(bestellung)) # 3) Status abfragen - Identifikation ueber Ihre eigene Bestellnummer. for zeile in client.order_status(str(bestellung["bestellnummer"])): print(" Status: %s Lieferschein: %s Tracking: %s" % ( zeile.get("status", "?"), zeile.get("lsnr", "-"), zeile.get("trackingid", "-"))) client.logout() return 0 except HegaApiError as fehler: print(fehler, file=sys.stderr) if fehler.ist_nicht_freigeschaltet: print("Hinweis: HTTP 403 bedeutet in der Regel, dass die Funktion fuer Ihr Konto\n" "noch nicht freigeschaltet ist - bitte wenden Sie sich an it@hega.net.", file=sys.stderr) for einzel in fehler.errors: print(" - %s: %s" % (einzel.get("errorCode", "?"), einzel.get("message", "")), file=sys.stderr) for feld, meldungen in fehler.field_errors.items(): if isinstance(meldungen, (list, tuple)): meldungen = " / ".join(str(m) for m in meldungen) print(" - Feld %s: %s" % (feld, meldungen), file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(_demo())