Skip to content

Commit cb71a59

Browse files
committed
feat: add German regex locale support
1 parent 2ba8e34 commit cb71a59

16 files changed

Lines changed: 637 additions & 55 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ print(datafog.sanitize("Card: 4111-1111-1111-1111", engine="regex"))
7676
# Card: [CREDIT_CARD_1]
7777
```
7878

79+
## German Structured PII
80+
81+
German VAT IDs and German IBANs are detected by the default regex path because
82+
their country-code structure is specific enough for default-on screening.
83+
Broader German identifiers are available with explicit locale selection or
84+
explicit entity-type filtering.
85+
86+
```python
87+
import datafog
88+
89+
text = "Steuer-ID 12345678901 liegt vor."
90+
91+
print(datafog.scan(text, engine="regex").entities)
92+
# []
93+
94+
print(datafog.scan(text, engine="regex", locales=["de"]).entities)
95+
# [Entity(type='DE_TAX_ID', text='12345678901', ...)]
96+
```
97+
7998
### Guardrails
8099

81100
```python
@@ -98,7 +117,8 @@ Use the engine that matches your accuracy and dependency constraints:
98117

99118
- `regex`:
100119
- Fastest and always available.
101-
- Best for structured entities: `EMAIL`, `PHONE`, `SSN`, `CREDIT_CARD`, `IP_ADDRESS`, `DATE`, `ZIP_CODE`.
120+
- Best for structured entities: `EMAIL`, `PHONE`, `SSN`, `CREDIT_CARD`, `IP_ADDRESS`, `DE_VAT_ID`, `DE_IBAN`, `DATE`, `ZIP_CODE`.
121+
- Use `locales=["de"]` for broader German structured IDs such as `DE_TAX_ID`, `DE_POSTAL_CODE`, and passport or residence permit numbers.
102122
- `spacy`:
103123
- Requires `pip install datafog[nlp]`.
104124
- Useful for unstructured entities like person and organization names.
@@ -169,6 +189,9 @@ datafog replace-text "john@example.com"
169189

170190
# Hash detected entities
171191
datafog hash-text "john@example.com"
192+
193+
# Enable broader German regex identifiers
194+
datafog redact-text "Steuer-ID 12345678901" --locale de
172195
```
173196

174197
## Telemetry

‎datafog/__init__.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,15 @@ def scan(
152152
text: str,
153153
engine: str = "regex",
154154
entity_types: list[str] | None = None,
155+
locales: list[str] | None = None,
155156
) -> ScanResult:
156157
"""
157158
v5-preview scan entrypoint.
158159
159160
Defaults to the lightweight regex engine so the core install works without
160161
optional dependency fallback warnings.
161162
"""
162-
return _scan(text=text, engine=engine, entity_types=entity_types)
163+
return _scan(text=text, engine=engine, entity_types=entity_types, locales=locales)
163164

164165

165166
def redact(
@@ -169,6 +170,7 @@ def redact(
169170
entity_types: list[str] | None = None,
170171
strategy: str = "token",
171172
preset: str | None = None,
173+
locales: list[str] | None = None,
172174
) -> RedactResult:
173175
"""
174176
v5-preview redaction entrypoint.
@@ -191,6 +193,7 @@ def redact(
191193
engine=engine,
192194
entity_types=entity_types,
193195
strategy=strategy,
196+
locales=locales,
194197
)
195198

196199

@@ -199,6 +202,7 @@ def protect(
199202
engine: str = "regex",
200203
strategy: str = "token",
201204
on_detect: str = "redact",
205+
locales: list[str] | None = None,
202206
):
203207
"""
204208
v5-preview guardrail factory.
@@ -208,6 +212,7 @@ def protect(
208212
engine=engine,
209213
strategy=strategy,
210214
on_detect=on_detect,
215+
locales=locales,
211216
)
212217

213218

‎datafog/agent.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,7 @@ class GuardrailWatch:
2727

2828
def scan(self, text: str) -> ScanResult:
2929
"""Scan text and increment detection counters."""
30-
result = scan(
31-
text=text,
32-
engine=self.guardrail.engine,
33-
entity_types=self.guardrail.entity_types,
34-
)
30+
result = self.guardrail.scan(text)
3531
if result.entities:
3632
self.detections += len(result.entities)
3733
return result
@@ -51,6 +47,7 @@ class Guardrail:
5147
"""Reusable text guardrail for wrapping LLM prompts and outputs."""
5248

5349
entity_types: Optional[list[str]] = None
50+
locales: Optional[list[str]] = None
5451
engine: str = "regex"
5552
strategy: str = "token"
5653
on_detect: str = "redact"
@@ -61,7 +58,12 @@ def __post_init__(self) -> None:
6158

6259
def scan(self, text: str) -> ScanResult:
6360
"""Scan a text value for entities."""
64-
return scan(text=text, engine=self.engine, entity_types=self.entity_types)
61+
return scan(
62+
text=text,
63+
engine=self.engine,
64+
entity_types=self.entity_types,
65+
locales=self.locales,
66+
)
6567

6668
def filter(self, text: str) -> RedactResult:
6769
"""Scan then enforce configured behavior."""
@@ -70,6 +72,7 @@ def filter(self, text: str) -> RedactResult:
7072
engine=self.engine,
7173
entity_types=self.entity_types,
7274
strategy=self.strategy,
75+
locales=self.locales,
7376
)
7477
if not result.entities:
7578
return result
@@ -137,6 +140,7 @@ def filter_output(output: str, engine: str = "regex", **kwargs: Any) -> RedactRe
137140

138141
def create_guardrail(
139142
entity_types: Optional[list[str]] = None,
143+
locales: Optional[list[str]] = None,
140144
engine: str = "regex",
141145
strategy: str = "token",
142146
on_detect: str = "redact",
@@ -146,6 +150,7 @@ def create_guardrail(
146150
"""
147151
return Guardrail(
148152
entity_types=entity_types,
153+
locales=locales,
149154
engine=engine,
150155
strategy=strategy,
151156
on_detect=on_detect,

‎datafog/client.py‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import asyncio
88
import logging
9-
from typing import List
9+
from typing import List, Optional
1010

1111
import typer
1212

@@ -104,6 +104,11 @@ def scan_text(
104104
None, help="List of texts to extract text from"
105105
),
106106
operations: str = typer.Option("scan", help="Operation to perform"),
107+
locale: Optional[List[str]] = typer.Option(
108+
None,
109+
"--locale",
110+
help="Regex locale to enable, for example de.",
111+
),
107112
):
108113
"""
109114
Scan texts for PII.
@@ -123,7 +128,7 @@ def scan_text(
123128
logging.basicConfig(level=logging.INFO)
124129
# Convert comma-separated string operations to a list of OperationType objects
125130
operation_list = [OperationType(op.strip()) for op in operations.split(",")]
126-
text_client = DataFog(operations=operation_list)
131+
text_client = DataFog(operations=operation_list, locales=locale)
127132
try:
128133
results = text_client.run_text_pipeline_sync(str_list=str_list)
129134
typer.echo(f"Text Pipeline Results: {results}")
@@ -316,7 +321,14 @@ def list_entities():
316321

317322

318323
@app.command()
319-
def redact_text(text: str = typer.Argument(None, help="Text to redact")):
324+
def redact_text(
325+
text: str = typer.Argument(None, help="Text to redact"),
326+
locale: Optional[List[str]] = typer.Option(
327+
None,
328+
"--locale",
329+
help="Regex locale to enable, for example de.",
330+
),
331+
):
320332
"""
321333
Redact PII in text.
322334
@@ -329,7 +341,12 @@ def redact_text(text: str = typer.Argument(None, help="Text to redact")):
329341
typer.echo("No text provided to redact.")
330342
raise typer.Exit(code=1)
331343

332-
result = scan_and_redact(text=text, engine="smart", strategy="token")
344+
result = scan_and_redact(
345+
text=text,
346+
engine="smart",
347+
strategy="token",
348+
locales=locale,
349+
)
333350
typer.echo(result.redacted_text)
334351

335352
try:
@@ -346,7 +363,14 @@ def redact_text(text: str = typer.Argument(None, help="Text to redact")):
346363

347364

348365
@app.command()
349-
def replace_text(text: str = typer.Argument(None, help="Text to replace PII")):
366+
def replace_text(
367+
text: str = typer.Argument(None, help="Text to replace PII"),
368+
locale: Optional[List[str]] = typer.Option(
369+
None,
370+
"--locale",
371+
help="Regex locale to enable, for example de.",
372+
),
373+
):
350374
"""
351375
Replace PII in text with anonymized values.
352376
@@ -359,7 +383,12 @@ def replace_text(text: str = typer.Argument(None, help="Text to replace PII")):
359383
typer.echo("No text provided to replace PII.")
360384
raise typer.Exit(code=1)
361385

362-
result = scan_and_redact(text=text, engine="smart", strategy="pseudonymize")
386+
result = scan_and_redact(
387+
text=text,
388+
engine="smart",
389+
strategy="pseudonymize",
390+
locales=locale,
391+
)
363392
typer.echo(result.redacted_text)
364393

365394
try:
@@ -379,6 +408,11 @@ def replace_text(text: str = typer.Argument(None, help="Text to replace PII")):
379408
def hash_text(
380409
text: str = typer.Argument(None, help="Text to hash PII"),
381410
hash_type: HashType = typer.Option(HashType.SHA256, help="Hash algorithm to use"),
411+
locale: Optional[List[str]] = typer.Option(
412+
None,
413+
"--locale",
414+
help="Regex locale to enable, for example de.",
415+
),
382416
):
383417
"""
384418
Choose from SHA256, MD5, or SHA3-256 algorithms to hash detected PII in text.
@@ -395,7 +429,12 @@ def hash_text(
395429

396430
# HashType is retained for backward-compatible CLI signature.
397431
_ = hash_type
398-
result = scan_and_redact(text=text, engine="smart", strategy="hash")
432+
result = scan_and_redact(
433+
text=text,
434+
engine="smart",
435+
strategy="hash",
436+
locales=locale,
437+
)
399438
typer.echo(result.redacted_text)
400439

401440
try:

‎datafog/core.py‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99

1010
from datafog.engine import scan, scan_and_redact
1111
from datafog.models.anonymizer import AnonymizerType
12+
from datafog.processing.text_processing.regex_annotator import RegexAnnotator
1213

1314
# Engine types as constants
1415
REGEX_ENGINE = "regex"
1516
SPACY_ENGINE = "spacy"
1617
AUTO_ENGINE = "auto"
1718

1819

19-
def detect_pii(text: str) -> Dict[str, List[str]]:
20+
def detect_pii(
21+
text: str,
22+
locales: List[str] | None = None,
23+
) -> Dict[str, List[str]]:
2024
"""
2125
Simple PII detection using lightweight regex engine.
2226
@@ -37,7 +41,7 @@ def detect_pii(text: str) -> Dict[str, List[str]]:
3741

3842
try:
3943
# Use engine boundary for canonical scan behavior.
40-
scan_result = scan(text=text, engine=REGEX_ENGINE)
44+
scan_result = scan(text=text, engine=REGEX_ENGINE, locales=locales)
4145
pii_dict: Dict[str, List[str]] = {}
4246
for entity in scan_result.entities:
4347
if not entity.text.strip():
@@ -81,7 +85,11 @@ def detect_pii(text: str) -> Dict[str, List[str]]:
8185
) from e
8286

8387

84-
def anonymize_text(text: str, method: Union[str, AnonymizerType] = "redact") -> str:
88+
def anonymize_text(
89+
text: str,
90+
method: Union[str, AnonymizerType] = "redact",
91+
locales: List[str] | None = None,
92+
) -> str:
8593
"""
8694
Simple text anonymization using lightweight regex engine.
8795
@@ -120,6 +128,7 @@ def anonymize_text(text: str, method: Union[str, AnonymizerType] = "redact") ->
120128
text=text,
121129
engine=REGEX_ENGINE,
122130
strategy=strategy_map[method],
131+
locales=locales,
123132
)
124133

125134
try:
@@ -155,7 +164,9 @@ def anonymize_text(text: str, method: Union[str, AnonymizerType] = "redact") ->
155164

156165

157166
def scan_text(
158-
text: str, return_entities: bool = False
167+
text: str,
168+
return_entities: bool = False,
169+
locales: List[str] | None = None,
159170
) -> Union[bool, Dict[str, List[str]]]:
160171
"""
161172
Quick scan to check if text contains any PII.
@@ -180,7 +191,7 @@ def scan_text(
180191

181192
_start = _time.monotonic()
182193

183-
entities = detect_pii(text)
194+
entities = detect_pii(text, locales=locales)
184195

185196
result = entities if return_entities else len(entities) > 0
186197

@@ -200,7 +211,7 @@ def scan_text(
200211
return result
201212

202213

203-
def get_supported_entities() -> List[str]:
214+
def get_supported_entities(locales: List[str] | None = None) -> List[str]:
204215
"""
205216
Get list of PII entity types supported by the regex engine.
206217
@@ -210,17 +221,15 @@ def get_supported_entities() -> List[str]:
210221
Example:
211222
>>> entities = get_supported_entities()
212223
>>> print(entities)
213-
['EMAIL', 'PHONE', 'SSN', 'CREDIT_CARD', 'IP_ADDRESS', 'DOB', 'ZIP']
224+
['EMAIL', 'PHONE', 'SSN', 'CREDIT_CARD', 'IP_ADDRESS', 'DE_VAT_ID', 'DE_IBAN', 'DATE', 'ZIP_CODE']
214225
"""
215-
result = [
216-
"EMAIL",
217-
"PHONE",
218-
"SSN",
219-
"CREDIT_CARD",
220-
"IP_ADDRESS",
221-
"DATE",
222-
"ZIP_CODE",
223-
]
226+
annotator = RegexAnnotator(locales=locales)
227+
legacy_map = {"DOB": "DATE", "ZIP": "ZIP_CODE"}
228+
result = []
229+
for label in annotator.active_labels:
230+
canonical = legacy_map.get(label, label)
231+
if canonical not in result:
232+
result.append(canonical)
224233

225234
try:
226235
from datafog.telemetry import track_function_call

0 commit comments

Comments
 (0)