datacrease 1.0.0__py3-none-any.whl
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.
- datacrease/__init__.py +58 -0
- datacrease/audit.py +145 -0
- datacrease/buffer.py +148 -0
- datacrease/checker.py +156 -0
- datacrease/cli.py +266 -0
- datacrease/config.py +22 -0
- datacrease/exceptions.py +59 -0
- datacrease/hash_guard.py +134 -0
- datacrease/iron.py +535 -0
- datacrease/models.py +133 -0
- datacrease/pipeline.py +226 -0
- datacrease/stream.py +167 -0
- datacrease-1.0.0.dist-info/METADATA +251 -0
- datacrease-1.0.0.dist-info/RECORD +18 -0
- datacrease-1.0.0.dist-info/WHEEL +5 -0
- datacrease-1.0.0.dist-info/entry_points.txt +2 -0
- datacrease-1.0.0.dist-info/licenses/LICENSE +21 -0
- datacrease-1.0.0.dist-info/top_level.txt +1 -0
datacrease/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""DataCrease: Das deterministische Daten-Bügeleisen.
|
|
2
|
+
|
|
3
|
+
Sanitization, Pufferung, Hash-Guard und Audit-Trails an Daten-Kupplungen.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datacrease.audit import AuditLogger
|
|
7
|
+
from datacrease.buffer import OverflowPolicy, RingBuffer
|
|
8
|
+
from datacrease.config import IronConfig
|
|
9
|
+
from datacrease.exceptions import (
|
|
10
|
+
BufferOverflowError,
|
|
11
|
+
ConfigError,
|
|
12
|
+
DataCreaseCorruptPayloadError,
|
|
13
|
+
DataCreaseError,
|
|
14
|
+
RegexConfigError,
|
|
15
|
+
)
|
|
16
|
+
from datacrease.hash_guard import HashGuard
|
|
17
|
+
from datacrease.iron import Iron
|
|
18
|
+
from datacrease.models import (
|
|
19
|
+
CheckerResult,
|
|
20
|
+
CreaseErrorCode,
|
|
21
|
+
FieldChangeType,
|
|
22
|
+
FieldModification,
|
|
23
|
+
IronResult,
|
|
24
|
+
ProcessingResult,
|
|
25
|
+
Receipt,
|
|
26
|
+
Status,
|
|
27
|
+
)
|
|
28
|
+
from datacrease.pipeline import DataCrease
|
|
29
|
+
from datacrease.stream import StreamProcessor, StreamStats
|
|
30
|
+
|
|
31
|
+
__version__ = "1.0.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"__version__",
|
|
35
|
+
"DataCrease",
|
|
36
|
+
"Iron",
|
|
37
|
+
"IronConfig",
|
|
38
|
+
"Checker",
|
|
39
|
+
"HashGuard",
|
|
40
|
+
"RingBuffer",
|
|
41
|
+
"OverflowPolicy",
|
|
42
|
+
"DataCreaseError",
|
|
43
|
+
"DataCreaseCorruptPayloadError",
|
|
44
|
+
"BufferOverflowError",
|
|
45
|
+
"AuditLogger",
|
|
46
|
+
"StreamProcessor",
|
|
47
|
+
"StreamStats",
|
|
48
|
+
"ConfigError",
|
|
49
|
+
"RegexConfigError",
|
|
50
|
+
"Status",
|
|
51
|
+
"CreaseErrorCode",
|
|
52
|
+
"FieldChangeType",
|
|
53
|
+
"FieldModification",
|
|
54
|
+
"IronResult",
|
|
55
|
+
"CheckerResult",
|
|
56
|
+
"ProcessingResult",
|
|
57
|
+
"Receipt",
|
|
58
|
+
]
|
datacrease/audit.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Audit-Logger: Strukturierter Append-Only JSONL-Writer und Metrik-Sammler für DataCrease.
|
|
2
|
+
|
|
3
|
+
Protokolliert jeden Datensatz mit seinem manipulationssicheren Receipt und aggregiert Latenzen.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
from datacrease.models import Receipt, Status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditLogger:
|
|
18
|
+
"""
|
|
19
|
+
Append-Only JSONL Audit-Logger:
|
|
20
|
+
- Schreibt lückenlose Audit-Trails direkt ins JSON-Lines-Format (.jsonl).
|
|
21
|
+
- Thread-sicher durch internes Datei-Locking.
|
|
22
|
+
- Führt Echtzeit-Metriken (Total, Passed, Cleaned, Dropped, Ø Latenz in µs).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
file_path: Optional[Union[str, Path]] = None,
|
|
28
|
+
auto_flush: bool = True,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
:param file_path: Zieldatei für den JSONL-Audit-Trail. Falls None, wird nur im Speicher mitgezählt.
|
|
32
|
+
:param auto_flush: Sofortiges Flushen auf die Festplatte bei jedem Schreibvorgang
|
|
33
|
+
"""
|
|
34
|
+
self.file_path: Optional[Path] = Path(file_path) if file_path else None
|
|
35
|
+
self.auto_flush = auto_flush
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
# Metrik-Zähler
|
|
39
|
+
self._total_processed = 0
|
|
40
|
+
self._total_passed = 0
|
|
41
|
+
self._total_cleaned = 0
|
|
42
|
+
self._total_dropped = 0
|
|
43
|
+
self._total_latency_us = 0
|
|
44
|
+
self._counters: Dict[str, int] = {"DROPPED_JUNK_LINE": 0}
|
|
45
|
+
|
|
46
|
+
# Datei initialisieren / Verzeichnis erstellen, falls Pfad angegeben
|
|
47
|
+
if self.file_path:
|
|
48
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
def log(
|
|
51
|
+
self,
|
|
52
|
+
receipt: Receipt,
|
|
53
|
+
record_preview: Optional[Dict[str, Any]] = None,
|
|
54
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
"""
|
|
57
|
+
Protokolliert ein Receipt inklusive optionaler Metadaten atomar.
|
|
58
|
+
|
|
59
|
+
:param receipt: Ausgestelltes Receipt-Objekt des Hash-Guards
|
|
60
|
+
:param record_preview: Optionale Vorschau auf die Daten (falls Audit Rohdaten beinhalten soll)
|
|
61
|
+
:param metadata: Zusätzliche Kontext-Informationen (z. B. Source-System, Batch-ID)
|
|
62
|
+
:return: Das geschriebene Audit-Eintrag-Dictionary
|
|
63
|
+
"""
|
|
64
|
+
entry: Dict[str, Any] = {
|
|
65
|
+
"record_id": receipt.record_id,
|
|
66
|
+
"status": receipt.status.value,
|
|
67
|
+
"sha256_raw": receipt.sha256_raw,
|
|
68
|
+
"sha256_clean": receipt.sha256_clean,
|
|
69
|
+
"modifications_count": receipt.modifications_count,
|
|
70
|
+
"timestamp_utc": receipt.timestamp_utc,
|
|
71
|
+
"latency_us": receipt.latency_us,
|
|
72
|
+
"errors": list(receipt.errors),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if record_preview is not None:
|
|
76
|
+
entry["record_preview"] = record_preview
|
|
77
|
+
|
|
78
|
+
if metadata:
|
|
79
|
+
entry["metadata"] = metadata
|
|
80
|
+
|
|
81
|
+
with self._lock:
|
|
82
|
+
# Metriken aktualisieren
|
|
83
|
+
self._total_processed += 1
|
|
84
|
+
if receipt.status == Status.PASSED:
|
|
85
|
+
self._total_passed += 1
|
|
86
|
+
elif receipt.status == Status.CLEANED:
|
|
87
|
+
self._total_cleaned += 1
|
|
88
|
+
elif receipt.status == Status.DROPPED:
|
|
89
|
+
self._total_dropped += 1
|
|
90
|
+
|
|
91
|
+
self._total_latency_us += max(0, receipt.latency_us)
|
|
92
|
+
|
|
93
|
+
# Dateiausgabe (Append-Only JSONL)
|
|
94
|
+
if self.file_path:
|
|
95
|
+
json_line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
96
|
+
with open(self.file_path, "a", encoding="utf-8") as f:
|
|
97
|
+
f.write(json_line)
|
|
98
|
+
if self.auto_flush:
|
|
99
|
+
f.flush()
|
|
100
|
+
|
|
101
|
+
return entry
|
|
102
|
+
|
|
103
|
+
def increment_counter(self, name: str, count: int = 1) -> int:
|
|
104
|
+
"""Erhöht einen benannten Zähler (z. B. DROPPED_JUNK_LINE) thread-sicher."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._counters[name] = self._counters.get(name, 0) + count
|
|
107
|
+
return self._counters[name]
|
|
108
|
+
|
|
109
|
+
def record_dropped_junk_line(self, count: int = 1) -> int:
|
|
110
|
+
"""Protokolliert übersprungene Strukturmüll-/Trennlinien."""
|
|
111
|
+
return self.increment_counter("DROPPED_JUNK_LINE", count)
|
|
112
|
+
|
|
113
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
114
|
+
"""Liefert die aktuellen Aggregats-Metriken."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
avg_latency = (
|
|
117
|
+
self._total_latency_us / self._total_processed
|
|
118
|
+
if self._total_processed > 0
|
|
119
|
+
else 0.0
|
|
120
|
+
)
|
|
121
|
+
return {
|
|
122
|
+
"total_processed": self._total_processed,
|
|
123
|
+
"total_passed": self._total_passed,
|
|
124
|
+
"total_cleaned": self._total_cleaned,
|
|
125
|
+
"total_dropped": self._total_dropped,
|
|
126
|
+
"total_latency_us": self._total_latency_us,
|
|
127
|
+
"avg_latency_us": round(avg_latency, 2),
|
|
128
|
+
"dropped_junk_lines": self._counters.get("DROPPED_JUNK_LINE", 0),
|
|
129
|
+
"counters": dict(self._counters),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def read_entries(cls, file_path: Union[str, Path]) -> Iterator[Dict[str, Any]]:
|
|
134
|
+
"""
|
|
135
|
+
Generator zum streaming-effizienten Einlesen einer JSONL-Audit-Datei.
|
|
136
|
+
"""
|
|
137
|
+
path = Path(file_path)
|
|
138
|
+
if not path.exists():
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
142
|
+
for line in f:
|
|
143
|
+
line_str = line.strip()
|
|
144
|
+
if line_str:
|
|
145
|
+
yield json.loads(line_str)
|
datacrease/buffer.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Ring-Buffer: Thread-sicherer In-Memory FIFO-Puffer für DataCrease.
|
|
2
|
+
|
|
3
|
+
Fängt Lastspitzen ab, wenn Erzeuger schneller senden als Folgesysteme konsumieren können.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import collections
|
|
9
|
+
import threading
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Generic, List, Optional, TypeVar
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OverflowPolicy(str, Enum):
|
|
17
|
+
"""Verhalten bei vollem Puffer."""
|
|
18
|
+
DROP_OLDEST = "DROP_OLDEST" # Ältestes Element stillschweigend verwerfen (Standard für Echtzeit-Streaming)
|
|
19
|
+
REJECT_NEWEST = "REJECT_NEWEST" # Neues Element ablehnen (Rückgabe False)
|
|
20
|
+
RAISE_ERROR = "RAISE_ERROR" # Löst BufferOverflowError aus
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
from datacrease.exceptions import BufferOverflowError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RingBuffer(Generic[T]):
|
|
27
|
+
"""
|
|
28
|
+
Thread-sicherer FIFO Ring-Puffer mit konfigurierbarer Kapazität und Überlaufstrategie.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
capacity: int = 1000,
|
|
34
|
+
overflow_policy: OverflowPolicy = OverflowPolicy.DROP_OLDEST,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
:param capacity: Maximale Anzahl an Elementen im Puffer (> 0)
|
|
38
|
+
:param overflow_policy: Verhalten bei Erreichen der Maximalkapazität
|
|
39
|
+
"""
|
|
40
|
+
if capacity <= 0:
|
|
41
|
+
raise ValueError(f"Kapazität muss positiv sein, erhalten: {capacity}")
|
|
42
|
+
|
|
43
|
+
self.capacity = capacity
|
|
44
|
+
self.overflow_policy = overflow_policy
|
|
45
|
+
self._deque: collections.deque[T] = collections.deque(maxlen=capacity if overflow_policy == OverflowPolicy.DROP_OLDEST else None)
|
|
46
|
+
self._lock = threading.RLock()
|
|
47
|
+
|
|
48
|
+
# Metriken
|
|
49
|
+
self._total_pushed = 0
|
|
50
|
+
self._total_popped = 0
|
|
51
|
+
self._total_dropped = 0
|
|
52
|
+
|
|
53
|
+
def push(self, item: T) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Fügt ein Element in den Puffer ein.
|
|
56
|
+
|
|
57
|
+
:param item: Einzupflegendes Element
|
|
58
|
+
:return: True, wenn das Element aufgenommen wurde, False wenn abgewiesen (bei REJECT_NEWEST)
|
|
59
|
+
:raises BufferOverflowError: Wenn der Puffer voll ist und RAISE_ERROR konfiguriert ist
|
|
60
|
+
"""
|
|
61
|
+
with self._lock:
|
|
62
|
+
current_size = len(self._deque)
|
|
63
|
+
|
|
64
|
+
if current_size >= self.capacity:
|
|
65
|
+
if self.overflow_policy == OverflowPolicy.REJECT_NEWEST:
|
|
66
|
+
self._total_dropped += 1
|
|
67
|
+
return False
|
|
68
|
+
elif self.overflow_policy == OverflowPolicy.RAISE_ERROR:
|
|
69
|
+
self._total_dropped += 1
|
|
70
|
+
raise BufferOverflowError(
|
|
71
|
+
f"Ring-Buffer Kapazität von {self.capacity} Elementen erschöpft."
|
|
72
|
+
)
|
|
73
|
+
elif self.overflow_policy == OverflowPolicy.DROP_OLDEST:
|
|
74
|
+
# Wenn maxlen gesetzt ist, verwirft deque das älteste Element automatisch,
|
|
75
|
+
# wir zählen den Drop für die Auditierung mit.
|
|
76
|
+
self._total_dropped += 1
|
|
77
|
+
|
|
78
|
+
self._deque.append(item)
|
|
79
|
+
self._total_pushed += 1
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def pop(self, default: Optional[T] = None) -> Optional[T]:
|
|
83
|
+
"""
|
|
84
|
+
Entnimmt das älteste Element nach dem FIFO-Prinzip.
|
|
85
|
+
|
|
86
|
+
:param default: Rückgabewert, falls der Puffer leer ist
|
|
87
|
+
:return: Ältestes Element oder default
|
|
88
|
+
"""
|
|
89
|
+
with self._lock:
|
|
90
|
+
if not self._deque:
|
|
91
|
+
return default
|
|
92
|
+
item = self._deque.popleft()
|
|
93
|
+
self._total_popped += 1
|
|
94
|
+
return item
|
|
95
|
+
|
|
96
|
+
def pop_batch(self, max_items: int) -> List[T]:
|
|
97
|
+
"""
|
|
98
|
+
Entnimmt bis zu `max_items` Elemente in einem atomaren Durchlauf.
|
|
99
|
+
"""
|
|
100
|
+
with self._lock:
|
|
101
|
+
batch: List[T] = []
|
|
102
|
+
count = min(max_items, len(self._deque))
|
|
103
|
+
for _ in range(count):
|
|
104
|
+
batch.append(self._deque.popleft())
|
|
105
|
+
self._total_popped += 1
|
|
106
|
+
return batch
|
|
107
|
+
|
|
108
|
+
def peek(self, default: Optional[T] = None) -> Optional[T]:
|
|
109
|
+
"""
|
|
110
|
+
Gibt das älteste Element zurück, ohne es zu entfernen.
|
|
111
|
+
"""
|
|
112
|
+
with self._lock:
|
|
113
|
+
if not self._deque:
|
|
114
|
+
return default
|
|
115
|
+
return self._deque[0]
|
|
116
|
+
|
|
117
|
+
def size(self) -> int:
|
|
118
|
+
"""Gibt die aktuelle Anzahl der Elemente im Puffer zurück."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
return len(self._deque)
|
|
121
|
+
|
|
122
|
+
def is_full(self) -> bool:
|
|
123
|
+
"""Prüft, ob der Puffer seine maximale Kapazität erreicht hat."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
return len(self._deque) >= self.capacity
|
|
126
|
+
|
|
127
|
+
def is_empty(self) -> bool:
|
|
128
|
+
"""Prüft, ob der Puffer leer ist."""
|
|
129
|
+
with self._lock:
|
|
130
|
+
return len(self._deque) == 0
|
|
131
|
+
|
|
132
|
+
def clear(self) -> None:
|
|
133
|
+
"""Leert den gesamten Puffer."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
self._deque.clear()
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def metrics(self) -> dict:
|
|
139
|
+
"""Gibt Zählerstände für Monitoring und Auditierung zurück."""
|
|
140
|
+
with self._lock:
|
|
141
|
+
return {
|
|
142
|
+
"capacity": self.capacity,
|
|
143
|
+
"current_size": len(self._deque),
|
|
144
|
+
"total_pushed": self._total_pushed,
|
|
145
|
+
"total_popped": self._total_popped,
|
|
146
|
+
"total_dropped": self._total_dropped,
|
|
147
|
+
"overflow_policy": self.overflow_policy.value,
|
|
148
|
+
}
|
datacrease/checker.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Data-Checker: Plausibilitäts-, Pflichtfeld- und Schwellenwert-Validierung.
|
|
2
|
+
|
|
3
|
+
Deterministische Validierung vor und nach dem Glätten mit strikter Regex-Governance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Pattern, Set, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from datacrease.models import CheckerResult, CreaseErrorCode, Status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
from datacrease.exceptions import ConfigError, RegexConfigError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Checker:
|
|
18
|
+
"""
|
|
19
|
+
Data-Checker: Prüft Pflichtfelder, Schwellenwerte, Whitelists und reguläre Ausdrücke.
|
|
20
|
+
|
|
21
|
+
Hält sich strikt an die DataCrease-Regex-Governance:
|
|
22
|
+
- Verwendet Python Standard-NFA (`re`, PCRE-Standard).
|
|
23
|
+
- Kompiliert und validiert alle Regex-Muster strikt bei der Initialisierung.
|
|
24
|
+
- Fängt fehlerhafte Syntax vor dem Live-Stream als RegexConfigError ab.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
required_fields: Optional[Iterable[str]] = None,
|
|
30
|
+
numeric_ranges: Optional[Dict[str, Tuple[Optional[float], Optional[float]]]] = None,
|
|
31
|
+
allowed_values: Optional[Dict[str, Set[Any]]] = None,
|
|
32
|
+
regex_rules: Optional[Dict[str, Union[str, Pattern[str]]]] = None,
|
|
33
|
+
custom_rules: Optional[Dict[str, Callable[[Any], bool]]] = None,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialisiert den Checker mit Validierungsregeln.
|
|
37
|
+
|
|
38
|
+
:param required_fields: Felder, die zwingend im Record vorhanden und nicht None sein müssen
|
|
39
|
+
:param numeric_ranges: Schwellenwerte pro Feld als (min_wert, max_wert)
|
|
40
|
+
:param allowed_values: Erlaubte Werte pro Feld (Whitelist/Enums)
|
|
41
|
+
:param regex_rules: Reguläre Ausdrücke pro Feld (werden sofort pre-kompiliert)
|
|
42
|
+
:param custom_rules: Benutzerdefinierte Prädikat-Funktionen (Key -> Bool)
|
|
43
|
+
:raises RegexConfigError: Falls ein übergebenes Regex-Muster syntaktisch ungültig ist
|
|
44
|
+
"""
|
|
45
|
+
self.required_fields: Set[str] = set(required_fields) if required_fields else set()
|
|
46
|
+
self.numeric_ranges: Dict[str, Tuple[Optional[float], Optional[float]]] = numeric_ranges or {}
|
|
47
|
+
self.allowed_values: Dict[str, Set[Any]] = allowed_values or {}
|
|
48
|
+
self.custom_rules: Dict[str, Callable[[Any], bool]] = custom_rules or {}
|
|
49
|
+
|
|
50
|
+
# Pre-Compilation und Syntax-Validierung der Regex-Regeln
|
|
51
|
+
self.compiled_regex_rules: Dict[str, Pattern[str]] = {}
|
|
52
|
+
if regex_rules:
|
|
53
|
+
for field_name, pattern in regex_rules.items():
|
|
54
|
+
if isinstance(pattern, re.Pattern):
|
|
55
|
+
self.compiled_regex_rules[field_name] = pattern
|
|
56
|
+
elif isinstance(pattern, str):
|
|
57
|
+
try:
|
|
58
|
+
self.compiled_regex_rules[field_name] = re.compile(pattern)
|
|
59
|
+
except re.error as err:
|
|
60
|
+
raise RegexConfigError(
|
|
61
|
+
f"CONFIG_ERROR: Ungültiges Regex-Muster für Feld '{field_name}': {pattern!r}. "
|
|
62
|
+
f"Fehler: {err}"
|
|
63
|
+
) from err
|
|
64
|
+
else:
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
f"CONFIG_ERROR: Regex für Feld '{field_name}' muss ein String oder re.Pattern sein, "
|
|
67
|
+
f"erhalten: {type(pattern).__name__}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def check(self, record: Dict[str, Any]) -> CheckerResult:
|
|
71
|
+
"""
|
|
72
|
+
Validiert einen Datensatz gegen alle definierten Regeln.
|
|
73
|
+
|
|
74
|
+
:param record: Zu prüfendes Datensatz-Dictionary
|
|
75
|
+
:return: CheckerResult mit Validitätsstatus und detaillierter Fehlerliste
|
|
76
|
+
"""
|
|
77
|
+
violations: List[str] = []
|
|
78
|
+
missing_fields: List[str] = []
|
|
79
|
+
error_codes: List[CreaseErrorCode] = []
|
|
80
|
+
|
|
81
|
+
# 1. Pflichtfeld-Prüfung
|
|
82
|
+
for field in self.required_fields:
|
|
83
|
+
if field not in record:
|
|
84
|
+
missing_fields.append(field)
|
|
85
|
+
violations.append(f"Pflichtfeld '{field}' fehlt.")
|
|
86
|
+
error_codes.append(CreaseErrorCode.MISSING_REQUIRED_FIELD)
|
|
87
|
+
elif record[field] is None:
|
|
88
|
+
missing_fields.append(field)
|
|
89
|
+
violations.append(f"Pflichtfeld '{field}' ist null.")
|
|
90
|
+
error_codes.append(CreaseErrorCode.NULL_VALUE_REJECTED)
|
|
91
|
+
|
|
92
|
+
# 2. Schwellenwert-Prüfung (Numerische Bereiche)
|
|
93
|
+
for field, (min_val, max_val) in self.numeric_ranges.items():
|
|
94
|
+
if field in record and record[field] is not None:
|
|
95
|
+
val = record[field]
|
|
96
|
+
try:
|
|
97
|
+
num_val = float(val) if not isinstance(val, (int, float)) else float(val)
|
|
98
|
+
if min_val is not None and num_val < min_val:
|
|
99
|
+
violations.append(
|
|
100
|
+
f"Feld '{field}' unterschreitet Schwellenwert: {num_val} < {min_val}"
|
|
101
|
+
)
|
|
102
|
+
error_codes.append(CreaseErrorCode.NUMERIC_BELOW_MIN)
|
|
103
|
+
if max_val is not None and num_val > max_val:
|
|
104
|
+
violations.append(
|
|
105
|
+
f"Feld '{field}' überschreitet Schwellenwert: {num_val} > {max_val}"
|
|
106
|
+
)
|
|
107
|
+
error_codes.append(CreaseErrorCode.NUMERIC_ABOVE_MAX)
|
|
108
|
+
except (ValueError, TypeError):
|
|
109
|
+
violations.append(
|
|
110
|
+
f"Feld '{field}' ist kein gültiger numerischer Wert für Schwellenwertprüfung: {val!r}"
|
|
111
|
+
)
|
|
112
|
+
error_codes.append(CreaseErrorCode.UNPARSEABLE_NUMBER)
|
|
113
|
+
|
|
114
|
+
# 3. Whitelist / Erlaubte Werte
|
|
115
|
+
for field, allowed_set in self.allowed_values.items():
|
|
116
|
+
if field in record and record[field] is not None:
|
|
117
|
+
val = record[field]
|
|
118
|
+
if val not in allowed_set:
|
|
119
|
+
violations.append(
|
|
120
|
+
f"Wert {val!r} in Feld '{field}' ist nicht in erlaubter Wertemenge enthalten."
|
|
121
|
+
)
|
|
122
|
+
error_codes.append(CreaseErrorCode.DISALLOWED_VALUE)
|
|
123
|
+
|
|
124
|
+
# 4. Regex-Muster-Validierung
|
|
125
|
+
for field, pattern in self.compiled_regex_rules.items():
|
|
126
|
+
if field in record and record[field] is not None:
|
|
127
|
+
val_str = str(record[field])
|
|
128
|
+
if not pattern.search(val_str):
|
|
129
|
+
violations.append(
|
|
130
|
+
f"Feld '{field}' ({val_str!r}) entspricht nicht dem geforderten Muster {pattern.pattern!r}."
|
|
131
|
+
)
|
|
132
|
+
error_codes.append(CreaseErrorCode.REGEX_PATTERN_MISMATCH)
|
|
133
|
+
|
|
134
|
+
# 5. Benutzerdefinierte Prädikat-Regeln
|
|
135
|
+
for field, rule_fn in self.custom_rules.items():
|
|
136
|
+
if field in record and record[field] is not None:
|
|
137
|
+
try:
|
|
138
|
+
if not rule_fn(record[field]):
|
|
139
|
+
violations.append(
|
|
140
|
+
f"Feld '{field}' verletzt benutzerdefinierte Regel: Wert {record[field]!r}"
|
|
141
|
+
)
|
|
142
|
+
except Exception as ex:
|
|
143
|
+
violations.append(
|
|
144
|
+
f"Fehler bei Ausführung benutzerdefinierter Regel für Feld '{field}': {ex}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
is_valid = len(violations) == 0
|
|
148
|
+
status = Status.PASSED if is_valid else Status.DROPPED
|
|
149
|
+
|
|
150
|
+
return CheckerResult(
|
|
151
|
+
is_valid=is_valid,
|
|
152
|
+
status=status,
|
|
153
|
+
violations=violations,
|
|
154
|
+
missing_fields=missing_fields,
|
|
155
|
+
error_codes=error_codes,
|
|
156
|
+
)
|