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 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
+ [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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