datafog 4.8.1__tar.gz → 4.9.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.
Files changed (89) hide show
  1. {datafog-4.8.1 → datafog-4.9.0}/PKG-INFO +35 -4
  2. {datafog-4.8.1 → datafog-4.9.0}/README.md +28 -3
  3. datafog-4.9.0/datafog/__about__.py +1 -0
  4. {datafog-4.8.1 → datafog-4.9.0}/datafog/__init__.py +28 -65
  5. datafog-4.9.0/datafog/_core_capabilities.py +141 -0
  6. datafog-4.9.0/datafog/_legacy_retirement.py +17 -0
  7. {datafog-4.8.1 → datafog-4.9.0}/datafog/client.py +5 -0
  8. datafog-4.9.0/datafog/compat/__init__.py +1 -0
  9. datafog-4.9.0/datafog/compat/v4.py +105 -0
  10. {datafog-4.8.1 → datafog-4.9.0}/datafog/engine.py +68 -2
  11. {datafog-4.8.1 → datafog-4.9.0}/datafog/main.py +3 -0
  12. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/image_processing/donut_processor.py +7 -0
  13. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/image_processing/image_downloader.py +4 -0
  14. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/image_processing/pytesseract_processor.py +3 -0
  15. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/spark_processing/pyspark_udfs.py +4 -0
  16. {datafog-4.8.1 → datafog-4.9.0}/datafog/services/image_service.py +10 -0
  17. {datafog-4.8.1 → datafog-4.9.0}/datafog/services/spark_service.py +3 -0
  18. datafog-4.9.0/datafog/v5.py +84 -0
  19. {datafog-4.8.1 → datafog-4.9.0}/datafog.egg-info/PKG-INFO +35 -4
  20. {datafog-4.8.1 → datafog-4.9.0}/datafog.egg-info/SOURCES.txt +12 -0
  21. {datafog-4.8.1 → datafog-4.9.0}/datafog.egg-info/requires.txt +7 -2
  22. {datafog-4.8.1 → datafog-4.9.0}/setup.py +4 -1
  23. datafog-4.9.0/tests/test_api_bridge_49.py +141 -0
  24. datafog-4.9.0/tests/test_contract_481.py +56 -0
  25. datafog-4.9.0/tests/test_core04_integration.py +215 -0
  26. datafog-4.9.0/tests/test_core_capability_adapter.py +386 -0
  27. {datafog-4.8.1 → datafog-4.9.0}/tests/test_install_profiles.py +5 -0
  28. datafog-4.9.0/tests/test_legacy_retirement.py +268 -0
  29. datafog-4.9.0/tests/test_rust_backend.py +215 -0
  30. datafog-4.9.0/tests/test_rust_contract.py +33 -0
  31. datafog-4.8.1/datafog/__about__.py +0 -1
  32. {datafog-4.8.1 → datafog-4.9.0}/LICENSE +0 -0
  33. {datafog-4.8.1 → datafog-4.9.0}/datafog/__init___lean.py +0 -0
  34. {datafog-4.8.1 → datafog-4.9.0}/datafog/__init___original.py +0 -0
  35. {datafog-4.8.1 → datafog-4.9.0}/datafog/agent.py +0 -0
  36. {datafog-4.8.1 → datafog-4.9.0}/datafog/config.py +0 -0
  37. {datafog-4.8.1 → datafog-4.9.0}/datafog/core.py +0 -0
  38. {datafog-4.8.1 → datafog-4.9.0}/datafog/exceptions.py +0 -0
  39. {datafog-4.8.1 → datafog-4.9.0}/datafog/integrations/__init__.py +0 -0
  40. {datafog-4.8.1 → datafog-4.9.0}/datafog/integrations/claude_code.py +0 -0
  41. {datafog-4.8.1 → datafog-4.9.0}/datafog/integrations/litellm_guardrail.py +0 -0
  42. {datafog-4.8.1 → datafog-4.9.0}/datafog/main_lean.py +0 -0
  43. {datafog-4.8.1 → datafog-4.9.0}/datafog/main_original.py +0 -0
  44. {datafog-4.8.1 → datafog-4.9.0}/datafog/models/__init__.py +0 -0
  45. {datafog-4.8.1 → datafog-4.9.0}/datafog/models/annotator.py +0 -0
  46. {datafog-4.8.1 → datafog-4.9.0}/datafog/models/anonymizer.py +0 -0
  47. {datafog-4.8.1 → datafog-4.9.0}/datafog/models/common.py +0 -0
  48. {datafog-4.8.1 → datafog-4.9.0}/datafog/models/spacy_nlp.py +0 -0
  49. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/__init__.py +0 -0
  50. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/image_processing/__init__.py +0 -0
  51. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/spark_processing/__init__.py +0 -0
  52. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/text_processing/__init__.py +0 -0
  53. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/text_processing/gliner_annotator.py +0 -0
  54. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/text_processing/regex_annotator/__init__.py +0 -0
  55. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/text_processing/regex_annotator/regex_annotator.py +0 -0
  56. {datafog-4.8.1 → datafog-4.9.0}/datafog/processing/text_processing/spacy_pii_annotator.py +0 -0
  57. {datafog-4.8.1 → datafog-4.9.0}/datafog/py.typed +0 -0
  58. {datafog-4.8.1 → datafog-4.9.0}/datafog/services/__init__.py +0 -0
  59. {datafog-4.8.1 → datafog-4.9.0}/datafog/services/text_service.py +0 -0
  60. {datafog-4.8.1 → datafog-4.9.0}/datafog/services/text_service_lean.py +0 -0
  61. {datafog-4.8.1 → datafog-4.9.0}/datafog/services/text_service_original.py +0 -0
  62. {datafog-4.8.1 → datafog-4.9.0}/datafog/telemetry.py +0 -0
  63. {datafog-4.8.1 → datafog-4.9.0}/datafog.egg-info/dependency_links.txt +0 -0
  64. {datafog-4.8.1 → datafog-4.9.0}/datafog.egg-info/entry_points.txt +0 -0
  65. {datafog-4.8.1 → datafog-4.9.0}/datafog.egg-info/top_level.txt +0 -0
  66. {datafog-4.8.1 → datafog-4.9.0}/setup.cfg +0 -0
  67. {datafog-4.8.1 → datafog-4.9.0}/tests/test_agent_api.py +0 -0
  68. {datafog-4.8.1 → datafog-4.9.0}/tests/test_allowlist.py +0 -0
  69. {datafog-4.8.1 → datafog-4.9.0}/tests/test_anonymizer.py +0 -0
  70. {datafog-4.8.1 → datafog-4.9.0}/tests/test_claude_code_hook.py +0 -0
  71. {datafog-4.8.1 → datafog-4.9.0}/tests/test_cli_smoke.py +0 -0
  72. {datafog-4.8.1 → datafog-4.9.0}/tests/test_client.py +0 -0
  73. {datafog-4.8.1 → datafog-4.9.0}/tests/test_de_pii_regex.py +0 -0
  74. {datafog-4.8.1 → datafog-4.9.0}/tests/test_detection_accuracy.py +0 -0
  75. {datafog-4.8.1 → datafog-4.9.0}/tests/test_donut_lazy_import.py +0 -0
  76. {datafog-4.8.1 → datafog-4.9.0}/tests/test_engine_api.py +0 -0
  77. {datafog-4.8.1 → datafog-4.9.0}/tests/test_gliner_annotator.py +0 -0
  78. {datafog-4.8.1 → datafog-4.9.0}/tests/test_image_service.py +0 -0
  79. {datafog-4.8.1 → datafog-4.9.0}/tests/test_litellm_guardrail.py +0 -0
  80. {datafog-4.8.1 → datafog-4.9.0}/tests/test_main.py +0 -0
  81. {datafog-4.8.1 → datafog-4.9.0}/tests/test_no_network_core.py +0 -0
  82. {datafog-4.8.1 → datafog-4.9.0}/tests/test_ocr_integration.py +0 -0
  83. {datafog-4.8.1 → datafog-4.9.0}/tests/test_regex_annotator.py +0 -0
  84. {datafog-4.8.1 → datafog-4.9.0}/tests/test_runtime_dependency_safety.py +0 -0
  85. {datafog-4.8.1 → datafog-4.9.0}/tests/test_spark_integration.py +0 -0
  86. {datafog-4.8.1 → datafog-4.9.0}/tests/test_telemetry.py +0 -0
  87. {datafog-4.8.1 → datafog-4.9.0}/tests/test_text_service.py +0 -0
  88. {datafog-4.8.1 → datafog-4.9.0}/tests/test_text_service_integration.py +0 -0
  89. {datafog-4.8.1 → datafog-4.9.0}/tests/test_v44_bridge_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datafog
3
- Version: 4.8.1
3
+ Version: 4.9.0
4
4
  Summary: Lightning-fast PII detection and anonymization library, 100x+ faster than NER-based detection (see benchmarks/)
5
5
  Author: Sid Mohan
6
6
  Author-email: sid@datafog.ai
@@ -28,6 +28,8 @@ License-File: LICENSE
28
28
  Requires-Dist: pydantic<3.0,>=2.0
29
29
  Requires-Dist: pydantic-settings>=2.0.0
30
30
  Requires-Dist: typing-extensions>=4.0
31
+ Provides-Extra: rust
32
+ Requires-Dist: datafog-core<0.5,>=0.4.0; extra == "rust"
31
33
  Provides-Extra: nlp
32
34
  Requires-Dist: click<9.0,>=8.0; extra == "nlp"
33
35
  Requires-Dist: spacy<4.0,>=3.7.0; extra == "nlp"
@@ -36,6 +38,8 @@ Requires-Dist: gliner>=0.2.5; extra == "nlp-advanced"
36
38
  Requires-Dist: torch<2.7,>=2.1.0; extra == "nlp-advanced"
37
39
  Requires-Dist: transformers>=4.20.0; extra == "nlp-advanced"
38
40
  Requires-Dist: huggingface-hub>=0.16.0; extra == "nlp-advanced"
41
+ Requires-Dist: sentencepiece>=0.2.0; extra == "nlp-advanced"
42
+ Requires-Dist: protobuf>=4.0.0; extra == "nlp-advanced"
39
43
  Provides-Extra: ocr
40
44
  Requires-Dist: numpy>=1.24.0; extra == "ocr"
41
45
  Requires-Dist: pytesseract>=0.3.0; extra == "ocr"
@@ -77,6 +81,8 @@ Requires-Dist: gliner>=0.2.5; extra == "all"
77
81
  Requires-Dist: torch<2.7,>=2.1.0; extra == "all"
78
82
  Requires-Dist: transformers>=4.20.0; extra == "all"
79
83
  Requires-Dist: huggingface-hub>=0.16.0; extra == "all"
84
+ Requires-Dist: sentencepiece>=0.2.0; extra == "all"
85
+ Requires-Dist: protobuf>=4.0.0; extra == "all"
80
86
  Requires-Dist: numpy>=1.24.0; extra == "all"
81
87
  Requires-Dist: pytesseract>=0.3.0; extra == "all"
82
88
  Requires-Dist: Pillow>=12.2.0; extra == "all"
@@ -178,6 +184,9 @@ pip install datafog[distributed]
178
184
  pip install datafog[all]
179
185
  ```
180
186
 
187
+ The `nlp-advanced` extra includes SentencePiece and protobuf for GLiNER's
188
+ multilingual tokenizer; installing the OCR extra is not required for GLiNER.
189
+
181
190
  Python 3.13 support is certified for the core SDK, CLI, `nlp`,
182
191
  `nlp-advanced`, and `ocr` install profiles. Donut OCR still requires a model
183
192
  that is available locally before runtime use. `distributed` and `all` remain
@@ -293,9 +302,10 @@ scan/redact helpers, or guardrail helpers.
293
302
  model.
294
303
  - A Java runtime is required by PySpark.
295
304
 
296
- OCR and Spark are not deprecated. Their broader API and packaging overhaul is
297
- deferred; the 4.x goal is to keep them explicit, documented, and isolated from
298
- the lightweight core path.
305
+ DataFog 4.9.0 deprecates OCR and Spark with visible use-time
306
+ warnings; their APIs and extras will be removed in 5.0. They remain functional
307
+ in 4.9. Users who need these features can stay on the final 4.x release. See
308
+ the [4.9 migration guide](docs/migration-4.9.md) for the transition plan.
299
309
 
300
310
  ## Backward-Compatible APIs
301
311
 
@@ -361,6 +371,27 @@ Telemetry does not include input text or detected PII values.
361
371
 
362
372
  ## Development
363
373
 
374
+ The [4.9 migration guide](docs/migration-4.9.md) explains opt-in Rust detection,
375
+ the native `datafog.v5` preview, and the revised 5.0 retirement schedule for
376
+ `detect`/`process`, OCR, and Spark. The Python detector remains the default.
377
+
378
+ The experimental Rust adapter in 4.9.0 requires Core `>=0.4.0,<0.5` and capability
379
+ contract 1. Upgrade with `python -m pip install --upgrade "datafog[rust]==4.9.0"`
380
+ to evaluate it. Base-only users can install `datafog==4.9.0` without Core;
381
+ installing the Rust extra does not change the default backend. See the
382
+ [4.9.0 release notes](RELEASE_NOTES_4.9.0.md).
383
+ Entity labels, locales, and activation settings come from the installed
384
+ Core, allowing compatible releases to add detectors without a Python update.
385
+ German detection is opt-in through locale or entity selection; UUID is opt-in
386
+ through `entity_types=["UUID"]`. Core's structured-only `PERSON` is unavailable
387
+ for explicit Rust text selection. The legacy overlap policy can suppress NPI in
388
+ favor of PHONE; the migration guide explains native alternatives. Lock the Core
389
+ version if detection output must remain reproducible.
390
+
391
+ The [4.8.1 compatibility contract](docs/migration-4.8.1-contract.md) records
392
+ published Python behavior for the Rust migration, with frozen fixtures and
393
+ instructions for independently reproducing them from the release wheel.
394
+
364
395
  ```bash
365
396
  git clone https://github.com/datafog/datafog-python
366
397
  cd datafog-python
@@ -70,6 +70,9 @@ pip install datafog[distributed]
70
70
  pip install datafog[all]
71
71
  ```
72
72
 
73
+ The `nlp-advanced` extra includes SentencePiece and protobuf for GLiNER's
74
+ multilingual tokenizer; installing the OCR extra is not required for GLiNER.
75
+
73
76
  Python 3.13 support is certified for the core SDK, CLI, `nlp`,
74
77
  `nlp-advanced`, and `ocr` install profiles. Donut OCR still requires a model
75
78
  that is available locally before runtime use. `distributed` and `all` remain
@@ -185,9 +188,10 @@ scan/redact helpers, or guardrail helpers.
185
188
  model.
186
189
  - A Java runtime is required by PySpark.
187
190
 
188
- OCR and Spark are not deprecated. Their broader API and packaging overhaul is
189
- deferred; the 4.x goal is to keep them explicit, documented, and isolated from
190
- the lightweight core path.
191
+ DataFog 4.9.0 deprecates OCR and Spark with visible use-time
192
+ warnings; their APIs and extras will be removed in 5.0. They remain functional
193
+ in 4.9. Users who need these features can stay on the final 4.x release. See
194
+ the [4.9 migration guide](docs/migration-4.9.md) for the transition plan.
191
195
 
192
196
  ## Backward-Compatible APIs
193
197
 
@@ -253,6 +257,27 @@ Telemetry does not include input text or detected PII values.
253
257
 
254
258
  ## Development
255
259
 
260
+ The [4.9 migration guide](docs/migration-4.9.md) explains opt-in Rust detection,
261
+ the native `datafog.v5` preview, and the revised 5.0 retirement schedule for
262
+ `detect`/`process`, OCR, and Spark. The Python detector remains the default.
263
+
264
+ The experimental Rust adapter in 4.9.0 requires Core `>=0.4.0,<0.5` and capability
265
+ contract 1. Upgrade with `python -m pip install --upgrade "datafog[rust]==4.9.0"`
266
+ to evaluate it. Base-only users can install `datafog==4.9.0` without Core;
267
+ installing the Rust extra does not change the default backend. See the
268
+ [4.9.0 release notes](RELEASE_NOTES_4.9.0.md).
269
+ Entity labels, locales, and activation settings come from the installed
270
+ Core, allowing compatible releases to add detectors without a Python update.
271
+ German detection is opt-in through locale or entity selection; UUID is opt-in
272
+ through `entity_types=["UUID"]`. Core's structured-only `PERSON` is unavailable
273
+ for explicit Rust text selection. The legacy overlap policy can suppress NPI in
274
+ favor of PHONE; the migration guide explains native alternatives. Lock the Core
275
+ version if detection output must remain reproducible.
276
+
277
+ The [4.8.1 compatibility contract](docs/migration-4.8.1-contract.md) records
278
+ published Python behavior for the Rust migration, with frozen fixtures and
279
+ instructions for independently reproducing them from the release wheel.
280
+
256
281
  ```bash
257
282
  git clone https://github.com/datafog/datafog-python
258
283
  cd datafog-python
@@ -0,0 +1 @@
1
+ __version__ = "4.9.0"
@@ -15,11 +15,9 @@ from .__about__ import __version__
15
15
  from .agent import create_guardrail, filter_output, sanitize, scan_prompt
16
16
 
17
17
  # Core API functions - always available (lightweight)
18
+ from .compat import v4 as _compat_v4
18
19
  from .core import anonymize_text, detect_pii, get_supported_entities, scan_text
19
20
  from .engine import Entity, RedactResult, ScanResult
20
- from .engine import redact as _redact_entities
21
- from .engine import scan as _scan
22
- from .engine import scan_and_redact as _scan_and_redact
23
21
 
24
22
  # Essential models - always available
25
23
  from .models.common import EntityTypes
@@ -130,20 +128,10 @@ _LAZY_EXPORTS = {
130
128
  }
131
129
 
132
130
 
133
- _REDACT_PRESETS = {
134
- "default": "token",
135
- "llm": "token",
136
- "mask": "mask",
137
- "hash": "hash",
138
- "replace": "pseudonymize",
139
- "pseudonymize": "pseudonymize",
140
- }
141
-
142
-
143
131
  def _warn_v5_replacement(old_api: str, replacement: str) -> None:
144
132
  warnings.warn(
145
- f"datafog.{old_api}() is deprecated for v5. Use {replacement} instead. "
146
- "This compatibility shim will remain through the v5.x line.",
133
+ f"datafog.{old_api}() is deprecated and will be removed in 5.0. Use {replacement} instead. "
134
+ "The earlier promise to retain this shim through 5.x has been revised.",
147
135
  FutureWarning,
148
136
  stacklevel=3,
149
137
  )
@@ -156,25 +144,18 @@ def scan(
156
144
  locales: list[str] | None = None,
157
145
  allowlist: list[str] | None = None,
158
146
  allowlist_patterns: list[str] | None = None,
147
+ *,
148
+ backend: str = "python",
159
149
  ) -> ScanResult:
160
- """
161
- v5-preview scan entrypoint.
162
-
163
- Defaults to the lightweight regex engine so the core install works without
164
- optional dependency fallback warnings.
165
-
166
- ``allowlist`` exempts exact entity texts (your own support address, doc
167
- placeholders); ``allowlist_patterns`` exempts entities whose full text
168
- matches a regex (e.g. ``^\\d{10}$`` so unix timestamps stop matching as
169
- phone numbers).
170
- """
171
- return _scan(
172
- text=text,
173
- engine=engine,
174
- entity_types=entity_types,
175
- locales=locales,
176
- allowlist=allowlist,
177
- allowlist_patterns=allowlist_patterns,
150
+ """Scan using the 4.x compatibility API; Rust detection is opt-in."""
151
+ return _compat_v4.scan(
152
+ text,
153
+ engine,
154
+ entity_types,
155
+ locales,
156
+ allowlist,
157
+ allowlist_patterns,
158
+ backend=backend,
178
159
  )
179
160
 
180
161
 
@@ -188,39 +169,21 @@ def redact(
188
169
  locales: list[str] | None = None,
189
170
  allowlist: list[str] | None = None,
190
171
  allowlist_patterns: list[str] | None = None,
172
+ *,
173
+ backend: str = "python",
191
174
  ) -> RedactResult:
192
- """
193
- v5-preview redaction entrypoint.
194
-
195
- If entities are provided, redact those spans. Otherwise, scan text first
196
- using the selected engine and redact the detected entities. ``allowlist``
197
- and ``allowlist_patterns`` exempt findings from redaction (exact text and
198
- full-text regex match respectively); they apply to the scan path and are
199
- rejected when explicit ``entities`` are supplied.
200
- """
201
- if preset is not None:
202
- try:
203
- strategy = _REDACT_PRESETS[preset]
204
- except KeyError as exc:
205
- allowed = ", ".join(sorted(_REDACT_PRESETS))
206
- raise ValueError(f"preset must be one of: {allowed}") from exc
207
-
208
- if entities is not None:
209
- if allowlist or allowlist_patterns:
210
- raise ValueError(
211
- "allowlist/allowlist_patterns cannot be combined with explicit "
212
- "entities; filter the entities before calling redact"
213
- )
214
- return _redact_entities(text=text, entities=entities, strategy=strategy)
215
-
216
- return _scan_and_redact(
217
- text=text,
218
- engine=engine,
219
- entity_types=entity_types,
220
- strategy=strategy,
221
- locales=locales,
222
- allowlist=allowlist,
223
- allowlist_patterns=allowlist_patterns,
175
+ """Redact using the 4.x compatibility API and legacy strategies."""
176
+ return _compat_v4.redact(
177
+ text,
178
+ entities,
179
+ engine,
180
+ entity_types,
181
+ strategy,
182
+ preset,
183
+ locales,
184
+ allowlist,
185
+ allowlist_patterns,
186
+ backend=backend,
224
187
  )
225
188
 
226
189
 
@@ -0,0 +1,141 @@
1
+ """Translate installed Core capabilities into native text scan configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from functools import lru_cache
7
+ from typing import Any
8
+
9
+ _ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
10
+
11
+
12
+ def _locale_key(value: str) -> str:
13
+ return value.strip(" \t\n\r\v\f").translate(_ASCII_LOWER)
14
+
15
+
16
+ def _invalid(detail: str) -> RuntimeError:
17
+ return RuntimeError(f"Incompatible datafog-core capabilities: {detail}")
18
+
19
+
20
+ def _labels(value: Any, field: str) -> set[str]:
21
+ if not isinstance(value, list) or any(
22
+ not isinstance(label, str) or not label for label in value
23
+ ):
24
+ raise _invalid(f"{field} must be a list of entity labels")
25
+ return set(value)
26
+
27
+
28
+ @lru_cache(maxsize=4)
29
+ def _read_capabilities(capabilities: Any) -> tuple[set[str], dict, dict]:
30
+ """Snapshot one installed build; replacing its reader invalidates the cache.
31
+
32
+ The private snapshot never escapes into native scan configuration. Failed
33
+ capability reads or validation are not cached by lru_cache.
34
+ """
35
+ payload = deepcopy(capabilities())
36
+ if not isinstance(payload, dict):
37
+ raise _invalid("capabilities() must return a dictionary")
38
+ version = payload.get("contract_version")
39
+ if not isinstance(version, int) or isinstance(version, bool) or version != 1:
40
+ raise _invalid(f"unsupported contract_version {version!r}; expected 1")
41
+ supported = _labels(payload.get("supported_entities"), "supported_entities")
42
+ defaults = _labels(payload.get("default_entities"), "default_entities")
43
+ if not defaults <= supported:
44
+ raise _invalid("default_entities contains unsupported labels")
45
+ advertised_locales = payload.get("locales")
46
+ metadata = payload.get("entities")
47
+ if not isinstance(advertised_locales, dict) or not isinstance(metadata, dict):
48
+ raise _invalid("locales and entities must be dictionaries")
49
+
50
+ return supported, _locale_lookup(advertised_locales, supported), metadata
51
+
52
+
53
+ def _locale_lookup(advertised_locales: dict, supported: set[str]) -> dict[str, str]:
54
+ locale_lookup: dict[str, str] = {}
55
+ for locale, details in advertised_locales.items():
56
+ if not isinstance(locale, str) or not _locale_key(locale):
57
+ raise _invalid("locale identifiers must be nonempty strings")
58
+ if (
59
+ not isinstance(details, dict)
60
+ or not _labels(
61
+ details.get("enabled_entities"), f"locales[{locale!r}].enabled_entities"
62
+ )
63
+ <= supported
64
+ ):
65
+ raise _invalid(f"invalid locale metadata for {locale!r}")
66
+ locale_lookup[_locale_key(locale)] = locale
67
+
68
+ return locale_lookup
69
+
70
+
71
+ def _resolve_locale(value: Any, locale_lookup: dict[str, str]) -> str:
72
+ if not isinstance(value, str) or _locale_key(value) not in locale_lookup:
73
+ raise ValueError(f"Unsupported locale for the Rust backend: {value!r}")
74
+ return value
75
+
76
+
77
+ def _requested_locales(locales: Any, lookup: dict[str, str]) -> set[str]:
78
+ if isinstance(locales, str):
79
+ locales = [locales]
80
+ if locales is not None and (
81
+ not isinstance(locales, (list, tuple))
82
+ or any(not isinstance(locale, str) for locale in locales)
83
+ ):
84
+ raise ValueError("locales must be a list of locale identifiers")
85
+ return {_resolve_locale(locale, lookup) for locale in locales or []}
86
+
87
+
88
+ def _activation_config(label: str, metadata: dict) -> dict:
89
+ details = metadata.get(label)
90
+ if not isinstance(details, dict):
91
+ raise _invalid(f"missing entity metadata for {label!r}")
92
+ scopes = details.get("scopes")
93
+ if not isinstance(scopes, list) or any(
94
+ not isinstance(scope, str) for scope in scopes
95
+ ):
96
+ raise _invalid(f"invalid scopes for {label!r}")
97
+ if "text" not in scopes:
98
+ raise ValueError(
99
+ f"Entity {label!r} is not available for Rust text scanning "
100
+ "(structured-only entity)"
101
+ )
102
+ activation = details.get("activation")
103
+ if not isinstance(activation, dict):
104
+ raise _invalid(f"missing activation metadata for {label!r}")
105
+ kind = activation.get("kind")
106
+ if kind == "default":
107
+ return {}
108
+ if kind not in {"locale", "config"}:
109
+ raise _invalid(f"unsupported activation kind {kind!r} for {label!r}")
110
+ config = activation.get("scan_config")
111
+ if not isinstance(config, dict) or not config:
112
+ raise _invalid(f"missing scan_config for {label!r}")
113
+ if kind == "locale" and "locale" not in config:
114
+ raise _invalid(f"missing activation locale for {label!r}")
115
+ return deepcopy(config)
116
+
117
+
118
+ def scan_configs(core: Any, requested: set[str], locales: Any) -> list[dict]:
119
+ """Build a union of singular-locale scans without duplicating inventories."""
120
+ capabilities = getattr(core, "capabilities", None)
121
+ if not callable(capabilities):
122
+ raise _invalid(
123
+ "contract version 1 is required; install a compatible Core 0.4.x"
124
+ )
125
+ supported, lookup, metadata = _read_capabilities(capabilities)
126
+ selected_locales = _requested_locales(locales, lookup)
127
+ common: dict[str, Any] = {}
128
+ for label in sorted(requested & supported):
129
+ config = _activation_config(label, metadata)
130
+ for key, value in config.items():
131
+ if key == "locale":
132
+ selected_locales.add(_resolve_locale(value, lookup))
133
+ elif key in common and common[key] != value:
134
+ raise _invalid(f"conflicting activation values for {key!r}")
135
+ else:
136
+ common[key] = value
137
+ if not selected_locales:
138
+ return [common]
139
+ return [
140
+ deepcopy(dict(common, locale=locale)) for locale in sorted(selected_locales)
141
+ ]
@@ -0,0 +1,17 @@
1
+ """Dependency-free retirement notices for optional legacy functionality."""
2
+
3
+ import warnings
4
+
5
+
6
+ class LegacySurfaceWarning(FutureWarning):
7
+ """An optional DataFog surface scheduled for removal in 5.0."""
8
+
9
+
10
+ def warn_legacy_surface(surface: str) -> None:
11
+ """Warn at the caller's public API boundary, before optional imports."""
12
+ warnings.warn(
13
+ f"DataFog {surface} support is deprecated in 4.9 and will be removed in 5.0. "
14
+ "Remain on the final 4.x release if you need continued support.",
15
+ LegacySurfaceWarning,
16
+ stacklevel=3,
17
+ )
@@ -10,6 +10,8 @@ from typing import List, Optional
10
10
 
11
11
  import typer
12
12
 
13
+ from datafog._legacy_retirement import LegacySurfaceWarning, warn_legacy_surface
14
+
13
15
  from .config import OperationType, get_config
14
16
  from .engine import scan_and_redact
15
17
  from .main import DataFog
@@ -63,6 +65,7 @@ def scan_image(
63
65
 
64
66
  Prints results or exits with error on failure.
65
67
  """
68
+ warn_legacy_surface("OCR")
66
69
  if not image_urls:
67
70
  typer.echo("No image URLs or file paths provided. Please provide at least one.")
68
71
  raise typer.Exit(code=1)
@@ -87,6 +90,8 @@ def scan_image(
87
90
  except Exception:
88
91
  pass
89
92
  except Exception as e:
93
+ if isinstance(e, LegacySurfaceWarning):
94
+ raise
90
95
  logging.exception("Error in run_ocr_pipeline")
91
96
  try:
92
97
  from .telemetry import track_error
@@ -0,0 +1 @@
1
+ """Compatibility APIs for migrations between DataFog major versions."""
@@ -0,0 +1,105 @@
1
+ """Legacy scan/redact facade, preserving the 4.x Python result schema.
2
+
3
+ The result classes retain their original engine identity. This namespace does
4
+ not include the deprecated detect/process shims.
5
+ """
6
+
7
+ from ..engine import Entity, RedactResult, ScanResult
8
+ from ..engine import redact as _redact_entities
9
+ from ..engine import scan as _scan
10
+ from ..engine import scan_and_redact as _scan_and_redact
11
+
12
+ __all__ = ["Entity", "ScanResult", "RedactResult", "scan", "redact"]
13
+
14
+ _REDACT_PRESETS = {
15
+ "default": "token",
16
+ "llm": "token",
17
+ "mask": "mask",
18
+ "hash": "hash",
19
+ "replace": "pseudonymize",
20
+ "pseudonymize": "pseudonymize",
21
+ }
22
+
23
+
24
+ def scan(
25
+ text: str,
26
+ engine: str = "regex",
27
+ entity_types: list[str] | None = None,
28
+ locales: list[str] | None = None,
29
+ allowlist: list[str] | None = None,
30
+ allowlist_patterns: list[str] | None = None,
31
+ *,
32
+ backend: str = "python",
33
+ ) -> ScanResult:
34
+ """
35
+ Scan with the legacy result schema; Rust detection is opt-in.
36
+
37
+ Defaults to the lightweight regex engine so the core install works without
38
+ optional dependency fallback warnings.
39
+
40
+ ``allowlist`` exempts exact entity texts (your own support address, doc
41
+ placeholders); ``allowlist_patterns`` exempts entities whose full text
42
+ matches a regex (e.g. ``^\\d{10}$`` so unix timestamps stop matching as
43
+ phone numbers).
44
+ """
45
+ return _scan(
46
+ text=text,
47
+ engine=engine,
48
+ entity_types=entity_types,
49
+ locales=locales,
50
+ allowlist=allowlist,
51
+ allowlist_patterns=allowlist_patterns,
52
+ backend=backend,
53
+ )
54
+
55
+
56
+ def redact(
57
+ text: str,
58
+ entities: list[Entity] | None = None,
59
+ engine: str = "regex",
60
+ entity_types: list[str] | None = None,
61
+ strategy: str = "token",
62
+ preset: str | None = None,
63
+ locales: list[str] | None = None,
64
+ allowlist: list[str] | None = None,
65
+ allowlist_patterns: list[str] | None = None,
66
+ *,
67
+ backend: str = "python",
68
+ ) -> RedactResult:
69
+ """
70
+ Redact with legacy strategies and result schema.
71
+
72
+ If entities are provided, redact those spans. Otherwise, scan text first
73
+ using the selected engine and redact the detected entities. ``allowlist``
74
+ and ``allowlist_patterns`` exempt findings from redaction (exact text and
75
+ full-text regex match respectively); they apply to the scan path and are
76
+ rejected when explicit ``entities`` are supplied.
77
+ """
78
+ if backend not in ("python", "rust"):
79
+ raise ValueError("backend must be one of: python, rust")
80
+
81
+ if preset is not None:
82
+ try:
83
+ strategy = _REDACT_PRESETS[preset]
84
+ except KeyError as exc:
85
+ allowed = ", ".join(sorted(_REDACT_PRESETS))
86
+ raise ValueError(f"preset must be one of: {allowed}") from exc
87
+
88
+ if entities is not None:
89
+ if allowlist or allowlist_patterns:
90
+ raise ValueError(
91
+ "allowlist/allowlist_patterns cannot be combined with explicit "
92
+ "entities; filter the entities before calling redact"
93
+ )
94
+ return _redact_entities(text=text, entities=entities, strategy=strategy)
95
+
96
+ return _scan_and_redact(
97
+ text=text,
98
+ engine=engine,
99
+ entity_types=entity_types,
100
+ strategy=strategy,
101
+ locales=locales,
102
+ allowlist=allowlist,
103
+ allowlist_patterns=allowlist_patterns,
104
+ backend=backend,
105
+ )
@@ -209,6 +209,53 @@ def _regex_entities(
209
209
  return _suppress_overlapping_entities(entities)
210
210
 
211
211
 
212
+ def _rust_entities(
213
+ text: str,
214
+ entity_types: Optional[list[str]] = None,
215
+ locales: Optional[list[str]] = None,
216
+ ) -> list[Entity]:
217
+ """Adapt native findings to the legacy regex result contract.
218
+
219
+ Core supplies candidate findings. Python applies the legacy overlap
220
+ suppression before entity selection and allowlists, preserving that order
221
+ without duplicating the native detectors.
222
+ """
223
+ try:
224
+ import datafog_core
225
+ except ImportError as exc:
226
+ raise ImportError(
227
+ "The Rust backend requires datafog-core. "
228
+ 'Install with: pip install "datafog[rust]"'
229
+ ) from exc
230
+
231
+ from ._core_capabilities import scan_configs
232
+
233
+ requested = {_canonical_type(value) for value in entity_types or []}
234
+ configs = scan_configs(datafog_core, requested, locales)
235
+ entities: list[Entity] = []
236
+ seen: set[tuple[str, int, int]] = set()
237
+ for config in configs:
238
+ for finding in datafog_core.scan(text, config=config):
239
+ canonical_type = _canonical_type(finding.entity_type)
240
+ start = finding.codepoint_range.start
241
+ end = finding.codepoint_range.end
242
+ key = (canonical_type, start, end)
243
+ if key in seen or not finding.matched_text.strip():
244
+ continue
245
+ seen.add(key)
246
+ entities.append(
247
+ Entity(
248
+ type=canonical_type,
249
+ text=finding.matched_text,
250
+ start=start,
251
+ end=end,
252
+ confidence=1.0,
253
+ engine="regex",
254
+ )
255
+ )
256
+ return _suppress_overlapping_entities(entities)
257
+
258
+
212
259
  def _spacy_entities(text: str) -> list[Entity]:
213
260
  annotator = _get_spacy_annotator()
214
261
  if isinstance(annotator, _UnavailableAnnotator):
@@ -357,6 +404,13 @@ def _needs_ner(entity_types: Optional[list[str]]) -> bool:
357
404
  return bool(requested & NER_ENTITY_TYPES)
358
405
 
359
406
 
407
+ def _validate_backend(backend: str, engine: str) -> None:
408
+ if backend not in {"python", "rust"}:
409
+ raise ValueError("backend must be one of: python, rust")
410
+ if backend == "rust" and engine != "regex":
411
+ raise ValueError("backend='rust' supports only engine='regex'")
412
+
413
+
360
414
  def scan(
361
415
  text: str,
362
416
  engine: str = "smart",
@@ -364,9 +418,15 @@ def scan(
364
418
  locales: Optional[list[str]] = None,
365
419
  allowlist: Optional[list[str]] = None,
366
420
  allowlist_patterns: Optional[list[str]] = None,
421
+ *,
422
+ backend: str = "python",
367
423
  ) -> ScanResult:
368
424
  """Scan text for PII entities.
369
425
 
426
+ ``backend="rust"`` opts into experimental native detection for
427
+ ``engine="regex"``. Results retain legacy regex provenance and confidence;
428
+ these are compatibility values, not native confidence estimates.
429
+
370
430
  ``allowlist`` exempts exact entity texts (e.g. your own support email);
371
431
  ``allowlist_patterns`` exempts entities whose full text matches a regex
372
432
  (e.g. ``^\\d{10}$`` to stop unix timestamps matching as phone numbers).
@@ -377,11 +437,14 @@ def scan(
377
437
  if engine not in {"regex", "spacy", "gliner", "smart"}:
378
438
  raise ValueError("engine must be one of: regex, spacy, gliner, smart")
379
439
 
440
+ _validate_backend(backend, engine)
441
+
380
442
  # Validate patterns up front so config errors fail fast even when the
381
443
  # text contains no entities.
382
444
  _compile_allowlist_patterns(allowlist_patterns)
383
445
 
384
- regex_entities = _regex_entities(
446
+ detector = _rust_entities if backend == "rust" else _regex_entities
447
+ regex_entities = detector(
385
448
  text,
386
449
  entity_types=entity_types,
387
450
  locales=locales,
@@ -547,8 +610,10 @@ def scan_and_redact(
547
610
  locales: Optional[list[str]] = None,
548
611
  allowlist: Optional[list[str]] = None,
549
612
  allowlist_patterns: Optional[list[str]] = None,
613
+ *,
614
+ backend: str = "python",
550
615
  ) -> RedactResult:
551
- """Convenience wrapper: scan then redact."""
616
+ """Scan with the selected backend, then apply legacy Python redaction."""
552
617
  scan_result = scan(
553
618
  text=text,
554
619
  engine=engine,
@@ -556,5 +621,6 @@ def scan_and_redact(
556
621
  locales=locales,
557
622
  allowlist=allowlist,
558
623
  allowlist_patterns=allowlist_patterns,
624
+ backend=backend,
559
625
  )
560
626
  return redact(text=text, entities=scan_result.entities, strategy=strategy)