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.
- quellenkontor-0.1.0/LICENSE +21 -0
- quellenkontor-0.1.0/PKG-INFO +47 -0
- quellenkontor-0.1.0/README.md +27 -0
- quellenkontor-0.1.0/pyproject.toml +34 -0
- quellenkontor-0.1.0/quellenkontor/__init__.py +329 -0
- quellenkontor-0.1.0/quellenkontor/offline_daten.json +5328 -0
- quellenkontor-0.1.0/quellenkontor.egg-info/PKG-INFO +47 -0
- quellenkontor-0.1.0/quellenkontor.egg-info/SOURCES.txt +9 -0
- quellenkontor-0.1.0/quellenkontor.egg-info/dependency_links.txt +1 -0
- quellenkontor-0.1.0/quellenkontor.egg-info/top_level.txt +1 -0
- quellenkontor-0.1.0/setup.cfg +4 -0
|
@@ -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", {})
|