quellenkontor 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quellenkontor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: quellenkontor
3
+ Version: 0.1.0
4
+ Summary: Offizielles SDK für die Quellenkontor-API: geprüfte HR-Daten für Deutschland mit Quelle und Gültigkeit. German HR reference data API client.
5
+ License: MIT
6
+ Project-URL: Homepage, https://quellenkontor.dev
7
+ Project-URL: Documentation, https://quellenkontor.dev/docs/sdk-python
8
+ Project-URL: Changelog, https://quellenkontor.dev/docs/aenderungen
9
+ Project-URL: Repository, https://github.com/NicksAdvisory/quellenkontor-sdk
10
+ Project-URL: Issues, https://github.com/NicksAdvisory/quellenkontor-sdk/issues
11
+ Keywords: hr,mindestlohn,sozialversicherung,feiertage,api,lohn,germany,german,minimum-wage,payroll,social-security,public-holidays,hr-data
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Natural Language :: German
15
+ Classifier: Topic :: Office/Business
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # quellenkontor
22
+
23
+ **English:** Official Python SDK for the [Quellenkontor](https://quellenkontor.dev/en) API: verified German HR reference data (minimum wage, social security ceilings and rates, public holidays by state, notice periods and more), each value with its legal basis, official source and effective date. No dependencies, Python 3.9+. English docs: [quellenkontor.dev/en/docs/sdk-python](https://quellenkontor.dev/en/docs/sdk-python). Field and parameter names are German, as in the API.
24
+
25
+
26
+ Offizielles Python-SDK für die [Quellenkontor](https://quellenkontor.dev)-API: geprüfte HR-Daten für Deutschland mit Quelle, Rechtsgrundlage und Gültigkeit. Ohne Abhängigkeiten, ab Python 3.9.
27
+
28
+ ```bash
29
+ pip install quellenkontor
30
+ ```
31
+
32
+ ```python
33
+ import os
34
+ from quellenkontor import Quellenkontor
35
+
36
+ qk = Quellenkontor(api_key=os.environ["QK_KEY"])
37
+
38
+ ml = qk.hr.mindestlohn(datum="2027-01-15")
39
+ print(ml["mindestlohn_brutto_stunde"], ml["minijob_grenze_monat"]) # 14.6 633
40
+
41
+ frist = qk.hr.kuendigungsfrist(eintritt="2017-03-01", zugang="2026-11-10")
42
+ print(frist["ende"]) # 2027-02-28
43
+ ```
44
+
45
+ Fehler der API kommen als `QuellenkontorFehler` mit `status`, `code` und `parameter`.
46
+
47
+ Kostenloser Schlüssel: https://quellenkontor.dev/anmelden · Doku: https://quellenkontor.dev/docs/sdk-python
@@ -0,0 +1,27 @@
1
+ # quellenkontor
2
+
3
+ **English:** Official Python SDK for the [Quellenkontor](https://quellenkontor.dev/en) API: verified German HR reference data (minimum wage, social security ceilings and rates, public holidays by state, notice periods and more), each value with its legal basis, official source and effective date. No dependencies, Python 3.9+. English docs: [quellenkontor.dev/en/docs/sdk-python](https://quellenkontor.dev/en/docs/sdk-python). Field and parameter names are German, as in the API.
4
+
5
+
6
+ Offizielles Python-SDK für die [Quellenkontor](https://quellenkontor.dev)-API: geprüfte HR-Daten für Deutschland mit Quelle, Rechtsgrundlage und Gültigkeit. Ohne Abhängigkeiten, ab Python 3.9.
7
+
8
+ ```bash
9
+ pip install quellenkontor
10
+ ```
11
+
12
+ ```python
13
+ import os
14
+ from quellenkontor import Quellenkontor
15
+
16
+ qk = Quellenkontor(api_key=os.environ["QK_KEY"])
17
+
18
+ ml = qk.hr.mindestlohn(datum="2027-01-15")
19
+ print(ml["mindestlohn_brutto_stunde"], ml["minijob_grenze_monat"]) # 14.6 633
20
+
21
+ frist = qk.hr.kuendigungsfrist(eintritt="2017-03-01", zugang="2026-11-10")
22
+ print(frist["ende"]) # 2027-02-28
23
+ ```
24
+
25
+ Fehler der API kommen als `QuellenkontorFehler` mit `status`, `code` und `parameter`.
26
+
27
+ Kostenloser Schlüssel: https://quellenkontor.dev/anmelden · Doku: https://quellenkontor.dev/docs/sdk-python
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quellenkontor"
7
+ version = "0.1.0"
8
+ description = "Offizielles SDK für die Quellenkontor-API: geprüfte HR-Daten für Deutschland mit Quelle und Gültigkeit. German HR reference data API client."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["hr", "mindestlohn", "sozialversicherung", "feiertage", "api", "lohn", "germany", "german", "minimum-wage", "payroll", "social-security", "public-holidays", "hr-data"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Natural Language :: German",
17
+ "Topic :: Office/Business",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://quellenkontor.dev"
23
+ Documentation = "https://quellenkontor.dev/docs/sdk-python"
24
+ Changelog = "https://quellenkontor.dev/docs/aenderungen"
25
+ Repository = "https://github.com/NicksAdvisory/quellenkontor-sdk"
26
+ Issues = "https://github.com/NicksAdvisory/quellenkontor-sdk/issues"
27
+
28
+ [tool.setuptools]
29
+ packages = ["quellenkontor"]
30
+
31
+ [tool.setuptools.package-data]
32
+ # Offline-Stand für den Fallback ohne Verbindung (Quellenkontor(..., offline=True)), siehe
33
+ # scripts/offline-snapshot.mjs in der App.
34
+ quellenkontor = ["offline_daten.json"]
@@ -0,0 +1,329 @@
1
+ """Offizielles SDK für die Quellenkontor-API.
2
+
3
+ Geprüfte HR-Daten für Deutschland mit Quelle, Rechtsgrundlage und Gültigkeit.
4
+ Ohne Abhängigkeiten, ab Python 3.9.
5
+
6
+ import os
7
+ from quellenkontor import Quellenkontor
8
+
9
+ qk = Quellenkontor(api_key=os.environ["QK_KEY"])
10
+ ml = qk.hr.mindestlohn(datum="2027-01-15")
11
+ print(ml["mindestlohn_brutto_stunde"]) # 14.6
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ import importlib.resources
17
+ import json
18
+ import os
19
+ import re
20
+ import time
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ __all__ = ["Quellenkontor", "QuellenkontorFehler", "__version__"]
27
+ __version__ = "0.1.0"
28
+
29
+ STANDARD_BASIS = "https://api.quellenkontor.dev/v1"
30
+ STANDARD_CACHE_TTL = 300.0 # Sekunden, wie der Standard von 5 Minuten im JavaScript-SDK
31
+ MIT_VERLAUF = {
32
+ "mindestlohn", "rechengroessen", "beitragssaetze", "sachbezugswerte", "pfaendungsfreigrenzen", "uebergangsbereich",
33
+ "minijob-abgaben", "reisekosten-inland", "mindestausbildungsverguetung", "kuenstlersozialabgabe", "steuerfreie-betraege",
34
+ "sfn-zuschlaege", "pflegemindestlohn", "ausgleichsabgabe", "einkommensteuer-eckwerte",
35
+ }
36
+
37
+
38
+ class QuellenkontorFehler(Exception):
39
+ """Fehler der API mit HTTP-Status, maschinenlesbarem Code und betroffenem Parameter."""
40
+
41
+ def __init__(self, nachricht: str, status: int, code: str, parameter: Optional[str] = None):
42
+ super().__init__(nachricht)
43
+ self.status = status
44
+ self.code = code
45
+ self.parameter = parameter
46
+
47
+
48
+ def _wert(v: Any) -> str:
49
+ if isinstance(v, bool):
50
+ return "true" if v else "false"
51
+ return str(v)
52
+
53
+
54
+ # --------------------------------------------------------------------------------------------------
55
+ # Offline-Stand: Verlauf der Tabellen-Datensätze, mitgeliefert in offline_daten.json (siehe
56
+ # scripts/offline-snapshot.mjs in der App). Genutzt als Fallback, wenn die API nach allen
57
+ # Wiederholungen nicht erreichbar ist (Quellenkontor(..., offline=True), Standard an).
58
+
59
+ _OFFLINE_DATEN: Optional[Dict[str, Any]] = None
60
+ _PFAD_MUSTER = re.compile(r"^/hr/([a-z0-9-]+)(/verlauf)?$")
61
+
62
+
63
+ def _offline_daten() -> Dict[str, Any]:
64
+ global _OFFLINE_DATEN
65
+ if _OFFLINE_DATEN is None:
66
+ with importlib.resources.files(__package__).joinpath("offline_daten.json").open("r", encoding="utf-8") as f:
67
+ _OFFLINE_DATEN = json.load(f)
68
+ return _OFFLINE_DATEN
69
+
70
+
71
+ def _beginn_von(z: Dict[str, Any]) -> str:
72
+ """Beginn einer Verlaufszeile: gueltig_ab, sonst der 1. Januar des Feldes jahr (wie lib/verlauf.ts serverseitig)."""
73
+ gueltig_ab = z.get("gueltig_ab")
74
+ return gueltig_ab if isinstance(gueltig_ab, str) else f"{z['jahr']}-01-01"
75
+
76
+
77
+ def _aktuelle_zeilen(zeilen: List[Dict[str, Any]], ziel_datum: str) -> List[Dict[str, Any]]:
78
+ """Je Gruppe (bestandteil, sonst eine gemeinsame Gruppe) die jüngste Zeile, deren Beginn nicht nach ziel_datum liegt."""
79
+ gruppen: Dict[str, List[Dict[str, Any]]] = {}
80
+ for z in zeilen:
81
+ g = z.get("bestandteil") if isinstance(z.get("bestandteil"), str) else ""
82
+ gruppen.setdefault(g, []).append(z)
83
+ aus: List[Dict[str, Any]] = []
84
+ for liste in gruppen.values():
85
+ passend = sorted((z for z in liste if _beginn_von(z) <= ziel_datum), key=_beginn_von)
86
+ if passend:
87
+ aus.append(passend[-1])
88
+ return aus
89
+
90
+
91
+ def _verlauf_offline(zeilen: List[Dict[str, Any]], werte: Dict[str, Any]) -> List[Dict[str, Any]]:
92
+ """Verlauf offline filtern: von und bis wirken auf gueltig_ab selbst, einfacher als live (siehe Doku)."""
93
+ bestandteil = werte.get("bestandteil")
94
+ von = werte.get("von")
95
+ bis = werte.get("bis")
96
+
97
+ def passt(z: Dict[str, Any]) -> bool:
98
+ if bestandteil is not None and z.get("bestandteil") != bestandteil:
99
+ return False
100
+ beginn = _beginn_von(z)
101
+ if von is not None and beginn < von:
102
+ return False
103
+ if bis is not None and beginn > bis:
104
+ return False
105
+ return True
106
+
107
+ return [z for z in zeilen if passt(z)]
108
+
109
+
110
+ def _offline_abfrage(pfad: str, werte: Dict[str, Any]) -> Optional[Dict[str, Any]]:
111
+ """Offline-Antwort für einen Pfad, oder None, wenn der Pfad kein Tabellen-Datensatz mit Verlauf ist."""
112
+ treffer_pfad = _PFAD_MUSTER.match(pfad)
113
+ if not treffer_pfad:
114
+ return None
115
+ datensatz_id, ist_verlauf = treffer_pfad.group(1), treffer_pfad.group(2) is not None
116
+ daten = _offline_daten()
117
+ zeilen = daten["datensaetze"].get(datensatz_id)
118
+ if zeilen is None:
119
+ return None
120
+ datenstand = daten["datenstand"]
121
+
122
+ if ist_verlauf:
123
+ gefiltert = _verlauf_offline(zeilen, werte)
124
+ return {"datensatz": datensatz_id, "anzahl": len(gefiltert), "verlauf": gefiltert, "offline": True, "datenstand": datenstand}
125
+
126
+ if werte.get("datum") is not None:
127
+ ziel_datum = werte["datum"]
128
+ elif werte.get("jahr") is not None:
129
+ ziel_datum = f"{werte['jahr']}-12-31"
130
+ else:
131
+ ziel_datum = datetime.date.today().isoformat()
132
+ treffer = _aktuelle_zeilen(zeilen, ziel_datum)
133
+ if not treffer:
134
+ raise QuellenkontorFehler(
135
+ f"Für diesen Stichtag gibt es im Offline-Stand ({datenstand}) noch keinen Wert von {datensatz_id}. "
136
+ "Sobald die API wieder erreichbar ist, liefert sie den amtlichen Stand.",
137
+ 0, "kein_wert_offline",
138
+ )
139
+ basis: Dict[str, Any] = {"datensatz": datensatz_id, "offline": True, "datenstand": datenstand}
140
+ if werte.get("datum") is not None:
141
+ basis["datum"] = ziel_datum
142
+ if werte.get("jahr") is not None:
143
+ basis["jahr"] = werte["jahr"]
144
+ if not any(isinstance(z.get("bestandteil"), str) for z in treffer):
145
+ return {**basis, **treffer[0]}
146
+ return {**basis, "werte": {z["bestandteil"]: z.get("wert") for z in treffer}, "bestandteile": treffer}
147
+
148
+
149
+ class _HR:
150
+ def __init__(self, client: "Quellenkontor"):
151
+ self._c = client
152
+
153
+ def mindestlohn(self, datum: Optional[str] = None) -> Dict[str, Any]:
154
+ return self._c.anfrage("/hr/mindestlohn", {"datum": datum})
155
+
156
+ def rechengroessen(self, jahr: Optional[int] = None) -> Dict[str, Any]:
157
+ return self._c.anfrage("/hr/rechengroessen", {"jahr": jahr})
158
+
159
+ def beitragssaetze(self, datum: Optional[str] = None) -> Dict[str, Any]:
160
+ return self._c.anfrage("/hr/beitragssaetze", {"datum": datum})
161
+
162
+ def sachbezugswerte(self, jahr: Optional[int] = None) -> Dict[str, Any]:
163
+ return self._c.anfrage("/hr/sachbezugswerte", {"jahr": jahr})
164
+
165
+ def pfaendungsfreigrenzen(self, datum: Optional[str] = None, unterhaltspflichten: Optional[int] = None, netto: Optional[float] = None) -> Dict[str, Any]:
166
+ return self._c.anfrage("/hr/pfaendungsfreigrenzen", {"datum": datum, "unterhaltspflichten": unterhaltspflichten, "netto": netto})
167
+
168
+ def uebergangsbereich(self, datum: Optional[str] = None) -> Dict[str, Any]:
169
+ return self._c.anfrage("/hr/uebergangsbereich", {"datum": datum})
170
+
171
+ def kuendigungsfrist(self, eintritt: str, zugang: str, seite: Optional[str] = None, probezeit: Optional[bool] = None) -> Dict[str, Any]:
172
+ return self._c.anfrage("/hr/kuendigungsfrist", {"eintritt": eintritt, "zugang": zugang, "seite": seite, "probezeit": probezeit})
173
+
174
+ def urlaubsanspruch(self, arbeitstage_pro_woche: float, jahr: Optional[int] = None, eintritt: Optional[str] = None, austritt: Optional[str] = None) -> Dict[str, Any]:
175
+ return self._c.anfrage("/hr/urlaubsanspruch", {"arbeitstage_pro_woche": arbeitstage_pro_woche, "jahr": jahr, "eintritt": eintritt, "austritt": austritt})
176
+
177
+ def mutterschutz(self, termin: Optional[str] = None, geburt: Optional[str] = None, fall: Optional[str] = None, ssw: Optional[int] = None) -> Dict[str, Any]:
178
+ return self._c.anfrage("/hr/mutterschutz", {"termin": termin, "geburt": geburt, "fall": fall, "ssw": ssw})
179
+
180
+ def feiertage(self, jahr: Optional[int] = None, land: Optional[str] = None) -> Dict[str, Any]:
181
+ return self._c.anfrage("/hr/feiertage", {"jahr": jahr, "land": land})
182
+
183
+ def arbeitstage(self, von: str, bis: str, land: str, samstag: Optional[bool] = None, regionale: Optional[bool] = None) -> Dict[str, Any]:
184
+ return self._c.anfrage("/hr/arbeitstage", {"von": von, "bis": bis, "land": land, "samstag": samstag, "regionale": regionale})
185
+
186
+ def minijob_abgaben(self, datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
187
+ return self._c.anfrage("/hr/minijob-abgaben", {"datum": datum, "bestandteil": bestandteil})
188
+
189
+ def reisekosten_inland(self, datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
190
+ return self._c.anfrage("/hr/reisekosten-inland", {"datum": datum, "bestandteil": bestandteil})
191
+
192
+ def mindestausbildungsverguetung(self, beginn: Optional[str] = None, ausbildungsjahr: Optional[int] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
193
+ return self._c.anfrage("/hr/mindestausbildungsverguetung", {"beginn": beginn, "ausbildungsjahr": ausbildungsjahr, "bestandteil": bestandteil})
194
+
195
+ def kuenstlersozialabgabe(self, jahr: Optional[int] = None, datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
196
+ return self._c.anfrage("/hr/kuenstlersozialabgabe", {"jahr": jahr, "datum": datum, "bestandteil": bestandteil})
197
+
198
+ def steuerfreie_betraege(self, datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
199
+ return self._c.anfrage("/hr/steuerfreie-betraege", {"datum": datum, "bestandteil": bestandteil})
200
+
201
+ def sfn_zuschlaege(self, datum: Optional[str] = None, grundlohn_stunde: Optional[float] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
202
+ return self._c.anfrage("/hr/sfn-zuschlaege", {"datum": datum, "grundlohn_stunde": grundlohn_stunde, "bestandteil": bestandteil})
203
+
204
+ def dienstwagen(self, listenpreis: float, antrieb: Optional[str] = None, anschaffung: Optional[str] = None, entfernung_km: Optional[float] = None,
205
+ co2_g_km: Optional[float] = None, reichweite_km: Optional[float] = None, batterie_kwh: Optional[float] = None, datum: Optional[str] = None,
206
+ ueberlassung: Optional[str] = None, fahrten_monat: Optional[int] = None, zuzahlung_monat: Optional[float] = None) -> Dict[str, Any]:
207
+ return self._c.anfrage("/hr/dienstwagen", {"listenpreis": listenpreis, "antrieb": antrieb, "anschaffung": anschaffung, "ueberlassung": ueberlassung,
208
+ "entfernung_km": entfernung_km, "fahrten_monat": fahrten_monat, "zuzahlung_monat": zuzahlung_monat,
209
+ "co2_g_km": co2_g_km, "reichweite_km": reichweite_km, "batterie_kwh": batterie_kwh, "datum": datum})
210
+
211
+ def pflegemindestlohn(self, datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
212
+ return self._c.anfrage("/hr/pflegemindestlohn", {"datum": datum, "bestandteil": bestandteil})
213
+
214
+ def ausgleichsabgabe(self, jahr: Optional[int] = None, arbeitsplaetze: Optional[float] = None, besetzt: Optional[float] = None,
215
+ datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
216
+ return self._c.anfrage("/hr/ausgleichsabgabe", {"jahr": jahr, "datum": datum, "arbeitsplaetze": arbeitsplaetze, "besetzt": besetzt, "bestandteil": bestandteil})
217
+
218
+ def einkommensteuer_eckwerte(self, jahr: Optional[int] = None, datum: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
219
+ return self._c.anfrage("/hr/einkommensteuer-eckwerte", {"jahr": jahr, "datum": datum, "bestandteil": bestandteil})
220
+
221
+ def regelaltersgrenze(self, geburtsdatum: Optional[str] = None, vertrauensschutz: Optional[bool] = None, geburtsjahr: Optional[int] = None) -> Dict[str, Any]:
222
+ """Mit geburtsdatum genau, mit geburtsjahr für den ganzen Jahrgang (Spanne des Rentenbeginns)."""
223
+ return self._c.anfrage("/hr/regelaltersgrenze", {"geburtsdatum": geburtsdatum, "geburtsjahr": geburtsjahr, "vertrauensschutz": vertrauensschutz})
224
+
225
+ def pausen(self, arbeitszeit_stunden: float, jugendlich: Optional[bool] = None) -> Dict[str, Any]:
226
+ return self._c.anfrage("/hr/pausen", {"arbeitszeit_stunden": arbeitszeit_stunden, "jugendlich": jugendlich})
227
+
228
+ def verlauf(self, datensatz: str, von: Optional[str] = None, bis: Optional[str] = None, bestandteil: Optional[str] = None) -> Dict[str, Any]:
229
+ """Stufen einer Reihe seit 2015, auf Wunsch nur ein Zeitraum oder ein Bestandteil."""
230
+ if datensatz not in MIT_VERLAUF:
231
+ raise ValueError(f"Für {datensatz} gibt es keinen Verlauf.")
232
+ return self._c.anfrage(f"/hr/{datensatz}/verlauf", {"von": von, "bis": bis, "bestandteil": bestandteil})
233
+
234
+
235
+ class Quellenkontor:
236
+ """Client für die Quellenkontor-API. Ohne api_key wird QK_KEY aus der Umgebung gelesen."""
237
+
238
+ def __init__(
239
+ self,
240
+ api_key: Optional[str] = None,
241
+ basis_url: str = STANDARD_BASIS,
242
+ timeout: float = 15.0,
243
+ wiederholungen: int = 2,
244
+ cache: bool = True,
245
+ cache_ttl: float = STANDARD_CACHE_TTL,
246
+ offline: bool = True,
247
+ ):
248
+ self.api_key = api_key or os.environ.get("QK_KEY")
249
+ self.basis_url = basis_url.rstrip("/")
250
+ self.timeout = timeout
251
+ self.wiederholungen = wiederholungen
252
+ # Zwischenspeicher im Speicher dieser Instanz: eine unveränderte Antwort kommt ohne erneute
253
+ # Anfrage zurück, danach nutzt anfrage() If-None-Match mit der gemerkten ETag (304 kostet kein
254
+ # Kontingent). cache_ttl in Sekunden, Standard 5 Minuten.
255
+ self.cache = cache
256
+ self.cache_ttl = cache_ttl
257
+ self._cache: Dict[str, Dict[str, Any]] = {}
258
+ # Offline-Stand nutzen, wenn die API nach allen Wiederholungen nicht erreichbar ist: nur für
259
+ # Tabellen-Datensätze mit Verlauf und nur format="json", siehe _offline_abfrage.
260
+ self.offline = offline
261
+ self.hr = _HR(self)
262
+
263
+ def cache_leeren(self) -> None:
264
+ """Leert den Zwischenspeicher, etwa nach einem bekannten neuen Wert (Webhook)."""
265
+ self._cache.clear()
266
+
267
+ def anfrage(self, pfad: str, werte: Dict[str, Any], format: str = "json") -> Any:
268
+ """Rohe Anfrage an einen Pfad, zum Beispiel "/hr/mindestlohn". Liefert ein dict oder bei format="csv" Text."""
269
+ parameter = {k: _wert(v) for k, v in werte.items() if v is not None}
270
+ if format == "csv":
271
+ parameter["format"] = "csv"
272
+ url = self.basis_url + pfad + ("?" + urllib.parse.urlencode(parameter) if parameter else "")
273
+
274
+ bekannt = self._cache.get(url) if self.cache else None
275
+ if bekannt is not None and bekannt["bis"] > time.monotonic():
276
+ return bekannt["wert"]
277
+
278
+ koepfe = {"Accept": "text/csv" if format == "csv" else "application/json", "User-Agent": f"quellenkontor-sdk-python/{__version__}"}
279
+ if self.api_key:
280
+ koepfe["Authorization"] = f"Bearer {self.api_key}"
281
+ if bekannt is not None and bekannt.get("etag"):
282
+ koepfe["If-None-Match"] = bekannt["etag"]
283
+
284
+ letzter: Optional[Exception] = None
285
+ for versuch in range(self.wiederholungen + 1):
286
+ if versuch:
287
+ time.sleep(0.3 * 2 ** (versuch - 1))
288
+ try:
289
+ with urllib.request.urlopen(urllib.request.Request(url, headers=koepfe), timeout=self.timeout) as antwort:
290
+ text = antwort.read().decode("utf-8-sig")
291
+ wert = text if format == "csv" else json.loads(text)
292
+ if self.cache:
293
+ self._cache[url] = {"wert": wert, "etag": antwort.headers.get("ETag"), "bis": time.monotonic() + self.cache_ttl}
294
+ return wert
295
+ except urllib.error.HTTPError as e:
296
+ # Unverändert seit der gemerkten ETag: die gecachte Antwort gilt weiter, ohne dass es zählt
297
+ if e.code == 304 and bekannt is not None:
298
+ if self.cache:
299
+ bekannt["bis"] = time.monotonic() + self.cache_ttl
300
+ self._cache[url] = bekannt
301
+ return bekannt["wert"]
302
+ if e.code in (502, 503, 504) and versuch < self.wiederholungen:
303
+ continue
304
+ try:
305
+ fehler = json.loads(e.read().decode("utf-8")).get("fehler", {})
306
+ except Exception:
307
+ fehler = {}
308
+ raise QuellenkontorFehler(fehler.get("nachricht", f"HTTP {e.code}"), e.code, fehler.get("code", "unbekannt"), fehler.get("parameter")) from None
309
+ except (urllib.error.URLError, TimeoutError) as e:
310
+ letzter = e
311
+ continue
312
+ # Nach allen Wiederholungen nicht erreichbar: mit dem Offline-Stand weiterhelfen, wenn er diesen Pfad abdeckt
313
+ if self.offline and format == "json":
314
+ offline_wert = _offline_abfrage(pfad, werte)
315
+ if offline_wert is not None:
316
+ return offline_wert
317
+ raise QuellenkontorFehler(f"Keine Verbindung zur API: {letzter}", 0, "netzwerk")
318
+
319
+ def datensaetze(self) -> Dict[str, Any]:
320
+ """Katalog aller Datensätze (ohne Schlüssel abrufbar)."""
321
+ return self.anfrage("/datensaetze", {})
322
+
323
+ def aenderungen(self) -> Dict[str, Any]:
324
+ """Änderungsprotokoll (ohne Schlüssel abrufbar)."""
325
+ return self.anfrage("/aenderungen", {})
326
+
327
+ def status(self) -> Dict[str, Any]:
328
+ """Prüfstand je Datensatz (ohne Schlüssel abrufbar)."""
329
+ return self.anfrage("/status", {})