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 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
+ )