euredact 0.3.2__tar.gz → 0.3.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.
- {euredact-0.3.2 → euredact-0.3.3}/CHANGELOG.md +95 -0
- {euredact-0.3.2 → euredact-0.3.3}/PKG-INFO +1 -1
- {euredact-0.3.2 → euredact-0.3.3}/pyproject.toml +1 -1
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/__init__.py +1 -1
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/engine.py +139 -24
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/matchers.py +67 -2
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/sdk.py +7 -2
- euredact-0.3.3/tests/sweep.py +234 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_api_contract.py +37 -0
- euredact-0.3.3/tests/test_structural_invariants.py +189 -0
- {euredact-0.3.2 → euredact-0.3.3}/.github/workflows/ci.yml +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/.gitignore +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/CONTRIBUTING.md +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/LICENSE +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/README.md +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/benchmarks/bench_engine.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/examples/basic_usage.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/examples/batch_processing.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/examples/llm_preprocessing.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cache.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/client.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/hasher.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/cloud/shuffler.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/coref/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/coref/models.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/coref/resolver.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/normalizer.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/py.typed +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/bic_registry.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/context.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/_base.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/_shared.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/at.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/be.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/bg.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ch.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/cy.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/cz.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/de.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/dk.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ee.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/el.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/es.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/fi.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/fr.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/hr.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/hu.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ie.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/is_.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/it.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/lt.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/lu.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/lv.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/mt.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/nl.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/no.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/pl.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/pt.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/ro.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/se.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/si.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/sk.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/countries/uk.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/de_districts.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/evidence.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/registry.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/structural.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/suppressors.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/rules/validators.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/src/euredact/types.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/benchmark.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/conftest.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/countries/__init__.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/countries/test_be.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/countries/test_nl.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/eval_diff.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/eval_full.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_batch.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_bic.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_cache.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_conformance.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_country_inference.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_document_context.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_engine.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_international.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_invariant_generation.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_normalizer.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_postal_code.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_redact.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_scan_path_parity.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_structdata.py +0 -0
- {euredact-0.3.2 → euredact-0.3.3}/tests/test_validators.py +0 -0
|
@@ -1,5 +1,100 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 (2026-07-29)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A shorter validated match could re-cut a longer one and leak the
|
|
8
|
+
remainder.** Under `countries` `["NL"]` the Dutch national-ID pattern
|
|
9
|
+
validates on `194.232.104`, outranked the IP address `194.232.104.77`, and
|
|
10
|
+
left `.77` in the output. The TypeScript SDK did the same with no country
|
|
11
|
+
declared at all.
|
|
12
|
+
|
|
13
|
+
Span length now outranks every other signal, including validation. Priority
|
|
14
|
+
still decides between candidates of *equal* length, which is where it was
|
|
15
|
+
always meant to apply — a valid IBAN beats a coincidental match on the same
|
|
16
|
+
characters. Masking more than necessary is recoverable; masking less is not.
|
|
17
|
+
|
|
18
|
+
- **`countries` could change which spans were found, not just how they were
|
|
19
|
+
labelled.** Promotion depends on whether the document corroborates a
|
|
20
|
+
country, so the declared country decided which of two overlapping candidates
|
|
21
|
+
won. Two cases were found, the second only after fixing the first.
|
|
22
|
+
|
|
23
|
+
Ordering between different spans is now country-blind by construction —
|
|
24
|
+
length, then the span's structural tier, then leftmost — and `(length, start)`
|
|
25
|
+
identifies a span uniquely, so the country-aware signals can only choose the
|
|
26
|
+
**label within a span**, never its extent. The invariant holds by design
|
|
27
|
+
rather than by test.
|
|
28
|
+
|
|
29
|
+
- **A bare string for `countries` silently discarded every detection.**
|
|
30
|
+
`countries="NL"` is iterable, so it was walked into the codes `"N"` and `"L"`.
|
|
31
|
+
Neither resolves, so nothing was declared — and every detection carrying a
|
|
32
|
+
country came back flagged out of scope, while the redacted text still looked
|
|
33
|
+
correct. The README tells callers to filter on exactly that field, so a
|
|
34
|
+
documented pipeline kept none of them. It failed toward "no PII here", from a
|
|
35
|
+
one-character typo.
|
|
36
|
+
|
|
37
|
+
Both SDKs now raise `TypeError` naming the argument and showing the fix, on
|
|
38
|
+
every entry point. A wrong *code* still only warns: raising there would invite
|
|
39
|
+
callers to wrap redaction in try/except and skip it. A wrong *type* is a
|
|
40
|
+
programming error with no correct interpretation to fall back on.
|
|
41
|
+
|
|
42
|
+
- **The generic phone pattern claimed fragments of rejected identifiers.**
|
|
43
|
+
`Rijksregisternummer: 85.03.19-284.73` was reported as a `PHONE`. The Belgian
|
|
44
|
+
national-number pattern matched the whole value and failed its checksum; the
|
|
45
|
+
separator-tolerant phone pattern then took characters 3-14 of it.
|
|
46
|
+
|
|
47
|
+
A candidate covering only *part* of a rejected identifier is a fragment of
|
|
48
|
+
that identifier, not a separate entity, and is now dropped. An equal span is
|
|
49
|
+
left alone: that is two schemes competing for the whole value, and demoting
|
|
50
|
+
those is what previously handed a Swedish phone number to a Danish CPR.
|
|
51
|
+
|
|
52
|
+
Fragment detection ignores the declared country by design. It removes
|
|
53
|
+
candidates, so making it country-aware would let `countries` change which
|
|
54
|
+
spans are found — the invariant in `test_invariant_generation.py`.
|
|
55
|
+
|
|
56
|
+
No measurable cost: recall, precision and type-correct rate are unchanged on
|
|
57
|
+
all 152,300 corpus documents.
|
|
58
|
+
|
|
59
|
+
- **Identifiers glued to a non-ASCII letter were missed.** Python's `\b` is
|
|
60
|
+
Unicode-aware, so it saw no boundary between a Cyrillic or accented letter
|
|
61
|
+
and a digit: `ЕГН7523169263`, `PESELŁ44051401359`, `čísloř7401011233`,
|
|
62
|
+
`Steuerß12345678911` and `kodasž38605181232` were all detected by the
|
|
63
|
+
TypeScript SDK and silently missed by this one.
|
|
64
|
+
|
|
65
|
+
`\b` is now compiled to the *union* of the Unicode and ASCII readings.
|
|
66
|
+
Swapping to ASCII-only — the obvious fix — trades one silent miss for
|
|
67
|
+
another: it drops Greek and Cyrillic e-mail local parts, which are
|
|
68
|
+
deliberately supported. `re.ASCII` is unusable for the same reason, since it
|
|
69
|
+
would also narrow `\w`.
|
|
70
|
+
|
|
71
|
+
Costs about 20% latency on short documents from the added lookarounds.
|
|
72
|
+
Accuracy on the corpus is unchanged.
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- **`make check` and `make verify`.** One entry point for every check.
|
|
77
|
+
`make check` is what CI runs; `make verify` adds the corpus checks CI cannot
|
|
78
|
+
run, because the corpus lives outside the repository.
|
|
79
|
+
|
|
80
|
+
- `tests/sweep.py` — structural properties over ~187,000 documents: offsets
|
|
81
|
+
index the text, spans do not overlap, detection is deterministic, the cache
|
|
82
|
+
is transparent, and no country argument changes which spans are found. Both
|
|
83
|
+
ranking bugs above were found here; the twenty-document version that runs in
|
|
84
|
+
CI showed nothing.
|
|
85
|
+
- `scripts/parity.py` — do both SDKs mask the same *characters*, over whole
|
|
86
|
+
corpora. Conformance vectors pin named cases; this is the broad counterpart,
|
|
87
|
+
and it is how a 19,014-character gap was found that no vector showed.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
### Known issues
|
|
91
|
+
|
|
92
|
+
- A checksum-invalid identifier occupying a span no other detector claims can
|
|
93
|
+
still be reported under the wrong type — `7401011234` with `countries` `["CZ"]`
|
|
94
|
+
is masked as a Romanian `PHONE` at `countryConfidence` 0. The span *is*
|
|
95
|
+
redacted; only the label is wrong. The fragment rule above does not apply
|
|
96
|
+
because the spans are equal rather than nested.
|
|
97
|
+
|
|
3
98
|
## 0.3.2 (2026-07-29)
|
|
4
99
|
|
|
5
100
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: euredact
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: European PII redaction SDK — rule engine for 31 countries
|
|
5
5
|
Project-URL: Documentation, https://github.com/euredact/euredact#readme
|
|
6
6
|
Project-URL: Repository, https://github.com/euredact/euredact
|
|
@@ -39,6 +39,31 @@ def _validate_custom_pattern(pattern: str) -> None:
|
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def check_country_arg(value: object, param: str) -> None:
|
|
43
|
+
"""Reject a bare string where a list of country codes is expected.
|
|
44
|
+
|
|
45
|
+
``countries="NL"`` is iterable, so Python walks it into the codes ``"N"``
|
|
46
|
+
and ``"L"``. Neither resolves, so nothing is declared — and every detection
|
|
47
|
+
that *does* carry a country is then flagged ``out_of_scope``. A caller
|
|
48
|
+
following the documented pattern of filtering on that field silently keeps
|
|
49
|
+
none of them, while ``redacted_text`` still looks perfectly correct. The
|
|
50
|
+
failure direction is "no PII here", from a one-character typo.
|
|
51
|
+
|
|
52
|
+
A wrong *code* is data and only warns (see ``CountryRegistry.warn_unknown``)
|
|
53
|
+
— raising there would invite callers to wrap redaction in try/except and
|
|
54
|
+
skip it. A wrong *type* is a programming error, surfaces immediately, and
|
|
55
|
+
has no correct interpretation to fall back on.
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(value, (str, bytes)):
|
|
58
|
+
shown = value.decode(errors="replace") if isinstance(value, bytes) else value
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"{param} must be a list of country codes, not a bare string. "
|
|
61
|
+
f"Pass {param}=[{shown!r}] rather than {param}={shown!r} — a string "
|
|
62
|
+
f"is iterated character by character, which silently declares "
|
|
63
|
+
f"nothing and flags every detection out_of_scope."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
42
67
|
class RuleEngine:
|
|
43
68
|
"""The core rule-based PII detection engine.
|
|
44
69
|
|
|
@@ -135,6 +160,9 @@ class RuleEngine:
|
|
|
135
160
|
It is scored alongside this text's own evidence but is **not** returned,
|
|
136
161
|
so a caller accumulating evidence never counts it twice.
|
|
137
162
|
"""
|
|
163
|
+
check_country_arg(country_codes, "countries")
|
|
164
|
+
check_country_arg(country_hint, "country_hint")
|
|
165
|
+
|
|
138
166
|
raw_matches = self.generate_candidates(text)
|
|
139
167
|
|
|
140
168
|
# The declared countries are a scoring signal from here on: they rank
|
|
@@ -226,16 +254,50 @@ class RuleEngine:
|
|
|
226
254
|
# phone number — and demoting on it cost every PHONE candidate in the
|
|
227
255
|
# document its rank, which is how a Dutch mobile came back as a Danish
|
|
228
256
|
# NATIONAL_ID even after the digits were correctly ranked.
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
257
|
+
# Two rules, because a failed checksum carries two different messages
|
|
258
|
+
# depending on how the spans line up.
|
|
259
|
+
#
|
|
260
|
+
# Same type, any containment: "these digits are not a valid X" is
|
|
261
|
+
# evidence against X, not against the span. Demoting *every* type on an
|
|
262
|
+
# equal span meant Sweden's own personnummer checksum failing on
|
|
263
|
+
# 0708787668 demoted the Swedish *phone* candidate for the same digits,
|
|
264
|
+
# handing the span to a Danish CPR that happened to validate — 878
|
|
265
|
+
# mistyped phone numbers per 30,000 documents.
|
|
266
|
+
#
|
|
267
|
+
# Any type, strict subset: a candidate covering only *part* of a
|
|
268
|
+
# rejected identifier is a fragment of it, not a different entity. The
|
|
269
|
+
# generic separator-tolerant phone pattern claimed characters 3-14 of
|
|
270
|
+
# the rejected Belgian national number in
|
|
271
|
+
# "Rijksregisternummer: 85.03.19-284.73" and reported it as a PHONE.
|
|
272
|
+
# This is the case the original suppression zones were written for —
|
|
273
|
+
# licence-plate fragments inside an invalid IBAN — which the per-type
|
|
274
|
+
# split above had given up along with the over-reach.
|
|
275
|
+
#
|
|
276
|
+
# An equal span is two schemes competing for the whole value; a strict
|
|
277
|
+
# subset is one scheme picking over another's wreckage.
|
|
278
|
+
corroborated: list[tuple[int, int, object]] = [
|
|
279
|
+
(start, end, etype)
|
|
280
|
+
for start, end, cc, etype in failed_spans
|
|
281
|
+
if not country_scores or cc in ("SHARED", "CUSTOM") or cc in country_scores
|
|
282
|
+
]
|
|
283
|
+
corroborated.sort()
|
|
284
|
+
|
|
285
|
+
# Fragment detection deliberately ignores corroboration and uses every
|
|
286
|
+
# failed span, because it *removes* candidates and must therefore be
|
|
287
|
+
# country-blind. Gating it on the document's countries made the set of
|
|
288
|
+
# spans found depend on `countries=`, which is precisely invariant I1 —
|
|
289
|
+
# caught by tests/test_invariant_generation.py within seconds of being
|
|
290
|
+
# written. Demotion may be country-aware because it only reorders; a
|
|
291
|
+
# demoted candidate still claims a span nothing else wants.
|
|
292
|
+
#
|
|
293
|
+
# Kept unmerged: merging adjacent spans would invent strict-subset
|
|
294
|
+
# relationships that neither original span had.
|
|
295
|
+
all_failed = sorted((start, end) for start, end, _cc, _et in failed_spans)
|
|
296
|
+
strict_starts = [f[0] for f in all_failed]
|
|
297
|
+
|
|
235
298
|
by_type: dict[object, list[tuple[int, int]]] = {}
|
|
236
|
-
for start, end,
|
|
237
|
-
|
|
238
|
-
by_type.setdefault(etype, []).append((start, end))
|
|
299
|
+
for start, end, etype in corroborated:
|
|
300
|
+
by_type.setdefault(etype, []).append((start, end))
|
|
239
301
|
|
|
240
302
|
# Merge overlapping zones per type and index them for O(log n)
|
|
241
303
|
# containment checks instead of O(n) per match.
|
|
@@ -274,7 +336,7 @@ class RuleEngine:
|
|
|
274
336
|
# (priority, in_scope, start, end, match, prebuilt). in_scope ranks
|
|
275
337
|
# candidates within a priority tier; it never removes any.
|
|
276
338
|
candidates: list[
|
|
277
|
-
tuple[int, float, int, int, int, RawMatch | None, Detection | None]
|
|
339
|
+
tuple[int, int, float, int, int, int, RawMatch | None, Detection | None]
|
|
278
340
|
] = []
|
|
279
341
|
for match, has_valid_validator in validated:
|
|
280
342
|
# A validator-less match inside a failed-validation span used to be
|
|
@@ -295,9 +357,31 @@ class RuleEngine:
|
|
|
295
357
|
zone_starts, zone_ends = zone
|
|
296
358
|
idx = bisect.bisect_right(zone_starts, match.start) - 1
|
|
297
359
|
demoted = idx >= 0 and zone_ends[idx] >= match.end
|
|
360
|
+
# Strict subset of a rejected identifier, whatever its type: the
|
|
361
|
+
# candidate covers only part of something already recognised and
|
|
362
|
+
# rejected, so it is a fragment of that, not a separate entity.
|
|
363
|
+
#
|
|
364
|
+
# Dropped rather than demoted. Demotion cannot help here: nothing
|
|
365
|
+
# else wants a fragment, so a demoted candidate still wins its span
|
|
366
|
+
# by default — the generic phone pattern kept claiming characters
|
|
367
|
+
# 3-14 of a rejected Belgian national number. Dropping is safe in a
|
|
368
|
+
# way that whole-span deletion was not, because the characters stay
|
|
369
|
+
# covered by whatever claims the enclosing span, or by nothing —
|
|
370
|
+
# and "nothing" is the right answer for the tail of a rejected
|
|
371
|
+
# identifier.
|
|
372
|
+
fragment = False
|
|
373
|
+
if match.pattern_def.validator is None and not demoted:
|
|
374
|
+
hi = bisect.bisect_right(strict_starts, match.start)
|
|
375
|
+
for zs, ze in all_failed[:hi]:
|
|
376
|
+
if match.end <= ze and (match.start > zs or match.end < ze):
|
|
377
|
+
fragment = True
|
|
378
|
+
break
|
|
379
|
+
if fragment:
|
|
380
|
+
continue
|
|
298
381
|
|
|
299
382
|
if demoted:
|
|
300
383
|
priority = -1
|
|
384
|
+
blind_priority = -1
|
|
301
385
|
elif has_valid_validator:
|
|
302
386
|
# A passing checksum normally outranks everything. But a
|
|
303
387
|
# checksum only says the digits fit *some* national scheme, and
|
|
@@ -321,28 +405,32 @@ class RuleEngine:
|
|
|
321
405
|
or match.country_code in country_scores
|
|
322
406
|
)
|
|
323
407
|
priority = 3 if vouched else 1
|
|
408
|
+
blind_priority = 3
|
|
324
409
|
elif match.country_code == "CUSTOM":
|
|
325
410
|
priority = 2
|
|
411
|
+
blind_priority = 2
|
|
326
412
|
elif match.pattern_def.entity_type == EntityType.POSTAL_CODE:
|
|
327
413
|
# A bare digit run is the weakest evidence in the engine and
|
|
328
414
|
# must never re-cut a span a structured detector claims
|
|
329
415
|
# (PHONE, SSN, NATIONAL_ID, BANK_ACCOUNT, VAT...). Postal
|
|
330
416
|
# codes take unclaimed spans only, whatever the span lengths.
|
|
331
417
|
priority = 0
|
|
418
|
+
blind_priority = 0
|
|
332
419
|
else:
|
|
333
420
|
priority = 1
|
|
421
|
+
blind_priority = 1
|
|
334
422
|
|
|
335
423
|
in_scope = 1 if (declared is None or match.country_code in declared) else 0
|
|
336
424
|
country_score = country_scores.get(match.country_code, 0.0)
|
|
337
425
|
candidates.append(
|
|
338
|
-
(priority, country_score, in_scope,
|
|
426
|
+
(blind_priority, priority, country_score, in_scope,
|
|
339
427
|
match.start, match.end, match, None)
|
|
340
428
|
)
|
|
341
429
|
|
|
342
430
|
# Structural detectors (JSON field names, CSV headers). These arrive as
|
|
343
431
|
# finished Detections, carry no RawMatch, and are not suppressed.
|
|
344
432
|
for d in detect_structural_dob(text):
|
|
345
|
-
candidates.append((1, 0.0, 1, d.start, d.end, None, d))
|
|
433
|
+
candidates.append((1, 1, 0.0, 1, d.start, d.end, None, d))
|
|
346
434
|
|
|
347
435
|
# Deduplicate: validated > custom > regex-only, then longer wins
|
|
348
436
|
detections = self._deduplicate(text, candidates, declared)
|
|
@@ -366,7 +454,7 @@ class RuleEngine:
|
|
|
366
454
|
def _deduplicate(
|
|
367
455
|
text: str,
|
|
368
456
|
candidates: list[
|
|
369
|
-
tuple[int, float, int, int, int, RawMatch | None, Detection | None]
|
|
457
|
+
tuple[int, int, float, int, int, int, RawMatch | None, Detection | None]
|
|
370
458
|
],
|
|
371
459
|
declared: set[str] | None = None,
|
|
372
460
|
) -> list[Detection]:
|
|
@@ -388,24 +476,51 @@ class RuleEngine:
|
|
|
388
476
|
if not candidates:
|
|
389
477
|
return []
|
|
390
478
|
|
|
391
|
-
#
|
|
392
|
-
#
|
|
479
|
+
# Ordering between *different* spans is country-blind; the country
|
|
480
|
+
# only ever picks the winner among candidates covering the *same* span.
|
|
481
|
+
#
|
|
482
|
+
# That split is what makes invariant I1 structural rather than merely
|
|
483
|
+
# tested. Country-aware promotion had twice decided which span won:
|
|
484
|
+
# under countries=["NL"] a validated national ID outranked a longer IP
|
|
485
|
+
# address and exposed ".77", and two equal-length candidates at
|
|
486
|
+
# different offsets swapped places. Both were found by sweeping the
|
|
487
|
+
# corpus, not by reasoning, and both are impossible now — the key below
|
|
488
|
+
# is country-blind up to and including `start`, and (length, start)
|
|
489
|
+
# already identifies a span uniquely.
|
|
490
|
+
#
|
|
491
|
+
# Ordering, in words: longest first (a shorter match winning re-cuts a
|
|
492
|
+
# longer one and leaks the remainder), then the structural tier, then
|
|
493
|
+
# leftmost. Only after a span is fixed do the country-aware fields
|
|
494
|
+
# break the tie, deciding the label rather than the extent.
|
|
495
|
+
#
|
|
496
|
+
# The structural tier is a property of the *span*, not of the candidate:
|
|
497
|
+
# the best tier anything claiming those exact characters can offer. Used
|
|
498
|
+
# per candidate it would also discriminate *within* a span, where the
|
|
499
|
+
# country-aware tier is supposed to have the final word — that silently
|
|
500
|
+
# disabled country inference entirely, handing every ambiguous value to
|
|
501
|
+
# whichever foreign scheme happened to validate.
|
|
502
|
+
span_tier: dict[tuple[int, int], int] = {}
|
|
503
|
+
for c in candidates:
|
|
504
|
+
key = (c[4], c[5])
|
|
505
|
+
if c[0] > span_tier.get(key, -2):
|
|
506
|
+
span_tier[key] = c[0]
|
|
507
|
+
|
|
393
508
|
sorted_cands = sorted(
|
|
394
509
|
candidates,
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
510
|
+
key=lambda c: (
|
|
511
|
+
-(c[5] - c[4]), # longest first } country-blind
|
|
512
|
+
-span_tier[(c[4], c[5])], # best tier here } — decides
|
|
513
|
+
c[4], # leftmost } WHICH span
|
|
514
|
+
-c[1], # country priority } country-aware
|
|
515
|
+
-c[2], # country evidence } — decides the
|
|
516
|
+
-c[3], # declared scope } LABEL only
|
|
517
|
+
),
|
|
403
518
|
)
|
|
404
519
|
result: list[Detection] = []
|
|
405
520
|
occupied: set[int] = set()
|
|
406
521
|
span_verdicts: dict[tuple[object, int, int], bool] = {}
|
|
407
522
|
|
|
408
|
-
for
|
|
523
|
+
for _bp, _prio, _score, in_scope, start, end, match, prebuilt in sorted_cands:
|
|
409
524
|
if any(p in occupied for p in range(start, end)):
|
|
410
525
|
continue
|
|
411
526
|
|
|
@@ -90,6 +90,68 @@ def _silence_stderr() -> Iterator[None]:
|
|
|
90
90
|
os.close(saved_fd)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
# Python's \b is Unicode-aware: a Cyrillic or accented letter counts as a word
|
|
94
|
+
# character, so "\b\d{10}\b" does not match the digits in "ЕГН7523169263". JS's
|
|
95
|
+
# \b is ASCII-only, so Node matched and redacted exactly those cases and Python
|
|
96
|
+
# did not — five of five, across the alphabets this engine targets.
|
|
97
|
+
#
|
|
98
|
+
# Which rule is "right" is arguable: Python's is self-consistent, JS's is
|
|
99
|
+
# script-dependent. But for a redaction tool the argument is settled by the
|
|
100
|
+
# failure direction. Python's principled rule leaves a national ID glued to a
|
|
101
|
+
# non-ASCII letter unredacted; JS's limitation catches it. Over-detection is
|
|
102
|
+
# recoverable, a silent miss is not.
|
|
103
|
+
#
|
|
104
|
+
# So \b is rewritten at compile time rather than in 303 pattern sources.
|
|
105
|
+
# re.ASCII is not usable here: it would also narrow \w, breaking Unicode e-mail
|
|
106
|
+
# local parts, which are deliberately supported.
|
|
107
|
+
#
|
|
108
|
+
# The rewrite is a *union* of both readings, not a swap to the ASCII one.
|
|
109
|
+
# Neither alone is sufficient, and swapping regresses the other direction:
|
|
110
|
+
#
|
|
111
|
+
# "ЕГН7523169263" needs the ASCII reading (Н and 7 are both
|
|
112
|
+
# word characters to Python, so no boundary)
|
|
113
|
+
# "Mail: αλέξης@example.gr" needs the Unicode reading (α is not an ASCII
|
|
114
|
+
# word character, so no boundary)
|
|
115
|
+
#
|
|
116
|
+
# Taking either one alone trades one silent miss for another — swapping to
|
|
117
|
+
# ASCII-only was measured to drop the Greek address entirely. The union fires
|
|
118
|
+
# wherever *either* reading sees a boundary, which is the over-detecting
|
|
119
|
+
# direction on both counts and matches what Node already does on all seven
|
|
120
|
+
# cases.
|
|
121
|
+
_ASCII_WORD_BOUNDARY = (
|
|
122
|
+
r"(?:\b|(?<![0-9A-Za-z_])(?=[0-9A-Za-z_])|(?<=[0-9A-Za-z_])(?![0-9A-Za-z_]))"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ascii_word_boundaries(pattern: str) -> str:
|
|
127
|
+
"""Rewrite every ``\b`` in *pattern* to the union boundary above.
|
|
128
|
+
|
|
129
|
+
Skips escaped backslashes and character classes, where ``\b`` means a
|
|
130
|
+
backspace rather than a boundary. Neither occurs in the shipped patterns
|
|
131
|
+
today; the scan is here so that adding one cannot silently corrupt it.
|
|
132
|
+
"""
|
|
133
|
+
out: list[str] = []
|
|
134
|
+
i = 0
|
|
135
|
+
in_class = False
|
|
136
|
+
while i < len(pattern):
|
|
137
|
+
ch = pattern[i]
|
|
138
|
+
if ch == "\\" and i + 1 < len(pattern):
|
|
139
|
+
nxt = pattern[i + 1]
|
|
140
|
+
if nxt == "b" and not in_class:
|
|
141
|
+
out.append(_ASCII_WORD_BOUNDARY)
|
|
142
|
+
else:
|
|
143
|
+
out.append(pattern[i:i + 2])
|
|
144
|
+
i += 2
|
|
145
|
+
continue
|
|
146
|
+
if ch == "[":
|
|
147
|
+
in_class = True
|
|
148
|
+
elif ch == "]":
|
|
149
|
+
in_class = False
|
|
150
|
+
out.append(ch)
|
|
151
|
+
i += 1
|
|
152
|
+
return "".join(out)
|
|
153
|
+
|
|
154
|
+
|
|
93
155
|
def _max_match_width(pattern: str) -> int:
|
|
94
156
|
"""Longest string *pattern* can match, or a huge number if unbounded.
|
|
95
157
|
|
|
@@ -149,14 +211,14 @@ class MultiPatternMatcher:
|
|
|
149
211
|
|
|
150
212
|
def add_pattern(self, pattern_def: PatternDef, country_code: str) -> None:
|
|
151
213
|
"""Add a single pattern."""
|
|
152
|
-
compiled = re.compile(pattern_def.pattern, re.UNICODE)
|
|
214
|
+
compiled = re.compile(_ascii_word_boundaries(pattern_def.pattern), re.UNICODE)
|
|
153
215
|
self._patterns.append((compiled, pattern_def, country_code))
|
|
154
216
|
self._compiled = False
|
|
155
217
|
|
|
156
218
|
def add_country(self, config: CountryConfig) -> None:
|
|
157
219
|
"""Add a country's patterns."""
|
|
158
220
|
for pdef in config.patterns:
|
|
159
|
-
compiled = re.compile(pdef.pattern, re.UNICODE)
|
|
221
|
+
compiled = re.compile(_ascii_word_boundaries(pdef.pattern), re.UNICODE)
|
|
160
222
|
self._patterns.append((compiled, pdef, config.code))
|
|
161
223
|
self._compiled = False
|
|
162
224
|
|
|
@@ -176,6 +238,9 @@ class MultiPatternMatcher:
|
|
|
176
238
|
codes and secrets), and those that can match further than the window
|
|
177
239
|
overlap, which the sliding window could otherwise straddle.
|
|
178
240
|
"""
|
|
241
|
+
# The *original* source goes to RE2, not the rewritten one: RE2's \b is
|
|
242
|
+
# already ASCII-only, so the semantics match, while the rewrite uses
|
|
243
|
+
# lookbehind that RE2 cannot compile.
|
|
179
244
|
automaton = _re2.Set.SearchSet()
|
|
180
245
|
self._re2_slot = []
|
|
181
246
|
added = 0
|
|
@@ -10,7 +10,7 @@ from typing import Iterator
|
|
|
10
10
|
from euredact.cache import ResultCache
|
|
11
11
|
from euredact.normalizer import map_offset_to_original, normalize
|
|
12
12
|
from euredact.rules.context import DocumentContext
|
|
13
|
-
from euredact.rules.engine import RuleEngine
|
|
13
|
+
from euredact.rules.engine import RuleEngine, check_country_arg
|
|
14
14
|
from euredact.rules.evidence import weights_to_ranking
|
|
15
15
|
from euredact.types import EntityType, RedactResult
|
|
16
16
|
|
|
@@ -112,7 +112,12 @@ class EuRedact:
|
|
|
112
112
|
engine applies keyword and structural (JSON/CSV header)
|
|
113
113
|
checks before emitting a date detection.
|
|
114
114
|
"""
|
|
115
|
-
# Step 0:
|
|
115
|
+
# Step 0: argument and input-size guards. The country check runs here
|
|
116
|
+
# as well as in the engine so that a bare string is rejected before any
|
|
117
|
+
# work happens, and on every entry point that funnels through redact().
|
|
118
|
+
check_country_arg(countries, "countries")
|
|
119
|
+
check_country_arg(country_hint, "country_hint")
|
|
120
|
+
|
|
116
121
|
if len(text) > self._max_input_length:
|
|
117
122
|
raise ValueError(
|
|
118
123
|
f"Input text length ({len(text):,} chars) exceeds the maximum "
|