datafog-core 0.4.1__tar.gz → 0.4.3__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 (60) hide show
  1. {datafog_core-0.4.1 → datafog_core-0.4.3}/Cargo.lock +4 -4
  2. {datafog_core-0.4.1 → datafog_core-0.4.3}/PKG-INFO +30 -6
  3. {datafog_core-0.4.1/crates/core → datafog_core-0.4.3}/README.md +29 -5
  4. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/Cargo.toml +1 -1
  5. datafog_core-0.4.3/bindings/python/tests/email_context_conformance.py +49 -0
  6. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/test_installed.py +39 -0
  7. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/test_typing.py +4 -1
  8. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/Cargo.toml +1 -1
  9. {datafog_core-0.4.1 → datafog_core-0.4.3/crates/core}/README.md +29 -5
  10. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/capabilities.rs +57 -5
  11. datafog_core-0.4.3/crates/core/src/email_context.rs +172 -0
  12. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/german.rs +9 -2
  13. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/lib.rs +105 -3
  14. datafog_core-0.4.3/crates/core/src/scan_selection_tests.rs +155 -0
  15. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/structured.rs +5 -1
  16. datafog_core-0.4.3/crates/core/tests/email_context.rs +107 -0
  17. {datafog_core-0.4.1 → datafog_core-0.4.3}/pyproject.toml +1 -1
  18. {datafog_core-0.4.1 → datafog_core-0.4.3}/python/datafog_core/__init__.pyi +7 -2
  19. {datafog_core-0.4.1 → datafog_core-0.4.3}/Cargo.toml +0 -0
  20. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/src/lib.rs +0 -0
  21. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/api_key_conformance.py +0 -0
  22. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/bearer_token_conformance.py +0 -0
  23. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/capabilities_conformance.py +0 -0
  24. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/credential_overlap_conformance.py +0 -0
  25. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/credential_uri_conformance.py +0 -0
  26. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/german_conformance.py +0 -0
  27. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/jwt_conformance.py +0 -0
  28. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/npi_conformance.py +0 -0
  29. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/private_key_conformance.py +0 -0
  30. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/requirements-typing.txt +0 -0
  31. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/synthetic_fixtures.py +0 -0
  32. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/test_python49_integration.py +0 -0
  33. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/us_routing_number_conformance.py +0 -0
  34. {datafog_core-0.4.1 → datafog_core-0.4.3}/bindings/python/tests/uuid_conformance.py +0 -0
  35. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/examples/german_benchmark.rs +0 -0
  36. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/examples/scan_benchmark.rs +0 -0
  37. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/api_key.rs +0 -0
  38. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/bearer_token.rs +0 -0
  39. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/credential_uri.rs +0 -0
  40. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/jwt.rs +0 -0
  41. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/npi.rs +0 -0
  42. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/offsets.rs +0 -0
  43. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/private_key.rs +0 -0
  44. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/selection_tests.rs +0 -0
  45. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/us_routing_number.rs +0 -0
  46. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/src/uuid.rs +0 -0
  47. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/api_key.rs +0 -0
  48. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/bearer_token.rs +0 -0
  49. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/capabilities.rs +0 -0
  50. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/credential_overlaps.rs +0 -0
  51. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/credential_uri.rs +0 -0
  52. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/german.rs +0 -0
  53. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/jwt.rs +0 -0
  54. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/npi.rs +0 -0
  55. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/private_key.rs +0 -0
  56. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
  57. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/us_routing_number.rs +0 -0
  58. {datafog_core-0.4.1 → datafog_core-0.4.3}/crates/core/tests/uuid.rs +0 -0
  59. {datafog_core-0.4.1 → datafog_core-0.4.3}/python/datafog_core/__init__.py +0 -0
  60. {datafog_core-0.4.1 → datafog_core-0.4.3}/python/datafog_core/py.typed +0 -0
@@ -80,7 +80,7 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
80
80
 
81
81
  [[package]]
82
82
  name = "datafog-core"
83
- version = "0.4.1"
83
+ version = "0.4.3"
84
84
  dependencies = [
85
85
  "base64",
86
86
  "futures",
@@ -94,7 +94,7 @@ dependencies = [
94
94
 
95
95
  [[package]]
96
96
  name = "datafog-core-python"
97
- version = "0.4.1"
97
+ version = "0.4.3"
98
98
  dependencies = [
99
99
  "datafog-core",
100
100
  "pyo3",
@@ -104,7 +104,7 @@ dependencies = [
104
104
 
105
105
  [[package]]
106
106
  name = "datafog-node"
107
- version = "0.4.1"
107
+ version = "0.4.3"
108
108
  dependencies = [
109
109
  "datafog-core",
110
110
  "napi",
@@ -115,7 +115,7 @@ dependencies = [
115
115
 
116
116
  [[package]]
117
117
  name = "datafog-wasm"
118
- version = "0.4.1"
118
+ version = "0.4.3"
119
119
  dependencies = [
120
120
  "datafog-core",
121
121
  "serde",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datafog-core
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: MIT License
6
6
  Classifier: Programming Language :: Python :: 3
@@ -79,8 +79,12 @@ full-match regex values:
79
79
  }
80
80
  ```
81
81
 
82
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
82
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
83
83
  detection settings remain separate from transformation policy.
84
+ For `.env` assignments or standard SQL quoted strings, select `format: "env"`
85
+ or `format: "sql"` to preserve email boundaries. See the
86
+ [email source-context policy](docs/guides/email-boundaries.mdx) for supported
87
+ syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
84
88
 
85
89
  ## Packages
86
90
 
@@ -321,8 +325,28 @@ boundaries, never newlines. Returned text and offsets preserve the source.
321
325
  Passport and residence-permit patterns are legacy heuristics with limited
322
326
  coverage. See the [German entity reference](docs/reference/german-entities.mdx)
323
327
  and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
324
- Core 0.4.0 is published. The higher-level Python adapter and its dependency
325
- update remain separate and unreleased in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179).
328
+ The higher-level Python adapter and its dependency update were merged in
329
+ [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
330
+
331
+ ## Text detector selection
332
+
333
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
334
+ selected detectors, rather than detecting every type and filtering findings
335
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
336
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
337
+ `scan` configuration; transformation selection remains independent.
338
+
339
+ Omitting `entities` preserves existing detection. An explicit selection must be
340
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
341
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
342
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
343
+ `capabilities().entities` for types with the `text` scope.
344
+
345
+ Selection narrows enabled detectors: selecting `UUID` still requires
346
+ `detect_uuid=true`, and German types still require a German locale. Text format
347
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
348
+ scans do not accept this option. It avoids unselected detector work and findings,
349
+ but does not cap the selected findings or bound the size of a scan response.
326
350
 
327
351
  ## UUID identifiers
328
352
 
@@ -332,7 +356,7 @@ structured scans, independently of locale. It emits `UUID` findings for versions
332
356
  sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
333
357
  excluded sentinel forms and transformation examples.
334
358
 
335
- **0.4.0 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter remains a separate unreleased integration in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179). See the [release notes](docs/releases/0-4-0.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
359
+ **0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
336
360
 
337
- The unreleased **0.4.1 candidate** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [candidate release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the required exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
361
+ **Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
338
362
 
@@ -56,8 +56,12 @@ full-match regex values:
56
56
  }
57
57
  ```
58
58
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
59
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
60
60
  detection settings remain separate from transformation policy.
61
+ For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
+ or `format: "sql"` to preserve email boundaries. See the
63
+ [email source-context policy](docs/guides/email-boundaries.mdx) for supported
64
+ syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
61
65
 
62
66
  ## Packages
63
67
 
@@ -298,8 +302,28 @@ boundaries, never newlines. Returned text and offsets preserve the source.
298
302
  Passport and residence-permit patterns are legacy heuristics with limited
299
303
  coverage. See the [German entity reference](docs/reference/german-entities.mdx)
300
304
  and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
301
- Core 0.4.0 is published. The higher-level Python adapter and its dependency
302
- update remain separate and unreleased in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179).
305
+ The higher-level Python adapter and its dependency update were merged in
306
+ [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
307
+
308
+ ## Text detector selection
309
+
310
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
311
+ selected detectors, rather than detecting every type and filtering findings
312
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
313
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
314
+ `scan` configuration; transformation selection remains independent.
315
+
316
+ Omitting `entities` preserves existing detection. An explicit selection must be
317
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
318
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
319
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
320
+ `capabilities().entities` for types with the `text` scope.
321
+
322
+ Selection narrows enabled detectors: selecting `UUID` still requires
323
+ `detect_uuid=true`, and German types still require a German locale. Text format
324
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
325
+ scans do not accept this option. It avoids unselected detector work and findings,
326
+ but does not cap the selected findings or bound the size of a scan response.
303
327
 
304
328
  ## UUID identifiers
305
329
 
@@ -309,6 +333,6 @@ structured scans, independently of locale. It emits `UUID` findings for versions
309
333
  sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
310
334
  excluded sentinel forms and transformation examples.
311
335
 
312
- **0.4.0 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter remains a separate unreleased integration in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179). See the [release notes](docs/releases/0-4-0.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
336
+ **0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
313
337
 
314
- The unreleased **0.4.1 candidate** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [candidate release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the required exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
338
+ **Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafog-core-python"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Python bindings for datafog-core"
@@ -0,0 +1,49 @@
1
+ """Email source-context fixtures against the installed extension."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import datafog_core as api
7
+
8
+
9
+ def verify():
10
+ fixture = Path(__file__).resolve().parents[3] / "fixtures/email-context.jsonl"
11
+ for line in fixture.read_text().splitlines():
12
+ row = json.loads(line)
13
+ text = row["text"]
14
+ findings = api.scan(text, row["config"])
15
+ emails = [f for f in findings if f.entity_type == "EMAIL"]
16
+ assert [
17
+ dict(text=f.matched_text, start=f.codepoint_range.start, end=f.codepoint_range.end)
18
+ for f in emails
19
+ ] == row["matches"], row["id"]
20
+ for finding in emails:
21
+ assert text.encode()[finding.byte_range.start:finding.byte_range.end].decode() == finding.matched_text
22
+ assert text[finding.codepoint_range.start:finding.codepoint_range.end] == finding.matched_text
23
+ data = {"source": text}
24
+ located = api.scan_structured(data, row["config"]).findings
25
+ assert [f.finding for f in located] == findings
26
+ for strategy in ["redact", "mask", "remove"]:
27
+ policy = {"default": {"strategy": strategy}, "entities": ["EMAIL"]}
28
+ envelope = {"scan": row["config"], "transform": policy}
29
+ result = api.scan_and_transform(text, envelope)
30
+ assert result.text == row["outputs"][strategy], (row["id"], strategy)
31
+ assert api.transform(text, findings, policy) == result
32
+ for record, finding in zip(result.transformations, emails):
33
+ assert record.source_byte_range == finding.byte_range
34
+ assert record.source_codepoint_range == finding.codepoint_range
35
+ assert result.text.encode()[record.output_byte_range.start:record.output_byte_range.end].decode() == record.replacement
36
+ assert api.scan_and_transform_structured(data, envelope).data == {"source": result.text}
37
+ if row["config"]["format"] == "text":
38
+ assert api.scan(text) == findings
39
+ for invalid in [None, 1, True, {}, "yaml", "ENV"]:
40
+ for call in [
41
+ lambda: api.scan("", {"format": invalid}),
42
+ lambda: api.scan_structured({"source": ""}, {"format": invalid}),
43
+ ]:
44
+ try:
45
+ call()
46
+ except api.DataFogConfigurationError as error:
47
+ assert error.path == "/format"
48
+ else:
49
+ raise AssertionError("Invalid source format accepted")
@@ -12,6 +12,7 @@ import jwt_conformance
12
12
  import bearer_token_conformance
13
13
  import private_key_conformance
14
14
  import api_key_conformance
15
+ import email_context_conformance
15
16
  import uuid_conformance
16
17
  import credential_uri_conformance
17
18
  import us_routing_number_conformance
@@ -125,7 +126,44 @@ def verify_structured() -> None:
125
126
  raise AssertionError("invalid structured input accepted")
126
127
 
127
128
 
129
+ def verify_text_selection() -> None:
130
+ text = "👋 jane@example.com SSN 123-45-6789"
131
+ complete = scan(text)
132
+ selected = scan(text, {"entities": ["EMAIL"]})
133
+ expected = [finding for finding in complete if finding.entity_type == "EMAIL"]
134
+ assert len(selected) == len(expected) == 1
135
+ assert selected[0].codepoint_range.start == expected[0].codepoint_range.start
136
+ assert selected[0].byte_range.start == expected[0].byte_range.start
137
+ assert selected[0].matched_text == "jane@example.com"
138
+ transformed = scan_and_transform(text, {
139
+ "scan": {"entities": ["EMAIL"]},
140
+ "transform": {"default": {"strategy": "redact"}},
141
+ })
142
+ assert "123-45-6789" in transformed.text
143
+ assert "jane@example.com" not in transformed.text
144
+ for entities in ([], ["EMAIL", "EMAIL"], ["PERSON"], ["email"], [1], "EMAIL"):
145
+ try:
146
+ scan(text, {"entities": entities})
147
+ except DataFogConfigurationError as error:
148
+ assert error.code == "invalid_configuration"
149
+ assert error.path.startswith("/entities")
150
+ else:
151
+ raise AssertionError(f"invalid selection accepted: {entities!r}")
152
+ uuid = "550e8400-e29b-41d4-a716-446655440000"
153
+ assert not scan(uuid, {"entities": ["UUID"]})
154
+ assert [item.entity_type for item in scan(uuid, {"entities": ["UUID"], "detect_uuid": True})] == ["UUID"]
155
+ assert not scan(uuid, {"entities": ["EMAIL"], "detect_uuid": True})
156
+ assert scan("contact=jane@example.com", {"format": "env", "entities": ["EMAIL"]})[0].matched_text == "jane@example.com"
157
+ try:
158
+ scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]})
159
+ except DataFogConfigurationError as error:
160
+ assert error.reason == "unknown_field"
161
+ else:
162
+ raise AssertionError("text selection was accepted in structured scan")
163
+
164
+
128
165
  def main() -> None:
166
+ verify_text_selection()
129
167
  capabilities_conformance.verify()
130
168
  german_conformance.verify()
131
169
  credential_overlap_conformance.verify()
@@ -133,6 +171,7 @@ def main() -> None:
133
171
  bearer_token_conformance.verify()
134
172
  private_key_conformance.verify()
135
173
  api_key_conformance.verify()
174
+ email_context_conformance.verify()
136
175
  uuid_conformance.verify()
137
176
  credential_uri_conformance.verify()
138
177
  us_routing_number_conformance.verify()
@@ -42,7 +42,7 @@ config: _TransformationConfig = {
42
42
  },
43
43
  "allow": {"exact": {"EMAIL": ["safe@example.com"]}, "regex": {"EMAIL": [{"pattern": "safe", "case_sensitive": False}]}},
44
44
  }
45
- findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True})
45
+ findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True, "format": "env", "entities": ["EMAIL"]})
46
46
  assert_type(findings, list[Finding])
47
47
  finding = findings[0]
48
48
  assert_type(finding.entity_type, str)
@@ -127,9 +127,12 @@ INVALID = """
127
127
  from datafog_core import capabilities, scan, transform, scan_structured, PrivacyManager, TransformResult
128
128
  capabilities(1) # error
129
129
  scan(123) # error
130
+ scan("text", {"format": "yaml"}) # error
130
131
  scan("text", {"locale": 123}) # error
131
132
  scan("text", {"detect_uuid": "true"}) # error
132
133
  scan("text", {"max_bytes": 100}) # error
134
+ scan("text", {"entities": "EMAIL"}) # error
135
+ scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]}) # error
133
136
  scan("text")[0].entity_type = "OTHER" # error
134
137
  scan("text")[0].byte_range.start = 1 # error
135
138
  transform("text", ["not a finding"], {"default": {"strategy": "redact"}}) # error
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafog-core"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Fast structured PII detection library"
@@ -56,8 +56,12 @@ full-match regex values:
56
56
  }
57
57
  ```
58
58
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
59
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
60
60
  detection settings remain separate from transformation policy.
61
+ For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
+ or `format: "sql"` to preserve email boundaries. See the
63
+ [email source-context policy](docs/guides/email-boundaries.mdx) for supported
64
+ syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
61
65
 
62
66
  ## Packages
63
67
 
@@ -298,8 +302,28 @@ boundaries, never newlines. Returned text and offsets preserve the source.
298
302
  Passport and residence-permit patterns are legacy heuristics with limited
299
303
  coverage. See the [German entity reference](docs/reference/german-entities.mdx)
300
304
  and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
301
- Core 0.4.0 is published. The higher-level Python adapter and its dependency
302
- update remain separate and unreleased in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179).
305
+ The higher-level Python adapter and its dependency update were merged in
306
+ [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
307
+
308
+ ## Text detector selection
309
+
310
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
311
+ selected detectors, rather than detecting every type and filtering findings
312
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
313
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
314
+ `scan` configuration; transformation selection remains independent.
315
+
316
+ Omitting `entities` preserves existing detection. An explicit selection must be
317
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
318
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
319
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
320
+ `capabilities().entities` for types with the `text` scope.
321
+
322
+ Selection narrows enabled detectors: selecting `UUID` still requires
323
+ `detect_uuid=true`, and German types still require a German locale. Text format
324
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
325
+ scans do not accept this option. It avoids unselected detector work and findings,
326
+ but does not cap the selected findings or bound the size of a scan response.
303
327
 
304
328
  ## UUID identifiers
305
329
 
@@ -309,6 +333,6 @@ structured scans, independently of locale. It emits `UUID` findings for versions
309
333
  sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
310
334
  excluded sentinel forms and transformation examples.
311
335
 
312
- **0.4.0 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter remains a separate unreleased integration in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179). See the [release notes](docs/releases/0-4-0.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
336
+ **0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
313
337
 
314
- The unreleased **0.4.1 candidate** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [candidate release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the required exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
338
+ **Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
@@ -88,6 +88,14 @@ struct Detector {
88
88
  }
89
89
 
90
90
  impl Detector {
91
+ fn enabled(&self, config: &ScanConfig) -> bool {
92
+ self.activation.enabled(config)
93
+ && self
94
+ .labels
95
+ .iter()
96
+ .any(|label| config.includes_entity(label.as_str()))
97
+ }
98
+
91
99
  fn single(label: Label, detect: fn(&str, &mut Vec<Candidate>), activation: Activation) -> Self {
92
100
  Self {
93
101
  labels: vec![label],
@@ -147,12 +155,21 @@ pub(super) fn valid_locale(locale: &str) -> bool {
147
155
  locale_activation(locale).is_some()
148
156
  }
149
157
 
150
- pub(super) fn detect(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
151
- for detector in REGISTRY
158
+ pub(super) fn text_entity_supported(entity: &str) -> bool {
159
+ REGISTRY
152
160
  .iter()
153
- .filter(|detector| detector.activation.enabled(config))
154
- {
155
- (detector.detect)(text, candidates);
161
+ .any(|detector| detector.labels.iter().any(|label| label.as_str() == entity))
162
+ }
163
+
164
+ pub(super) fn detect(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
165
+ for detector in REGISTRY.iter().filter(|detector| detector.enabled(config)) {
166
+ if detector.labels == [Label::Email] {
167
+ crate::email_context::detect(text, config.format(), candidates);
168
+ } else if detector.activation == Activation::German {
169
+ german::detect_selected(text, config, candidates);
170
+ } else {
171
+ (detector.detect)(text, candidates);
172
+ }
156
173
  }
157
174
  }
158
175
 
@@ -214,3 +231,38 @@ pub fn capabilities() -> Capabilities {
214
231
  entities,
215
232
  }
216
233
  }
234
+
235
+ #[cfg(test)]
236
+ mod selection_dispatch_tests {
237
+ use super::*;
238
+
239
+ #[test]
240
+ fn dispatch_skips_every_registry_detector_without_a_selected_label() {
241
+ let activated = ScanConfig::new()
242
+ .with_locale("de")
243
+ .unwrap()
244
+ .with_uuid_detection(true);
245
+ for entity in capabilities()
246
+ .supported_entities
247
+ .iter()
248
+ .filter(|name| name.as_str() != "PERSON")
249
+ {
250
+ let selected = activated
251
+ .clone()
252
+ .with_entities(vec![entity.clone()])
253
+ .unwrap();
254
+ let enabled: Vec<_> = REGISTRY
255
+ .iter()
256
+ .filter(|detector| detector.enabled(&selected))
257
+ .collect();
258
+ assert_eq!(enabled.len(), 1, "{entity}");
259
+ assert!(
260
+ enabled[0]
261
+ .labels
262
+ .iter()
263
+ .any(|label| label.as_str() == entity)
264
+ );
265
+ }
266
+ assert!(REGISTRY.iter().all(|detector| detector.enabled(&activated)));
267
+ }
268
+ }
@@ -0,0 +1,172 @@
1
+ //! Delimit original source slices before applying the shared email matcher.
2
+ use crate::{Candidate, ScanFormat, detect_email};
3
+
4
+ pub(super) fn detect(text: &str, format: ScanFormat, candidates: &mut Vec<Candidate>) {
5
+ match format {
6
+ ScanFormat::Text => detect_email(text, candidates),
7
+ ScanFormat::Env => detect_env(text, candidates),
8
+ ScanFormat::Sql => detect_sql(text, candidates),
9
+ }
10
+ }
11
+
12
+ fn detect_slice(text: &str, start: usize, end: usize, candidates: &mut Vec<Candidate>) {
13
+ let first = candidates.len();
14
+ detect_email(&text[start..end], candidates);
15
+ for candidate in &mut candidates[first..] {
16
+ candidate.start_byte += start;
17
+ candidate.end_byte += start;
18
+ }
19
+ }
20
+
21
+ fn line_end(text: &str, start: usize) -> usize {
22
+ text[start..]
23
+ .find('\n')
24
+ .map_or(text.len(), |end| start + end)
25
+ }
26
+
27
+ fn skip_horizontal_space(bytes: &[u8], mut cursor: usize) -> usize {
28
+ while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\r')) {
29
+ cursor += 1;
30
+ }
31
+ cursor
32
+ }
33
+
34
+ fn env_value_start(line: &str) -> Option<usize> {
35
+ let bytes = line.as_bytes();
36
+ let mut cursor = skip_horizontal_space(bytes, 0);
37
+ if line[cursor..].starts_with("export") && matches!(bytes.get(cursor + 6), Some(b' ' | b'\t')) {
38
+ cursor = skip_horizontal_space(bytes, cursor + 6);
39
+ }
40
+ if !bytes
41
+ .get(cursor)
42
+ .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_')
43
+ {
44
+ return None;
45
+ }
46
+ cursor += 1;
47
+ while bytes
48
+ .get(cursor)
49
+ .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
50
+ {
51
+ cursor += 1;
52
+ }
53
+ cursor = skip_horizontal_space(bytes, cursor);
54
+ if bytes.get(cursor) != Some(&b'=') {
55
+ return None;
56
+ }
57
+ Some(skip_horizontal_space(bytes, cursor + 1))
58
+ }
59
+
60
+ // Return the closing delimiter or EOF. No values are decoded, so offsets and
61
+ // escaped local parts remain contiguous spans of the caller's source text.
62
+ fn quote_end(bytes: &[u8], opening: usize, doubled: bool, backslash: bool) -> usize {
63
+ let quote = bytes[opening];
64
+ let mut cursor = opening + 1;
65
+ while cursor < bytes.len() {
66
+ if backslash && bytes[cursor] == b'\\' {
67
+ cursor += if cursor + 1 < bytes.len() { 2 } else { 1 };
68
+ } else if bytes[cursor] == quote {
69
+ if doubled && bytes.get(cursor + 1) == Some(&quote) {
70
+ cursor += 2;
71
+ } else {
72
+ return cursor;
73
+ }
74
+ } else {
75
+ cursor += 1;
76
+ }
77
+ }
78
+ bytes.len()
79
+ }
80
+
81
+ fn detect_env(text: &str, candidates: &mut Vec<Candidate>) {
82
+ let bytes = text.as_bytes();
83
+ let mut cursor = 0;
84
+ while cursor < text.len() {
85
+ let end = line_end(text, cursor);
86
+ if let Some(value) = env_value_start(&text[cursor..end]) {
87
+ let start = cursor + value;
88
+ if matches!(bytes.get(start), Some(b'\'' | b'"')) {
89
+ let close = quote_end(bytes, start, false, bytes[start] == b'"');
90
+ detect_slice(text, start + 1, close, candidates);
91
+ if close == text.len() {
92
+ break;
93
+ }
94
+ let tail_end = line_end(text, close + 1);
95
+ detect_env_unquoted(text, close + 1, tail_end, candidates);
96
+ cursor = tail_end;
97
+ } else {
98
+ detect_env_unquoted(text, start, end, candidates);
99
+ cursor = end;
100
+ }
101
+ } else {
102
+ detect_env_unquoted(text, cursor, end, candidates);
103
+ cursor = end;
104
+ }
105
+ if cursor < text.len() {
106
+ cursor += 1;
107
+ }
108
+ }
109
+ }
110
+
111
+ fn detect_env_unquoted(text: &str, start: usize, end: usize, candidates: &mut Vec<Candidate>) {
112
+ let bytes = text.as_bytes();
113
+ for cursor in start..end {
114
+ if bytes[cursor] == b'#' && (cursor == start || bytes[cursor - 1].is_ascii_whitespace()) {
115
+ detect_slice(text, start, cursor, candidates);
116
+ detect_slice(text, cursor + 1, end, candidates);
117
+ return;
118
+ }
119
+ }
120
+ detect_slice(text, start, end, candidates);
121
+ }
122
+
123
+ fn detect_sql(text: &str, candidates: &mut Vec<Candidate>) {
124
+ let bytes = text.as_bytes();
125
+ let mut cursor = 0;
126
+ let mut plain_start = 0;
127
+ while cursor < bytes.len() {
128
+ let end = if matches!(bytes[cursor], b'\'' | b'"') {
129
+ detect_slice(text, plain_start, cursor, candidates);
130
+ let close = quote_end(bytes, cursor, true, false);
131
+ detect_slice(text, cursor + 1, close, candidates);
132
+ if close < bytes.len() {
133
+ close + 1
134
+ } else {
135
+ close
136
+ }
137
+ } else if bytes[cursor..].starts_with(b"--") {
138
+ detect_slice(text, plain_start, cursor, candidates);
139
+ let end = line_end(text, cursor);
140
+ detect_slice(text, cursor + 2, end, candidates);
141
+ end
142
+ } else if bytes[cursor..].starts_with(b"/*") {
143
+ detect_slice(text, plain_start, cursor, candidates);
144
+ let mut end = cursor + 2;
145
+ let mut comment_start = end;
146
+ let mut depth = 1;
147
+ while end < bytes.len() && depth > 0 {
148
+ if bytes[end..].starts_with(b"/*") {
149
+ detect_slice(text, comment_start, end, candidates);
150
+ depth += 1;
151
+ end += 2;
152
+ comment_start = end;
153
+ } else if bytes[end..].starts_with(b"*/") {
154
+ detect_slice(text, comment_start, end, candidates);
155
+ depth -= 1;
156
+ end += 2;
157
+ comment_start = end;
158
+ } else {
159
+ end += 1;
160
+ }
161
+ }
162
+ detect_slice(text, comment_start, end, candidates);
163
+ end
164
+ } else {
165
+ cursor += 1;
166
+ continue;
167
+ };
168
+ cursor = end;
169
+ plain_start = end;
170
+ }
171
+ detect_slice(text, plain_start, text.len(), candidates);
172
+ }
@@ -1,5 +1,5 @@
1
1
  //! Locale-gated lexical detection, without official identifier validation.
2
- use crate::{Candidate, Label};
2
+ use crate::{Candidate, Label, ScanConfig};
3
3
  use regex::Regex;
4
4
  use std::sync::LazyLock;
5
5
 
@@ -71,7 +71,14 @@ fn ascii_boundary(text: &str, start: usize, end: usize) -> bool {
71
71
  }
72
72
 
73
73
  pub(super) fn detect(text: &str, candidates: &mut Vec<Candidate>) {
74
- for (label, regex) in DETECTORS.iter() {
74
+ detect_selected(text, &ScanConfig::default(), candidates);
75
+ }
76
+
77
+ pub(super) fn detect_selected(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
78
+ for (label, regex) in DETECTORS
79
+ .iter()
80
+ .filter(|(label, _)| config.includes_entity(label.as_str()))
81
+ {
75
82
  for captures in regex.captures_iter(text) {
76
83
  // Both captures are unconditional in the statically compiled expressions.
77
84
  let whole = captures.get(0).expect("whole regex match");