fa-redact 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fa_redact/__init__.py +37 -0
- fa_redact/detectors/__init__.py +8 -0
- fa_redact/detectors/mobile.py +62 -0
- fa_redact/detectors/national_id.py +60 -0
- fa_redact/models.py +103 -0
- fa_redact/normalization.py +102 -0
- fa_redact/pipeline.py +61 -0
- fa_redact/protocols.py +33 -0
- fa_redact/pseudonymization.py +166 -0
- fa_redact/py.typed +1 -0
- fa_redact/redaction.py +37 -0
- fa_redact/validators/__init__.py +8 -0
- fa_redact/validators/mobile.py +119 -0
- fa_redact/validators/national_id.py +47 -0
- fa_redact-0.1.0.dist-info/METADATA +245 -0
- fa_redact-0.1.0.dist-info/RECORD +19 -0
- fa_redact-0.1.0.dist-info/WHEEL +5 -0
- fa_redact-0.1.0.dist-info/licenses/LICENSE +21 -0
- fa_redact-0.1.0.dist-info/top_level.txt +1 -0
fa_redact/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""fa-redact: Privacy-first Persian/Iranian PII redaction and pseudonymization toolkit.
|
|
2
|
+
|
|
3
|
+
This package is currently in early development.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from fa_redact.detectors import (
|
|
7
|
+
IranianMobileNumberDetector,
|
|
8
|
+
IranianNationalIDDetector,
|
|
9
|
+
)
|
|
10
|
+
from fa_redact.models import Detection
|
|
11
|
+
from fa_redact.normalization import (
|
|
12
|
+
normalize_digits,
|
|
13
|
+
normalize_letters,
|
|
14
|
+
normalize_text,
|
|
15
|
+
)
|
|
16
|
+
from fa_redact.pipeline import detect
|
|
17
|
+
from fa_redact.protocols import Detector
|
|
18
|
+
from fa_redact.pseudonymization import PseudonymizationSession
|
|
19
|
+
from fa_redact.redaction import redact
|
|
20
|
+
from fa_redact.validators import is_valid_mobile_number, is_valid_national_id
|
|
21
|
+
|
|
22
|
+
__version__: str = "0.1.0"
|
|
23
|
+
__all__: list[str] = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"Detection",
|
|
26
|
+
"Detector",
|
|
27
|
+
"IranianMobileNumberDetector",
|
|
28
|
+
"IranianNationalIDDetector",
|
|
29
|
+
"PseudonymizationSession",
|
|
30
|
+
"detect",
|
|
31
|
+
"is_valid_mobile_number",
|
|
32
|
+
"is_valid_national_id",
|
|
33
|
+
"normalize_digits",
|
|
34
|
+
"normalize_letters",
|
|
35
|
+
"normalize_text",
|
|
36
|
+
"redact",
|
|
37
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Identifier detectors for fa-redact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fa_redact.detectors.mobile import IranianMobileNumberDetector
|
|
6
|
+
from fa_redact.detectors.national_id import IranianNationalIDDetector
|
|
7
|
+
|
|
8
|
+
__all__ = ["IranianMobileNumberDetector", "IranianNationalIDDetector"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Detector for Iranian mobile phone numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from fa_redact.models import Detection
|
|
9
|
+
from fa_redact.validators.mobile import is_valid_mobile_number
|
|
10
|
+
|
|
11
|
+
_ENTITY_TYPE: str = "IR_MOBILE"
|
|
12
|
+
_CANDIDATE_PATTERN: re.Pattern[str] = re.compile(
|
|
13
|
+
r"(?<![0-9+])(?:\+98[0-9]{10}|0098[0-9]{10}|0[0-9]{10})(?![0-9])"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IranianMobileNumberDetector:
|
|
18
|
+
"""Detects prefix-valid Iranian mobile numbers in text.
|
|
19
|
+
|
|
20
|
+
Scans position-preserving normalized text for compact domestic and international
|
|
21
|
+
mobile candidates, validates them against official 2026 CRA NDC mobile prefixes,
|
|
22
|
+
and returns Detection instances preserving both raw and normalized representations.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def detect(
|
|
26
|
+
self,
|
|
27
|
+
original_text: str,
|
|
28
|
+
normalized_text: str,
|
|
29
|
+
) -> Sequence[Detection]:
|
|
30
|
+
"""Detect Iranian mobile numbers across source texts.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
original_text: Raw input text.
|
|
34
|
+
normalized_text: Position-preserving normalized text of identical length.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of detected Detection instances in textual order.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If original_text and normalized_text differ in length.
|
|
41
|
+
"""
|
|
42
|
+
if len(original_text) != len(normalized_text):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"original_text length ({len(original_text)}) must equal "
|
|
45
|
+
f"normalized_text length ({len(normalized_text)})"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
detections: list[Detection] = []
|
|
49
|
+
for match in _CANDIDATE_PATTERN.finditer(normalized_text):
|
|
50
|
+
candidate = match.group(0)
|
|
51
|
+
if is_valid_mobile_number(candidate):
|
|
52
|
+
detections.append(
|
|
53
|
+
Detection.from_texts(
|
|
54
|
+
type=_ENTITY_TYPE,
|
|
55
|
+
original_text=original_text,
|
|
56
|
+
normalized_text=normalized_text,
|
|
57
|
+
start=match.start(),
|
|
58
|
+
end=match.end(),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return detections
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Detector for Iranian individual National IDs (Code Melli)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from fa_redact.models import Detection
|
|
9
|
+
from fa_redact.validators.national_id import is_valid_national_id
|
|
10
|
+
|
|
11
|
+
_ENTITY_TYPE: str = "IR_NATIONAL_ID"
|
|
12
|
+
_CANDIDATE_PATTERN: re.Pattern[str] = re.compile(r"(?<![0-9])[0-9]{10}(?![0-9])")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IranianNationalIDDetector:
|
|
16
|
+
"""Detects checksum-valid Iranian National IDs (Code Melli) in text.
|
|
17
|
+
|
|
18
|
+
Scans position-preserving normalized text for 10-digit candidate sequences,
|
|
19
|
+
validates their modulo-11 checksums, and constructs Detection instances
|
|
20
|
+
preserving both original raw and normalized representations.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def detect(
|
|
24
|
+
self,
|
|
25
|
+
original_text: str,
|
|
26
|
+
normalized_text: str,
|
|
27
|
+
) -> Sequence[Detection]:
|
|
28
|
+
"""Detect Iranian National IDs across source texts.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
original_text: Raw input text.
|
|
32
|
+
normalized_text: Position-preserving normalized text of identical length.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of detected Detection instances in textual order.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If original_text and normalized_text differ in length.
|
|
39
|
+
"""
|
|
40
|
+
if len(original_text) != len(normalized_text):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"original_text length ({len(original_text)}) must equal "
|
|
43
|
+
f"normalized_text length ({len(normalized_text)})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
detections: list[Detection] = []
|
|
47
|
+
for match in _CANDIDATE_PATTERN.finditer(normalized_text):
|
|
48
|
+
candidate = match.group(0)
|
|
49
|
+
if is_valid_national_id(candidate):
|
|
50
|
+
detections.append(
|
|
51
|
+
Detection.from_texts(
|
|
52
|
+
type=_ENTITY_TYPE,
|
|
53
|
+
original_text=original_text,
|
|
54
|
+
normalized_text=normalized_text,
|
|
55
|
+
start=match.start(),
|
|
56
|
+
end=match.end(),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return detections
|
fa_redact/models.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Data models for detected identifiers and PII in fa-redact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Detection:
|
|
10
|
+
"""Represents an immutable detected identifier span in text.
|
|
11
|
+
|
|
12
|
+
Preserves both the original raw string representation and the
|
|
13
|
+
position-preserving normalized representation.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
type: Extensible entity identifier type (e.g., 'IR_NATIONAL_ID', 'PATIENT_ID').
|
|
17
|
+
start: Inclusive start character offset in original text.
|
|
18
|
+
end: Exclusive end character offset in original text.
|
|
19
|
+
value: Exact substring extracted from original text.
|
|
20
|
+
normalized_value: Exact substring extracted from normalized text.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
type: str
|
|
24
|
+
start: int
|
|
25
|
+
end: int
|
|
26
|
+
value: str
|
|
27
|
+
normalized_value: str
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
"""Validate detection field invariants."""
|
|
31
|
+
if not isinstance(self.type, str) or not self.type.strip():
|
|
32
|
+
raise ValueError("type must be a non-empty, non-whitespace string")
|
|
33
|
+
if self.start < 0:
|
|
34
|
+
raise ValueError(f"start must be >= 0, got {self.start}")
|
|
35
|
+
if self.end <= self.start:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"end ({self.end}) must be strictly greater than start ({self.start})"
|
|
38
|
+
)
|
|
39
|
+
expected_len = self.end - self.start
|
|
40
|
+
if len(self.value) != expected_len:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"len(value) ({len(self.value)}) does not match "
|
|
43
|
+
f"span length ({expected_len})"
|
|
44
|
+
)
|
|
45
|
+
if len(self.normalized_value) != expected_len:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"len(normalized_value) ({len(self.normalized_value)}) "
|
|
48
|
+
f"does not match span length ({expected_len})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_texts(
|
|
53
|
+
cls,
|
|
54
|
+
*,
|
|
55
|
+
type: str,
|
|
56
|
+
original_text: str,
|
|
57
|
+
normalized_text: str,
|
|
58
|
+
start: int,
|
|
59
|
+
end: int,
|
|
60
|
+
) -> Detection:
|
|
61
|
+
"""Safely construct a Detection by slicing source texts.
|
|
62
|
+
|
|
63
|
+
Extracts `value` from `original_text[start:end]` and `normalized_value`
|
|
64
|
+
from `normalized_text[start:end]`.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
type: Extensible entity identifier type.
|
|
68
|
+
original_text: Raw input text.
|
|
69
|
+
normalized_text: Position-preserving normalized text of equal length.
|
|
70
|
+
start: Inclusive start character index.
|
|
71
|
+
end: Exclusive end character index.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Immutable Detection with slices from original and normalized texts.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If texts differ in length, offsets are invalid, or
|
|
78
|
+
offsets fall outside string boundaries.
|
|
79
|
+
"""
|
|
80
|
+
if len(original_text) != len(normalized_text):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"original_text length ({len(original_text)}) must equal "
|
|
83
|
+
f"normalized_text length ({len(normalized_text)})"
|
|
84
|
+
)
|
|
85
|
+
if start < 0 or end > len(original_text):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Offsets [{start}:{end}] are out of bounds [0:{len(original_text)}]"
|
|
88
|
+
)
|
|
89
|
+
if end <= start:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"end ({end}) must be strictly greater than start ({start})"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
value = original_text[start:end]
|
|
95
|
+
normalized_value = normalized_text[start:end]
|
|
96
|
+
|
|
97
|
+
return cls(
|
|
98
|
+
type=type,
|
|
99
|
+
start=start,
|
|
100
|
+
end=end,
|
|
101
|
+
value=value,
|
|
102
|
+
normalized_value=normalized_value,
|
|
103
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Position-preserving Persian and Arabic-Indic text normalization.
|
|
2
|
+
|
|
3
|
+
This module provides deterministic, 1-to-1 Unicode code point transformations
|
|
4
|
+
designed to normalize digits and common Arabic letter variants without altering
|
|
5
|
+
character positions or string length.
|
|
6
|
+
|
|
7
|
+
Architectural Invariant:
|
|
8
|
+
For every string `s`, the following holds:
|
|
9
|
+
len(normalize_digits(s)) == len(s)
|
|
10
|
+
len(normalize_letters(s)) == len(s)
|
|
11
|
+
len(normalize_text(s)) == len(s)
|
|
12
|
+
|
|
13
|
+
This invariant ensures that character offsets (start/end indices) computed on
|
|
14
|
+
normalized text map 1-to-1 to the original input text.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
# Mapping Persian (Extended Arabic-Indic) digits and Arabic-Indic digits to ASCII digits
|
|
20
|
+
_PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
|
|
21
|
+
_ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
|
|
22
|
+
_ASCII_DIGITS = "0123456789"
|
|
23
|
+
|
|
24
|
+
_DIGIT_TRANSLATION_TABLE: dict[int, int] = str.maketrans(
|
|
25
|
+
_PERSIAN_DIGITS + _ARABIC_INDIC_DIGITS,
|
|
26
|
+
_ASCII_DIGITS + _ASCII_DIGITS,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Mapping Arabic letter variants to Persian standard code points
|
|
30
|
+
# ي (U+064A) -> ی (U+06CC)
|
|
31
|
+
# ك (U+0643) -> ک (U+06A9)
|
|
32
|
+
_ARABIC_LETTERS = "يك"
|
|
33
|
+
_PERSIAN_LETTERS = "یک"
|
|
34
|
+
|
|
35
|
+
_LETTER_TRANSLATION_TABLE: dict[int, int] = str.maketrans(
|
|
36
|
+
_ARABIC_LETTERS,
|
|
37
|
+
_PERSIAN_LETTERS,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Combined translation table for all supported 1-to-1 normalizations
|
|
41
|
+
_ALL_TRANSLATION_TABLE: dict[int, int] = {
|
|
42
|
+
**_DIGIT_TRANSLATION_TABLE,
|
|
43
|
+
**_LETTER_TRANSLATION_TABLE,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_digits(text: str) -> str:
|
|
48
|
+
"""Normalize Persian and Arabic-Indic digits to ASCII digits.
|
|
49
|
+
|
|
50
|
+
Transforms:
|
|
51
|
+
۰-۹ (U+06F0..U+06F9) -> 0-9 (U+0030..U+0039)
|
|
52
|
+
٠-٩ (U+0660..U+0669) -> 0-9 (U+0030..U+0039)
|
|
53
|
+
|
|
54
|
+
Guarantees:
|
|
55
|
+
len(normalize_digits(text)) == len(text)
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
text: Input string.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
String with all Persian and Arabic-Indic digits replaced by ASCII digits.
|
|
62
|
+
"""
|
|
63
|
+
return text.translate(_DIGIT_TRANSLATION_TABLE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_letters(text: str) -> str:
|
|
67
|
+
"""Normalize common Arabic letter variants to Persian code points.
|
|
68
|
+
|
|
69
|
+
Transforms only:
|
|
70
|
+
ي (U+064A ARABIC LETTER YEH) -> ی (U+06CC ARABIC LETTER FARSI YEH)
|
|
71
|
+
ك (U+0643 ARABIC LETTER KAF) -> ک (U+06A9 ARABIC LETTER KEHEH)
|
|
72
|
+
|
|
73
|
+
Other Arabic characters (such as ى, ة, ۀ, ؤ, إ, أ) remain unchanged.
|
|
74
|
+
|
|
75
|
+
Guarantees:
|
|
76
|
+
len(normalize_letters(text)) == len(text)
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
text: Input string.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
String with supported Arabic letter variants replaced by Persian equivalents.
|
|
83
|
+
"""
|
|
84
|
+
return text.translate(_LETTER_TRANSLATION_TABLE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_text(text: str) -> str:
|
|
88
|
+
"""Apply position-preserving normalization (digits and supported letters).
|
|
89
|
+
|
|
90
|
+
Combines digit normalization (Persian and Arabic-Indic -> ASCII) and
|
|
91
|
+
letter normalization (ي -> ی, ك -> ک).
|
|
92
|
+
|
|
93
|
+
Guarantees:
|
|
94
|
+
len(normalize_text(text)) == len(text)
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
text: Input string.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Normalized string with identical length and preserved character offsets.
|
|
101
|
+
"""
|
|
102
|
+
return text.translate(_ALL_TRANSLATION_TABLE)
|
fa_redact/pipeline.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""High-level detection pipeline for fa-redact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from fa_redact.detectors.mobile import IranianMobileNumberDetector
|
|
8
|
+
from fa_redact.detectors.national_id import IranianNationalIDDetector
|
|
9
|
+
from fa_redact.models import Detection
|
|
10
|
+
from fa_redact.normalization import normalize_text
|
|
11
|
+
from fa_redact.protocols import Detector
|
|
12
|
+
|
|
13
|
+
_DEFAULT_DETECTORS: tuple[Detector, ...] = (
|
|
14
|
+
IranianNationalIDDetector(),
|
|
15
|
+
IranianMobileNumberDetector(),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect(
|
|
20
|
+
text: str,
|
|
21
|
+
*,
|
|
22
|
+
detectors: Sequence[Detector] | None = None,
|
|
23
|
+
) -> list[Detection]:
|
|
24
|
+
"""Detect Iranian PII entities in text using built-in or custom detectors.
|
|
25
|
+
|
|
26
|
+
Applies position-preserving normalization at the pipeline boundary and runs
|
|
27
|
+
each configured detector, returning detected entity spans sorted deterministically
|
|
28
|
+
by character offset.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
text: Input string to scan for PII.
|
|
32
|
+
detectors: Sequence of Detector instances to execute. If None (default),
|
|
33
|
+
uses all built-in detectors (IranianNationalIDDetector,
|
|
34
|
+
IranianMobileNumberDetector). If an explicit empty sequence (e.g. `[]`),
|
|
35
|
+
no detectors are executed and an empty list is returned.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of Detection instances sorted by `(start, end, type)`.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
TypeError: If `text` is not a string.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(text, str):
|
|
44
|
+
raise TypeError(f"text must be a str, got {type(text).__name__}")
|
|
45
|
+
|
|
46
|
+
if detectors is None:
|
|
47
|
+
active_detectors: Sequence[Detector] = _DEFAULT_DETECTORS
|
|
48
|
+
else:
|
|
49
|
+
active_detectors = detectors
|
|
50
|
+
|
|
51
|
+
if not active_detectors:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
normalized_text = normalize_text(text)
|
|
55
|
+
detections: list[Detection] = []
|
|
56
|
+
|
|
57
|
+
for detector in active_detectors:
|
|
58
|
+
detections.extend(detector.detect(text, normalized_text))
|
|
59
|
+
|
|
60
|
+
detections.sort(key=lambda d: (d.start, d.end, d.type))
|
|
61
|
+
return detections
|
fa_redact/protocols.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Protocols defining structural contracts for fa-redact components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from fa_redact.models import Detection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Detector(Protocol):
|
|
12
|
+
"""Structural interface for identifier detectors.
|
|
13
|
+
|
|
14
|
+
Detectors receive both the raw original text and the position-preserving
|
|
15
|
+
normalized text (guaranteed to have the exact same length), and return a
|
|
16
|
+
sequence of detected entity spans whose offsets map identically onto both.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def detect(
|
|
20
|
+
self,
|
|
21
|
+
original_text: str,
|
|
22
|
+
normalized_text: str,
|
|
23
|
+
) -> Sequence[Detection]:
|
|
24
|
+
"""Detect identifier spans across source texts.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
original_text: Original, unmodified input text.
|
|
28
|
+
normalized_text: Position-preserving normalized text of identical length.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Sequence of detected Detection instances.
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Stateful pseudonymization sessions with stable mappings and safe restoration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from fa_redact.pipeline import detect
|
|
9
|
+
from fa_redact.protocols import Detector
|
|
10
|
+
|
|
11
|
+
_PLACEHOLDER_PATTERN = re.compile(r"\[[^\[\]\r\n]+_[1-9][0-9]*\]")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PseudonymizationSession:
|
|
15
|
+
"""Stateful pseudonymization session for Iranian PII.
|
|
16
|
+
|
|
17
|
+
Maintains a local-only mapping between detected PII identities and stable,
|
|
18
|
+
typed placeholders across multiple messages or conversation turns. Allows
|
|
19
|
+
safe restoration of placeholders in downstream AI/LLM responses.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._identity_to_placeholder: dict[tuple[str, str], str] = {}
|
|
24
|
+
self._placeholder_to_value: dict[str, str] = {}
|
|
25
|
+
self._counters_by_type: dict[str, int] = {}
|
|
26
|
+
self._reserved_placeholders: set[str] = set()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def mapping(self) -> dict[str, str]:
|
|
30
|
+
"""Return a shallow copy of the placeholder-to-value mapping.
|
|
31
|
+
|
|
32
|
+
The returned mapping contains original sensitive PII values and must
|
|
33
|
+
be treated as sensitive data.
|
|
34
|
+
"""
|
|
35
|
+
return self._placeholder_to_value.copy()
|
|
36
|
+
|
|
37
|
+
def pseudonymize(
|
|
38
|
+
self,
|
|
39
|
+
text: str,
|
|
40
|
+
*,
|
|
41
|
+
detectors: Sequence[Detector] | None = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Pseudonymize detected PII in text using stable, typed placeholders.
|
|
44
|
+
|
|
45
|
+
Identities are tracked across calls on this session. If an identifier
|
|
46
|
+
with the same entity type and normalized value was previously observed,
|
|
47
|
+
its existing placeholder is reused. New identifiers receive subsequent
|
|
48
|
+
per-type indices and record their first-observed raw representation for
|
|
49
|
+
future restoration.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: Input string to pseudonymize.
|
|
53
|
+
detectors: Optional sequence of Detector instances to execute. If None,
|
|
54
|
+
uses default built-in detectors. If `[]`, no detectors run.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The pseudonymized string with detected spans replaced by placeholders.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
TypeError: If `text` is not a string.
|
|
61
|
+
ValueError: If `text` contains a placeholder already assigned by this
|
|
62
|
+
session, or if detected spans overlap.
|
|
63
|
+
"""
|
|
64
|
+
if not isinstance(text, str):
|
|
65
|
+
raise TypeError(f"text must be a str, got {type(text).__name__}")
|
|
66
|
+
|
|
67
|
+
# Extract placeholder-shaped literal tokens from input
|
|
68
|
+
literal_tokens = set(_PLACEHOLDER_PATTERN.findall(text))
|
|
69
|
+
|
|
70
|
+
# Check for conflicts with existing mapped placeholders in this session
|
|
71
|
+
assigned_keys = set(self._placeholder_to_value.keys())
|
|
72
|
+
conflicts = sorted(literal_tokens & assigned_keys)
|
|
73
|
+
if conflicts:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Input contains a placeholder already assigned by this "
|
|
76
|
+
f"session: {conflicts[0]}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Work on isolated copies to guarantee atomic updates
|
|
80
|
+
temp_reserved_placeholders = self._reserved_placeholders.copy()
|
|
81
|
+
temp_reserved_placeholders.update(literal_tokens)
|
|
82
|
+
temp_identity_to_placeholder = self._identity_to_placeholder.copy()
|
|
83
|
+
temp_placeholder_to_value = self._placeholder_to_value.copy()
|
|
84
|
+
temp_counters_by_type = self._counters_by_type.copy()
|
|
85
|
+
assigned_placeholders = assigned_keys.copy()
|
|
86
|
+
|
|
87
|
+
detections = detect(text, detectors=detectors)
|
|
88
|
+
if not detections:
|
|
89
|
+
# Commit reserved literals even when no PII is detected
|
|
90
|
+
self._reserved_placeholders = temp_reserved_placeholders
|
|
91
|
+
return text
|
|
92
|
+
|
|
93
|
+
# Validate that detections do not overlap, nest, or duplicate
|
|
94
|
+
for i in range(1, len(detections)):
|
|
95
|
+
prev = detections[i - 1]
|
|
96
|
+
curr = detections[i]
|
|
97
|
+
if curr.start < prev.end:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Overlapping detections at spans [{prev.start}:{prev.end}] "
|
|
100
|
+
f"({prev.type}) and [{curr.start}:{curr.end}] ({curr.type})"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
pieces: list[str] = []
|
|
104
|
+
cursor = 0
|
|
105
|
+
|
|
106
|
+
for d in detections:
|
|
107
|
+
identity = (d.type, d.normalized_value)
|
|
108
|
+
placeholder: str | None = temp_identity_to_placeholder.get(identity)
|
|
109
|
+
if placeholder is None:
|
|
110
|
+
counter = temp_counters_by_type.get(d.type, 0)
|
|
111
|
+
while True:
|
|
112
|
+
counter += 1
|
|
113
|
+
candidate = f"[{d.type}_{counter}]"
|
|
114
|
+
if (
|
|
115
|
+
candidate not in text
|
|
116
|
+
and candidate not in assigned_placeholders
|
|
117
|
+
and candidate not in temp_reserved_placeholders
|
|
118
|
+
):
|
|
119
|
+
placeholder = candidate
|
|
120
|
+
break
|
|
121
|
+
temp_counters_by_type[d.type] = counter
|
|
122
|
+
temp_identity_to_placeholder[identity] = placeholder
|
|
123
|
+
# Record first observed raw representation for restoration:
|
|
124
|
+
temp_placeholder_to_value[placeholder] = d.value
|
|
125
|
+
assigned_placeholders.add(placeholder)
|
|
126
|
+
|
|
127
|
+
assert placeholder is not None
|
|
128
|
+
pieces.append(text[cursor : d.start])
|
|
129
|
+
pieces.append(placeholder)
|
|
130
|
+
cursor = d.end
|
|
131
|
+
|
|
132
|
+
pieces.append(text[cursor:])
|
|
133
|
+
result = "".join(pieces)
|
|
134
|
+
|
|
135
|
+
# Commit state atomically
|
|
136
|
+
self._reserved_placeholders = temp_reserved_placeholders
|
|
137
|
+
self._identity_to_placeholder = temp_identity_to_placeholder
|
|
138
|
+
self._placeholder_to_value = temp_placeholder_to_value
|
|
139
|
+
self._counters_by_type = temp_counters_by_type
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def restore(self, text: str) -> str:
|
|
143
|
+
"""Restore mapped placeholders in text to their original raw values.
|
|
144
|
+
|
|
145
|
+
Performs a single-pass regex replacement of exact known placeholders to
|
|
146
|
+
prevent cascading restoration. Unrecognized placeholders remain untouched.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
text: Input string (e.g. LLM response) containing placeholders.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The restored string with known placeholders replaced.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
TypeError: If `text` is not a string.
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(text, str):
|
|
158
|
+
raise TypeError(f"text must be a str, got {type(text).__name__}")
|
|
159
|
+
|
|
160
|
+
if not self._placeholder_to_value or not text:
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
# Sort descending by length for defensive matching
|
|
164
|
+
patterns = sorted(self._placeholder_to_value.keys(), key=len, reverse=True)
|
|
165
|
+
pattern = re.compile("|".join(re.escape(p) for p in patterns))
|
|
166
|
+
return pattern.sub(lambda m: self._placeholder_to_value[m.group(0)], text)
|
fa_redact/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561 (inline type annotations).
|
fa_redact/redaction.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Safe placeholder-based redaction for fa-redact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from fa_redact.protocols import Detector
|
|
8
|
+
from fa_redact.pseudonymization import PseudonymizationSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def redact(
|
|
12
|
+
text: str,
|
|
13
|
+
*,
|
|
14
|
+
detectors: Sequence[Detector] | None = None,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Redact detected Iranian PII using typed, referentially-consistent placeholders.
|
|
17
|
+
|
|
18
|
+
Runs the detection pipeline and replaces identified spans with deterministic,
|
|
19
|
+
typed placeholders (e.g. `[IR_NATIONAL_ID_1]`, `[IR_MOBILE_1]`). Within a single
|
|
20
|
+
call, repeated occurrences of the same identifier (matching entity type and
|
|
21
|
+
normalized value) receive the same placeholder.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
text: Input string to redact.
|
|
25
|
+
detectors: Optional sequence of Detector instances to execute. If None,
|
|
26
|
+
uses all default built-in detectors. If an explicit empty sequence
|
|
27
|
+
(e.g. `[]`), no detectors run and the original text is returned unchanged.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The redacted string with detected spans replaced by placeholders.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
TypeError: If `text` is not a string.
|
|
34
|
+
ValueError: If any detected spans overlap, are nested, or are exact duplicates.
|
|
35
|
+
"""
|
|
36
|
+
session = PseudonymizationSession()
|
|
37
|
+
return session.pseudonymize(text, detectors=detectors)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Validation functions for Iranian identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fa_redact.validators.mobile import is_valid_mobile_number
|
|
6
|
+
from fa_redact.validators.national_id import is_valid_national_id
|
|
7
|
+
|
|
8
|
+
__all__ = ["is_valid_mobile_number", "is_valid_national_id"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Iranian Mobile Number validation based on the 2026 CRA numbering plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fa_redact.normalization import normalize_digits
|
|
6
|
+
|
|
7
|
+
# Official Mobile services NDC prefixes from the 2026 CRA/ITU-T Numbering Plan snapshot.
|
|
8
|
+
# Ranges such as 94... (fixed/non-geographical) and 9950... (public trunk) are excluded.
|
|
9
|
+
_MOBILE_NDC_PREFIXES: tuple[str, ...] = (
|
|
10
|
+
"900",
|
|
11
|
+
"901",
|
|
12
|
+
"902",
|
|
13
|
+
"903",
|
|
14
|
+
"904",
|
|
15
|
+
"905",
|
|
16
|
+
"91",
|
|
17
|
+
"920",
|
|
18
|
+
"921",
|
|
19
|
+
"922",
|
|
20
|
+
"923",
|
|
21
|
+
"93",
|
|
22
|
+
"990",
|
|
23
|
+
"991",
|
|
24
|
+
"992",
|
|
25
|
+
"993",
|
|
26
|
+
"994",
|
|
27
|
+
"99510",
|
|
28
|
+
"99550",
|
|
29
|
+
"996",
|
|
30
|
+
"9981",
|
|
31
|
+
"9982",
|
|
32
|
+
"99830",
|
|
33
|
+
"99831",
|
|
34
|
+
"99832",
|
|
35
|
+
"99888",
|
|
36
|
+
"99900",
|
|
37
|
+
"99901",
|
|
38
|
+
"99902",
|
|
39
|
+
"99903",
|
|
40
|
+
"9991",
|
|
41
|
+
"99921",
|
|
42
|
+
"99930",
|
|
43
|
+
"99931",
|
|
44
|
+
"99932",
|
|
45
|
+
"99933",
|
|
46
|
+
"99934",
|
|
47
|
+
"9995",
|
|
48
|
+
"99969",
|
|
49
|
+
"99977",
|
|
50
|
+
"9998",
|
|
51
|
+
"9999",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_nsn(normalized: str) -> str | None:
|
|
56
|
+
"""Extract the 10-digit NSN from compact phone strings.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
normalized: Digit-normalized input string.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
10-digit NSN string starting with '9', or None if the format is invalid.
|
|
63
|
+
"""
|
|
64
|
+
if normalized.startswith("+989") and len(normalized) == 13:
|
|
65
|
+
nsn = normalized[3:]
|
|
66
|
+
if nsn.isascii() and nsn.isdigit():
|
|
67
|
+
return nsn
|
|
68
|
+
elif normalized.startswith("00989") and len(normalized) == 14:
|
|
69
|
+
nsn = normalized[4:]
|
|
70
|
+
if nsn.isascii() and nsn.isdigit():
|
|
71
|
+
return nsn
|
|
72
|
+
elif (
|
|
73
|
+
normalized.startswith("09")
|
|
74
|
+
and not normalized.startswith("00")
|
|
75
|
+
and len(normalized) == 11
|
|
76
|
+
):
|
|
77
|
+
nsn = normalized[1:]
|
|
78
|
+
if nsn.isascii() and nsn.isdigit():
|
|
79
|
+
return nsn
|
|
80
|
+
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_valid_mobile_number(value: str) -> bool:
|
|
85
|
+
"""Validate whether a string is a valid Iranian mobile phone number.
|
|
86
|
+
|
|
87
|
+
Accepts compact representations using ASCII digits, Persian digits (۰-۹),
|
|
88
|
+
Arabic-Indic digits (٠-٩), or mixed sets:
|
|
89
|
+
- Domestic: '09xxxxxxxxx' (11 digits)
|
|
90
|
+
- International plus: '+989xxxxxxxxx' (13 characters)
|
|
91
|
+
- International dial prefix: '00989xxxxxxxxx' (14 characters)
|
|
92
|
+
|
|
93
|
+
Validation rules:
|
|
94
|
+
- Requires an exact compact representation (no spaces, hyphens, or separators).
|
|
95
|
+
- Extracts the 10-digit National Significant Number (NSN).
|
|
96
|
+
- Validates the NSN against the official 2026 CRA Mobile services NDC prefixes.
|
|
97
|
+
- Rejects fixed non-geographical numbers (e.g. 094...), public trunk ranges,
|
|
98
|
+
and unlisted prefix allocations.
|
|
99
|
+
|
|
100
|
+
Note:
|
|
101
|
+
Prefix validation verifies structural allocation according to the bundled
|
|
102
|
+
2026 CRA National Numbering Plan snapshot. It does not verify subscriber
|
|
103
|
+
ownership, active SIM status, or carrier identity.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
value: Candidate mobile number string.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the candidate represents a valid mobile number format; False otherwise.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(value, str):
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
normalized = normalize_digits(value)
|
|
115
|
+
nsn = _extract_nsn(normalized)
|
|
116
|
+
if nsn is None:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
return any(nsn.startswith(prefix) for prefix in _MOBILE_NDC_PREFIXES)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Iranian National ID (Code Melli) validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fa_redact.normalization import normalize_digits
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_valid_national_id(value: str) -> bool:
|
|
9
|
+
"""Validate the checksum structure of an Iranian individual National ID.
|
|
10
|
+
|
|
11
|
+
Accepts 10-character strings consisting of ASCII digits, Persian digits (۰-۹),
|
|
12
|
+
Arabic-Indic digits (٠-٩), or mixed representations thereof.
|
|
13
|
+
|
|
14
|
+
Strict validation policy:
|
|
15
|
+
- Exact length of 10 characters required.
|
|
16
|
+
- No whitespace, separators, hyphens, or non-digit characters allowed.
|
|
17
|
+
- No 8/9-digit zero-padding performed.
|
|
18
|
+
- Rejects all identical/repeated digit patterns ('0000000000'..'9999999999').
|
|
19
|
+
- Validates the standard modulo-11 weighted check digit.
|
|
20
|
+
|
|
21
|
+
Note:
|
|
22
|
+
Checksum validity confirms structural mathematical integrity only. It does NOT
|
|
23
|
+
verify whether the ID has actually been issued or belongs to a real individual.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
value: Candidate national ID string.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
True if the candidate has a valid 10-digit format and correct check digit;
|
|
30
|
+
False otherwise.
|
|
31
|
+
"""
|
|
32
|
+
if not isinstance(value, str) or len(value) != 10:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
normalized = normalize_digits(value)
|
|
36
|
+
if not (normalized.isascii() and normalized.isdigit()):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
# Reject repeated-digit pseudo-values (0000000000, 1111111111, ..., 9999999999)
|
|
40
|
+
if len(set(normalized)) == 1:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
total = sum(int(normalized[i]) * (10 - i) for i in range(9))
|
|
44
|
+
remainder = total % 11
|
|
45
|
+
expected_check_digit = remainder if remainder < 2 else 11 - remainder
|
|
46
|
+
|
|
47
|
+
return int(normalized[9]) == expected_check_digit
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fa-redact
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A privacy-first Python toolkit for Persian/Iranian PII redaction and pseudonymization, designed especially for healthcare and AI applications.
|
|
5
|
+
Author: fa-redact contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mehdimt1980/fa-redact
|
|
8
|
+
Project-URL: Repository, https://github.com/mehdimt1980/fa-redact
|
|
9
|
+
Project-URL: Issues, https://github.com/mehdimt1980/fa-redact/issues
|
|
10
|
+
Keywords: persian,farsi,pii,redaction,anonymization,pseudonymization,privacy,healthcare
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.9.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# fa-redact
|
|
36
|
+
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
`fa-redact` is a lightweight, privacy-first Python toolkit for Persian/Iranian Personally Identifiable Information (PII) detection, redaction, and pseudonymization, designed especially for healthcare and AI/LLM applications.
|
|
41
|
+
|
|
42
|
+
> **Status: v0.1.0 Release Candidate (Alpha)**
|
|
43
|
+
> This package provides position-preserving Persian text normalization, immutable `Detection` data models, strict Iranian National ID (Code Melli) and Mobile Number validators and detectors, high-level `detect()` orchestration, stateless placeholder-based `redact()`, and stateful `PseudonymizationSession` with safe restoration for AI/LLM workflows.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Detect PII
|
|
50
|
+
Identify sensitive spans with exact source offsets and normalized representations:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from fa_redact import detect
|
|
54
|
+
|
|
55
|
+
text = "بیمار با کد ملی ۱۲۳۴۵۶۷۸۹۱ و شماره ۰۹۱۲۳۴۵۶۷۸۹ مراجعه کرد."
|
|
56
|
+
detections = detect(text)
|
|
57
|
+
|
|
58
|
+
for d in detections:
|
|
59
|
+
print(f"Type: {d.type} | Value: {d.value} | Span: [{d.start}:{d.end}]")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2. Redact PII (Stateless)
|
|
63
|
+
Sanitize text into safe, typed placeholders with fresh counter numbering:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from fa_redact import redact
|
|
67
|
+
|
|
68
|
+
text = "کد ملی: ۱۲۳۴۵۶۷۸۹۱، تماس: ۰۹۱۲۳۴۵۶۷۸۹، تماس دوم: 09123456789"
|
|
69
|
+
safe_text = redact(text)
|
|
70
|
+
print(safe_text)
|
|
71
|
+
# Output: "کد ملی: [IR_NATIONAL_ID_1]، تماس: [IR_MOBILE_1]، تماس دوم: [IR_MOBILE_1]"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Stateful Pseudonymization & AI/LLM Restoration
|
|
75
|
+
Maintain consistent entity mappings across conversation turns and restore placeholders locally:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fa_redact import PseudonymizationSession
|
|
79
|
+
|
|
80
|
+
session = PseudonymizationSession()
|
|
81
|
+
|
|
82
|
+
# 1. Pseudonymize prompt locally before sending to external LLM:
|
|
83
|
+
prompt = "کد ملی بیمار ۱۲۳۴۵۶۷۸۹۱ و شماره تماس ۰۹۱۲۳۴۵۶۷۸۹ است."
|
|
84
|
+
safe_prompt = session.pseudonymize(prompt)
|
|
85
|
+
print(safe_prompt)
|
|
86
|
+
# Output: "کد ملی بیمار [IR_NATIONAL_ID_1] و شماره تماس [IR_MOBILE_1] است."
|
|
87
|
+
|
|
88
|
+
# 2. Send ONLY safe_prompt to external LLM. Simulated LLM response:
|
|
89
|
+
llm_response = "جهت پیگیری بیمار با [IR_MOBILE_1] هماهنگ شد."
|
|
90
|
+
|
|
91
|
+
# 3. Restore placeholders locally within your trusted boundary:
|
|
92
|
+
restored = session.restore(llm_response)
|
|
93
|
+
print(restored)
|
|
94
|
+
# Output: "جهت پیگیری بیمار با ۰۹۱۲۳۴۵۶۷۸۹ هماهنگ شد."
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Installation
|
|
100
|
+
|
|
101
|
+
### Future PyPI Release
|
|
102
|
+
Once v0.1.0 is published to PyPI:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install fa-redact
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Development Installation
|
|
109
|
+
For development or installing from source:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git clone https://github.com/mehdimt1980/fa-redact.git
|
|
113
|
+
cd fa-redact
|
|
114
|
+
pip install -e ".[dev]"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Detailed Capabilities
|
|
120
|
+
|
|
121
|
+
### 1. Stateful Pseudonymization Sessions
|
|
122
|
+
The `PseudonymizationSession` class manages state across multi-turn workflows:
|
|
123
|
+
- **Local Sensitive Mapping**: `session.mapping` holds `{placeholder: original_pii}`. Keep this mapping strictly inside your local trusted environment; never transmit it to external AI services.
|
|
124
|
+
- **First-Observed Representative Restoration**: For each unique identity `(type, normalized_value)`, the session records the first-observed raw string as its semantic restoration target.
|
|
125
|
+
- **Non-Cascading Single-Pass Restoration**: `restore()` performs an escaped single-pass substitution, preventing recursive evaluation if restored values contain placeholder-like text.
|
|
126
|
+
- **Cross-Call Collision Safety**: Generated placeholders automatically avoid colliding with literal placeholder-shaped tokens seen in current or previous inputs within the session.
|
|
127
|
+
- **Unknown Placeholders**: Unmapped placeholders (e.g., `[IR_MOBILE_999]`) are left untouched without error.
|
|
128
|
+
|
|
129
|
+
> [!WARNING]
|
|
130
|
+
> **Sensitive Data Notice**: `session.mapping` contains original PII. Treat it as sensitive data and protect it accordingly.
|
|
131
|
+
>
|
|
132
|
+
> **Scope Limitation**: `fa-redact` detects and redacts only the PII types supported by its enabled detectors (Iranian National IDs and Iranian Mobile Numbers in v0.1.0). It does not provide complete automated clinical de-identification.
|
|
133
|
+
|
|
134
|
+
### 2. Position-Preserving Normalization
|
|
135
|
+
`fa-redact` provides pure, deterministic normalization where each Unicode character maps 1-to-1 to a normalized code point (`len(normalized) == len(original)`), guaranteeing that character offsets remain identical to the original input text:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from fa_redact import normalize_digits, normalize_letters, normalize_text
|
|
139
|
+
|
|
140
|
+
# Normalizes Persian (۰-۹) and Arabic-Indic (٠-٩) digits to ASCII (0-9)
|
|
141
|
+
normalize_digits("کد بیمار: ۱۲۳٤٥")
|
|
142
|
+
# Returns: "کد بیمار: 12345"
|
|
143
|
+
|
|
144
|
+
# Normalizes Arabic letter variants (ي -> ی, ك -> ک)
|
|
145
|
+
normalize_letters("پزشك و دكتر")
|
|
146
|
+
# Returns: "پزشک و دکتر"
|
|
147
|
+
|
|
148
|
+
# Full position-preserving normalization
|
|
149
|
+
normalize_text("كد ملي: ۰۰۱٢٣٤٥٦٧٨")
|
|
150
|
+
# Returns: "کد ملی: 0012345678"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 3. Detection Data Model & Pipeline
|
|
154
|
+
The immutable `Detection` dataclass represents identified spans:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from fa_redact import Detection, IranianNationalIDDetector, detect, normalize_text
|
|
158
|
+
|
|
159
|
+
# Using custom detector list:
|
|
160
|
+
detections = detect("کد ملی: ۱۲۳۴۵۶۷۸۹۱", detectors=[IranianNationalIDDetector()])
|
|
161
|
+
for d in detections:
|
|
162
|
+
assert d.type == "IR_NATIONAL_ID"
|
|
163
|
+
assert d.value == "۱۲۳۴۵۶۷۸۹۱"
|
|
164
|
+
assert d.normalized_value == "1234567891"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### 4. Iranian National ID Validation & Detection
|
|
168
|
+
Validate and detect Iranian National IDs (Code Melli / `کد ملی`) with strict modulo-11 checksum verification:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from fa_redact import IranianNationalIDDetector, is_valid_national_id
|
|
172
|
+
|
|
173
|
+
# Algorithmic test vectors not sourced from personal data
|
|
174
|
+
is_valid_national_id("1234567891") # True
|
|
175
|
+
is_valid_national_id("۱۲۳۴۵۶۷۸۹۱") # True (Persian digits)
|
|
176
|
+
is_valid_national_id("1234567890") # False (invalid check digit)
|
|
177
|
+
is_valid_national_id("1111111111") # False (repeated digits rejected)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
> **Verification Notice**: Checksum validation verifies mathematical structure only without querying official registries. These values are algorithmic test vectors not sourced from personal or patient records. Checksum validity does not establish whether an identifier has been officially issued to an individual.
|
|
181
|
+
|
|
182
|
+
### 5. Iranian Mobile Number Validation & Detection
|
|
183
|
+
Validate and detect Iranian mobile numbers against official Communications Regulatory Authority (CRA) mobile NDC prefixes:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from fa_redact import IranianMobileNumberDetector, is_valid_mobile_number
|
|
187
|
+
|
|
188
|
+
# Domestic, +98 international, and 0098 international formats
|
|
189
|
+
is_valid_mobile_number("09123456789") # True (domestic)
|
|
190
|
+
is_valid_mobile_number("۰۹۱۲۳۴۵۶۷۸۹") # True (Persian digits)
|
|
191
|
+
is_valid_mobile_number("+989123456789") # True (+98 format)
|
|
192
|
+
is_valid_mobile_number("00989351234567") # True (0098 format)
|
|
193
|
+
is_valid_mobile_number("09412345678") # False (fixed non-geographical)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> **Numbering Plan Notice**: Prefix classification is based on the official Communications Regulatory Authority (CRA) National Numbering Plan (published via ITU Operational Bulletin No. 1340). Prefix validation confirms structural allocation only and does not verify subscriber ownership, active SIM status, or carrier identity.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Privacy-Safe Test-Data Policy
|
|
201
|
+
|
|
202
|
+
All test fixtures, examples, and documentation in `fa-redact` are constructed from **synthetic test vectors, algorithmic patterns, and non-personal sample data**. No real patient records, clinical charts, credentials, or personal datasets are used or included in the repository.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Important Disclaimers
|
|
207
|
+
|
|
208
|
+
- **Not Production Clinical Software**: `fa-redact` is an experimental, early-stage open-source library and is **not** certified as a medical device or approved for production clinical decision-making.
|
|
209
|
+
- **No Inherent Regulatory Compliance**: Use of this library does not automatically ensure compliance with HIPAA, GDPR, or local privacy regulations. Organizations remain responsible for verifying that their data pipelines meet applicable legal and privacy standards.
|
|
210
|
+
- **No Identity Verification**: Validation functions verify format and mathematical structure only; they do not query government registries or authenticate individuals.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Development & Quality Checks
|
|
215
|
+
|
|
216
|
+
Run the automated test suite:
|
|
217
|
+
```bash
|
|
218
|
+
python -m pytest
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Check code formatting and linting:
|
|
222
|
+
```bash
|
|
223
|
+
ruff check .
|
|
224
|
+
ruff format --check .
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Run static type checking:
|
|
228
|
+
```bash
|
|
229
|
+
mypy src
|
|
230
|
+
mypy tests
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Build and validate distribution packages:
|
|
234
|
+
```bash
|
|
235
|
+
python -m build
|
|
236
|
+
python -m twine check dist/*
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
For release procedures and PyPI publishing setup, see [RELEASING.md](RELEASING.md).
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
fa_redact/__init__.py,sha256=sPYQpisDvMnmtkgOLxHzONoKfuZSOkdjcS1lJiSo-8Q,1006
|
|
2
|
+
fa_redact/models.py,sha256=WB7EDndMQhLoVYU84unRkY4IgIvZaREJRrHEaAT9iOE,3588
|
|
3
|
+
fa_redact/normalization.py,sha256=-XAdKifmLrY2rrOUfP3SvaDHmjW7HJ5nWQJB7u1BgZo,3071
|
|
4
|
+
fa_redact/pipeline.py,sha256=tgyF-RN_R96PUAHR2dkc4CNZxx88cxoOHzPH2wS5CQA,1961
|
|
5
|
+
fa_redact/protocols.py,sha256=ErL5xuuYVFnx4mPPxZz0h0pxOa5rVbuCM56nNWFLQn4,970
|
|
6
|
+
fa_redact/pseudonymization.py,sha256=469WbmnN3fTdcqGPyRmGc8FeVCVjXKpsbSHcb_tIMOE,6709
|
|
7
|
+
fa_redact/py.typed,sha256=JTMQ3X5TErayUJWJNlQV9q9Zsq86cF22FslLGORWvEw,53
|
|
8
|
+
fa_redact/redaction.py,sha256=Yeu8uOK_ytKFFLgrRDAYQww2Q6Pq8lSvWV-a2kTyUGs,1352
|
|
9
|
+
fa_redact/detectors/__init__.py,sha256=ztke_TzGUMkRtlkne1UFHd_BzTR3sTdNUvF9XHR8i9M,288
|
|
10
|
+
fa_redact/detectors/mobile.py,sha256=Za7VI3MymooMMmdgJfzN_Q6igxnvkaEZIG-tv7Hsj6I,2095
|
|
11
|
+
fa_redact/detectors/national_id.py,sha256=TjX6c5TYWbNXUSYD_-DD9lu0AsbZMLsGui2Z5FwwvS0,2049
|
|
12
|
+
fa_redact/validators/__init__.py,sha256=rAoHznaBEH5bZM1pqNtgrB6ZSZcoT8hXnwfHb6cw0do,280
|
|
13
|
+
fa_redact/validators/mobile.py,sha256=sFbjgFQm8v4eWvf_AXkBEtjNwtRMi7Yu0VYCBjk3pbQ,3210
|
|
14
|
+
fa_redact/validators/national_id.py,sha256=iu_ff2y_x2xpSKs4n6cus2KedFkk-rhOMov2yHiaGmA,1710
|
|
15
|
+
fa_redact-0.1.0.dist-info/licenses/LICENSE,sha256=rYBqj0XvcIjFPLx41dUw9ugfDXNWusbW83Wmg6ErM90,1079
|
|
16
|
+
fa_redact-0.1.0.dist-info/METADATA,sha256=hxKWnxqlSBkiYememicjBq-V2KMi6bkrwINopPilGK0,10457
|
|
17
|
+
fa_redact-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
fa_redact-0.1.0.dist-info/top_level.txt,sha256=26_kVCc8Rti8OXHj8SyjRZhNtRNm9t3sW02EPpX0ycA,10
|
|
19
|
+
fa_redact-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fa-redact contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fa_redact
|