pseudonymize 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.
Files changed (41) hide show
  1. pseudonymize/__init__.py +66 -0
  2. pseudonymize/adapters.py +17 -0
  3. pseudonymize/api.py +50 -0
  4. pseudonymize/backends/__init__.py +16 -0
  5. pseudonymize/backends/base.py +90 -0
  6. pseudonymize/backends/composite.py +54 -0
  7. pseudonymize/backends/rules.py +43 -0
  8. pseudonymize/cli.py +206 -0
  9. pseudonymize/detectors/__init__.py +4 -0
  10. pseudonymize/detectors/base.py +10 -0
  11. pseudonymize/detectors/email.py +21 -0
  12. pseudonymize/detectors/iban.py +31 -0
  13. pseudonymize/detectors/ip_address.py +27 -0
  14. pseudonymize/detectors/payment_card.py +33 -0
  15. pseudonymize/detectors/phone.py +26 -0
  16. pseudonymize/detectors/registry.py +18 -0
  17. pseudonymize/detectors/secret.py +31 -0
  18. pseudonymize/detectors/url.py +53 -0
  19. pseudonymize/document.py +107 -0
  20. pseudonymize/engine.py +542 -0
  21. pseudonymize/exceptions.py +42 -0
  22. pseudonymize/formats.py +361 -0
  23. pseudonymize/normalization.py +22 -0
  24. pseudonymize/policy.py +82 -0
  25. pseudonymize/processing.py +45 -0
  26. pseudonymize/py.typed +0 -0
  27. pseudonymize/resolution.py +28 -0
  28. pseudonymize/result.py +58 -0
  29. pseudonymize/spans.py +46 -0
  30. pseudonymize/transforms/__init__.py +27 -0
  31. pseudonymize/transforms/alias.py +74 -0
  32. pseudonymize/transforms/base.py +8 -0
  33. pseudonymize/transforms/hmac.py +7 -0
  34. pseudonymize/transforms/mode.py +8 -0
  35. pseudonymize/transforms/placeholder.py +11 -0
  36. pseudonymize/transforms/redact.py +13 -0
  37. pseudonymize-0.1.0.dist-info/METADATA +295 -0
  38. pseudonymize-0.1.0.dist-info/RECORD +41 -0
  39. pseudonymize-0.1.0.dist-info/WHEEL +4 -0
  40. pseudonymize-0.1.0.dist-info/entry_points.txt +2 -0
  41. pseudonymize-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,66 @@
1
+ from pseudonymize.adapters import InputAdapter, OutputAdapter
2
+ from pseudonymize.api import generate_key, pseudonymize, redact
3
+ from pseudonymize.backends import (
4
+ BackendCapabilities,
5
+ CompositeBackend,
6
+ DetectionBackend,
7
+ RulesBackend,
8
+ )
9
+ from pseudonymize.document import (
10
+ ContentBlock,
11
+ CSVCellLocation,
12
+ Document,
13
+ JSONPathLocation,
14
+ MetadataValue,
15
+ SourceLocation,
16
+ TextOffsetLocation,
17
+ )
18
+ from pseudonymize.engine import ProcessingScope, Pseudonymizer
19
+ from pseudonymize.formats import FileFormat
20
+ from pseudonymize.policy import NetworkPolicy, Policy
21
+ from pseudonymize.processing import (
22
+ DetectionReport,
23
+ ProcessingResult,
24
+ ProcessingStatistics,
25
+ ProcessingWarning,
26
+ )
27
+ from pseudonymize.resolution import EntityResolver, ExactEntityResolver, ResolvedEntity
28
+ from pseudonymize.result import Detection, EntityType, Replacement, Result
29
+ from pseudonymize.transforms import Alias, TransformationMode
30
+
31
+ __all__ = [
32
+ "Alias",
33
+ "BackendCapabilities",
34
+ "CSVCellLocation",
35
+ "CompositeBackend",
36
+ "ContentBlock",
37
+ "Detection",
38
+ "DetectionBackend",
39
+ "DetectionReport",
40
+ "Document",
41
+ "EntityResolver",
42
+ "EntityType",
43
+ "ExactEntityResolver",
44
+ "FileFormat",
45
+ "InputAdapter",
46
+ "JSONPathLocation",
47
+ "MetadataValue",
48
+ "NetworkPolicy",
49
+ "OutputAdapter",
50
+ "Policy",
51
+ "ProcessingResult",
52
+ "ProcessingScope",
53
+ "ProcessingStatistics",
54
+ "ProcessingWarning",
55
+ "Pseudonymizer",
56
+ "Replacement",
57
+ "ResolvedEntity",
58
+ "Result",
59
+ "RulesBackend",
60
+ "SourceLocation",
61
+ "TextOffsetLocation",
62
+ "TransformationMode",
63
+ "generate_key",
64
+ "pseudonymize",
65
+ "redact",
66
+ ]
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+ from typing import Protocol, TypeVar
3
+
4
+ from pseudonymize.document import Document
5
+
6
+ SourceT = TypeVar("SourceT", contravariant=True)
7
+
8
+
9
+ class InputAdapter(Protocol[SourceT]):
10
+ def extract(self, source: SourceT) -> Document: ...
11
+
12
+
13
+ class OutputAdapter(Protocol):
14
+ def render(self, document: Document) -> bytes: ...
15
+
16
+
17
+ FileInputAdapter = InputAdapter[Path]
pseudonymize/api.py ADDED
@@ -0,0 +1,50 @@
1
+ from collections.abc import Sequence
2
+
3
+ from pseudonymize.backends import DetectionBackend
4
+ from pseudonymize.engine import Pseudonymizer
5
+ from pseudonymize.policy import Policy
6
+ from pseudonymize.transforms import TransformationMode, generate_key
7
+
8
+
9
+ def pseudonymize(
10
+ text: str,
11
+ *,
12
+ mode: TransformationMode | str = TransformationMode.NUMBERED,
13
+ key: bytes | None = None,
14
+ namespace: str = "default",
15
+ policy: Policy | None = None,
16
+ backends: Sequence[DetectionBackend] | None = None,
17
+ ) -> str:
18
+ return (
19
+ Pseudonymizer(
20
+ mode=mode,
21
+ key=key,
22
+ namespace=namespace,
23
+ policy=policy,
24
+ backends=backends,
25
+ )
26
+ .process(text)
27
+ .text
28
+ )
29
+
30
+
31
+ def redact(
32
+ text: str,
33
+ *,
34
+ typed: bool = False,
35
+ policy: Policy | None = None,
36
+ backends: Sequence[DetectionBackend] | None = None,
37
+ ) -> str:
38
+ return (
39
+ Pseudonymizer(
40
+ mode=TransformationMode.REDACTED,
41
+ policy=policy,
42
+ backends=backends,
43
+ typed_redaction=typed,
44
+ )
45
+ .process(text)
46
+ .text
47
+ )
48
+
49
+
50
+ __all__ = ["generate_key", "pseudonymize", "redact"]
@@ -0,0 +1,16 @@
1
+ from pseudonymize.backends.base import (
2
+ BackendCapabilities,
3
+ DetectionBackend,
4
+ backend_capabilities,
5
+ )
6
+ from pseudonymize.backends.composite import CompositeBackend, leaf_backends
7
+ from pseudonymize.backends.rules import RulesBackend
8
+
9
+ __all__ = [
10
+ "BackendCapabilities",
11
+ "CompositeBackend",
12
+ "DetectionBackend",
13
+ "RulesBackend",
14
+ "backend_capabilities",
15
+ "leaf_backends",
16
+ ]
@@ -0,0 +1,90 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass, replace
3
+ from typing import Protocol
4
+
5
+ from pseudonymize.document import ContentBlock
6
+ from pseudonymize.exceptions import (
7
+ BackendContractError,
8
+ BackendExecutionError,
9
+ InvalidDetectionError,
10
+ NetworkPolicyError,
11
+ )
12
+ from pseudonymize.policy import NetworkPolicy, Policy
13
+ from pseudonymize.result import Detection, EntityType
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class BackendCapabilities:
18
+ entity_types: frozenset[EntityType]
19
+ remote: bool = False
20
+
21
+ def __post_init__(self) -> None:
22
+ object.__setattr__(self, "entity_types", frozenset(self.entity_types))
23
+ if any(not isinstance(entity_type, EntityType) for entity_type in self.entity_types):
24
+ raise TypeError("backend entity types must be EntityType values")
25
+ if not isinstance(self.remote, bool):
26
+ raise TypeError("backend remote capability must be a boolean")
27
+
28
+
29
+ class DetectionBackend(Protocol):
30
+ @property
31
+ def name(self) -> str: ...
32
+
33
+ @property
34
+ def capabilities(self) -> BackendCapabilities: ...
35
+
36
+ @property
37
+ def allow_remote_processing(self) -> bool: ...
38
+
39
+ def detect(self, block: ContentBlock, policy: Policy) -> Sequence[Detection]: ...
40
+
41
+
42
+ def backend_capabilities(backend: DetectionBackend) -> BackendCapabilities:
43
+ try:
44
+ capabilities = backend.capabilities
45
+ allow_remote_processing = backend.allow_remote_processing
46
+ name = backend.name
47
+ except Exception:
48
+ raise BackendContractError(
49
+ "backend does not declare the required block-aware contract"
50
+ ) from None
51
+ if not isinstance(capabilities, BackendCapabilities):
52
+ raise BackendContractError("backend capabilities are invalid")
53
+ if not isinstance(allow_remote_processing, bool):
54
+ raise BackendContractError("backend remote consent must be a boolean")
55
+ if not isinstance(name, str) or not name:
56
+ raise BackendContractError("backend name must be a non-empty string")
57
+ return capabilities
58
+
59
+
60
+ def invoke_backend(
61
+ backend: DetectionBackend, block: ContentBlock, policy: Policy
62
+ ) -> tuple[Detection, ...]:
63
+ capabilities = backend_capabilities(backend)
64
+ name = backend.name
65
+ if capabilities.remote:
66
+ if policy.network_policy is NetworkPolicy.DENY:
67
+ raise NetworkPolicyError("network policy denies remote processing")
68
+ if not backend.allow_remote_processing:
69
+ raise NetworkPolicyError("remote backend lacks explicit consent")
70
+ if (
71
+ policy.network_policy is NetworkPolicy.ALLOW_CONFIGURED
72
+ and name not in policy.allowed_remote_backends
73
+ ):
74
+ raise NetworkPolicyError("remote backend is not allowlisted")
75
+ try:
76
+ candidates = tuple(backend.detect(block, policy))
77
+ except TypeError:
78
+ raise BackendContractError("backend does not implement block-aware detection") from None
79
+ except Exception:
80
+ raise BackendExecutionError("backend failed during detection") from None
81
+ detections: list[Detection] = []
82
+ for detection in candidates:
83
+ if not isinstance(detection, Detection):
84
+ raise BackendContractError("backend returned a value that is not a Detection")
85
+ if detection.entity_type not in capabilities.entity_types:
86
+ raise BackendContractError("backend returned an undeclared entity type")
87
+ if detection.end > len(block.text):
88
+ raise InvalidDetectionError("backend returned offsets outside the content block")
89
+ detections.append(detection if detection.backend else replace(detection, backend=name))
90
+ return tuple(detections)
@@ -0,0 +1,54 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.backends.base import (
5
+ BackendCapabilities,
6
+ DetectionBackend,
7
+ backend_capabilities,
8
+ invoke_backend,
9
+ )
10
+ from pseudonymize.document import ContentBlock
11
+ from pseudonymize.policy import Policy
12
+ from pseudonymize.result import Detection
13
+ from pseudonymize.spans import resolve_overlaps
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class CompositeBackend:
18
+ backends: Sequence[DetectionBackend]
19
+ name: str = "composite"
20
+ allow_remote_processing: bool = False
21
+
22
+ def __post_init__(self) -> None:
23
+ object.__setattr__(self, "backends", tuple(self.backends))
24
+
25
+ @property
26
+ def capabilities(self) -> BackendCapabilities:
27
+ return BackendCapabilities(
28
+ frozenset(
29
+ entity_type
30
+ for backend in self.backends
31
+ for entity_type in backend_capabilities(backend).entity_types
32
+ ),
33
+ any(backend_capabilities(backend).remote for backend in self.backends),
34
+ )
35
+
36
+ def detect(self, block: ContentBlock, policy: Policy) -> tuple[Detection, ...]:
37
+ candidates = (
38
+ detection
39
+ for backend in self.backends
40
+ for detection in invoke_backend(backend, block, policy)
41
+ if detection.entity_type in policy.entity_types
42
+ and detection.confidence >= policy.minimum_confidence
43
+ )
44
+ return resolve_overlaps(candidates, policy.detector_priority)
45
+
46
+
47
+ def leaf_backends(backends: Sequence[DetectionBackend]) -> tuple[DetectionBackend, ...]:
48
+ leaves: list[DetectionBackend] = []
49
+ for backend in backends:
50
+ if isinstance(backend, CompositeBackend):
51
+ leaves.extend(leaf_backends(backend.backends))
52
+ else:
53
+ leaves.append(backend)
54
+ return tuple(leaves)
@@ -0,0 +1,43 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.backends.base import BackendCapabilities
5
+ from pseudonymize.detectors import DEFAULT_DETECTORS, Detector
6
+ from pseudonymize.document import ContentBlock
7
+ from pseudonymize.policy import Policy
8
+ from pseudonymize.result import Detection, EntityType
9
+
10
+ _RULE_ENTITY_TYPES = frozenset(
11
+ {
12
+ EntityType.EMAIL,
13
+ EntityType.PHONE,
14
+ EntityType.IP_ADDRESS,
15
+ EntityType.IBAN,
16
+ EntityType.PAYMENT_CARD,
17
+ EntityType.URL_CREDENTIAL,
18
+ EntityType.SECRET,
19
+ }
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RulesBackend:
25
+ detectors: Sequence[Detector] = DEFAULT_DETECTORS
26
+ name: str = "rules"
27
+ allow_remote_processing: bool = False
28
+
29
+ def __post_init__(self) -> None:
30
+ object.__setattr__(self, "detectors", tuple(self.detectors))
31
+
32
+ @property
33
+ def capabilities(self) -> BackendCapabilities:
34
+ return BackendCapabilities(_RULE_ENTITY_TYPES)
35
+
36
+ def detect(self, block: ContentBlock, policy: Policy) -> tuple[Detection, ...]:
37
+ return tuple(
38
+ detection
39
+ for detector in self.detectors
40
+ for detection in detector.detect(block.text)
41
+ if detection.entity_type in policy.entity_types
42
+ and detection.confidence >= policy.minimum_confidence
43
+ )
pseudonymize/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ import argparse
2
+ import base64
3
+ import binascii
4
+ import json
5
+ import os
6
+ import stat
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from pseudonymize.api import generate_key
12
+ from pseudonymize.detectors import DEFAULT_DETECTORS
13
+ from pseudonymize.document import CSVCellLocation, JSONPathLocation, TextOffsetLocation
14
+ from pseudonymize.engine import Data, Pseudonymizer
15
+ from pseudonymize.exceptions import PseudonymizeError
16
+ from pseudonymize.formats import FileFormat
17
+ from pseudonymize.policy import Policy
18
+ from pseudonymize.processing import DetectionReport, ProcessingResult
19
+ from pseudonymize.transforms import TransformationMode
20
+
21
+
22
+ def _parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog="pseudonymize")
24
+ subparsers = parser.add_subparsers(dest="command", required=True)
25
+ subparsers.add_parser("keygen", help="print a new base64-encoded key")
26
+ subparsers.add_parser("detectors", help="list bundled detectors")
27
+
28
+ text_parser = subparsers.add_parser("text", help="process text")
29
+ text_parser.add_argument("text", help="text to process, or - for standard input")
30
+ _add_key_options(text_parser, allow_stdin=True)
31
+ text_parser.add_argument("--namespace", default="default")
32
+ _add_mode_options(text_parser)
33
+
34
+ json_parser = subparsers.add_parser("json", help="process JSON from standard input")
35
+ _add_key_options(json_parser)
36
+ json_parser.add_argument("--namespace", default="default")
37
+ _add_mode_options(json_parser)
38
+
39
+ file_parser = subparsers.add_parser("file", help="process a supported file")
40
+ file_parser.add_argument("source", type=Path)
41
+ file_parser.add_argument("--output", type=Path)
42
+ file_parser.add_argument("--format", choices=tuple(FileFormat))
43
+ file_parser.add_argument("--encoding")
44
+ file_parser.add_argument("--overwrite", action="store_true")
45
+ _add_key_options(file_parser, allow_stdin=True)
46
+ file_parser.add_argument("--namespace", default="default")
47
+ _add_mode_options(file_parser)
48
+
49
+ inspect_parser = subparsers.add_parser(
50
+ "inspect-file", help="inspect a supported file without writing output"
51
+ )
52
+ inspect_parser.add_argument("source", type=Path)
53
+ inspect_parser.add_argument("--format", choices=tuple(FileFormat))
54
+ inspect_parser.add_argument("--encoding")
55
+ return parser
56
+
57
+
58
+ def _add_key_options(parser: argparse.ArgumentParser, *, allow_stdin: bool = False) -> None:
59
+ sources = parser.add_mutually_exclusive_group()
60
+ sources.add_argument("--key-env", metavar="NAME")
61
+ sources.add_argument("--key-file", type=Path)
62
+ sources.add_argument("--key-fd", type=int)
63
+ if allow_stdin:
64
+ sources.add_argument("--key-stdin", action="store_true")
65
+
66
+
67
+ def _add_mode_options(parser: argparse.ArgumentParser) -> None:
68
+ modes = parser.add_mutually_exclusive_group()
69
+ modes.add_argument(
70
+ "--mode", choices=tuple(TransformationMode), default=TransformationMode.NUMBERED
71
+ )
72
+ modes.add_argument(
73
+ "--redact", action="store_const", const=TransformationMode.REDACTED, dest="mode"
74
+ )
75
+ parser.add_argument("--typed-redaction", action="store_true")
76
+
77
+
78
+ def _has_key_source(arguments: argparse.Namespace) -> bool:
79
+ return any(
80
+ (
81
+ getattr(arguments, "key_env", None),
82
+ getattr(arguments, "key_file", None),
83
+ getattr(arguments, "key_fd", None) is not None,
84
+ getattr(arguments, "key_stdin", False),
85
+ )
86
+ )
87
+
88
+
89
+ def _read_key(arguments: argparse.Namespace) -> bytes:
90
+ encoded: str
91
+ if getattr(arguments, "key_env", None):
92
+ encoded = os.environ.get(arguments.key_env, "")
93
+ if not encoded:
94
+ raise ValueError(f"environment variable {arguments.key_env!r} is empty or unset")
95
+ elif getattr(arguments, "key_file", None):
96
+ path: Path = arguments.key_file
97
+ if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077:
98
+ raise ValueError("key file must not be accessible by group or other users")
99
+ encoded = path.read_text(encoding="ascii").strip()
100
+ elif getattr(arguments, "key_fd", None) is not None:
101
+ with os.fdopen(os.dup(arguments.key_fd), encoding="ascii") as stream:
102
+ encoded = stream.read().strip()
103
+ else:
104
+ if getattr(arguments, "text", None) == "-":
105
+ raise ValueError("standard input cannot provide both the key and text")
106
+ encoded = sys.stdin.read().strip()
107
+ try:
108
+ return base64.b64decode(encoded, validate=True)
109
+ except (binascii.Error, ValueError) as error:
110
+ raise ValueError("key must be valid base64") from error
111
+
112
+
113
+ def main(argv: Sequence[str] | None = None) -> int:
114
+ parser = _parser()
115
+ arguments = parser.parse_args(argv)
116
+ if arguments.command == "keygen":
117
+ print(base64.b64encode(generate_key()).decode("ascii"))
118
+ return 0
119
+ if arguments.command == "detectors":
120
+ print("\n".join(detector.name for detector in DEFAULT_DETECTORS))
121
+ return 0
122
+ try:
123
+ if arguments.command == "inspect-file":
124
+ inspected = Pseudonymizer().inspect_file(
125
+ arguments.source,
126
+ format=arguments.format,
127
+ encoding=arguments.encoding,
128
+ )
129
+ json.dump(_inspection_payload(inspected), sys.stdout, ensure_ascii=False)
130
+ sys.stdout.write("\n")
131
+ return 0
132
+ key = _read_key(arguments) if _has_key_source(arguments) else None
133
+ engine = Pseudonymizer(
134
+ mode=arguments.mode,
135
+ key=key,
136
+ namespace=arguments.namespace,
137
+ policy=Policy.llm() if arguments.command == "json" else Policy.default(),
138
+ typed_redaction=arguments.typed_redaction,
139
+ )
140
+ if arguments.command == "text":
141
+ source = sys.stdin.read() if arguments.text == "-" else arguments.text
142
+ print(engine.process(source).text)
143
+ return 0
144
+ if arguments.command == "file":
145
+ result = engine.process_file(
146
+ arguments.source,
147
+ arguments.output,
148
+ format=arguments.format,
149
+ encoding=arguments.encoding,
150
+ overwrite=arguments.overwrite,
151
+ )
152
+ print(result.output)
153
+ return 0
154
+ payload = json.load(sys.stdin)
155
+ output: Data = engine.process_data(payload)
156
+ json.dump(output, sys.stdout, ensure_ascii=False)
157
+ sys.stdout.write("\n")
158
+ return 0
159
+ except (LookupError, OSError, ValueError, json.JSONDecodeError, PseudonymizeError) as error:
160
+ parser.exit(2, f"pseudonymize: error: {error}\n")
161
+
162
+
163
+ def _inspection_payload(result: ProcessingResult[None]) -> dict[str, object]:
164
+ return {
165
+ "detections": [_detection_payload(report) for report in result.detections],
166
+ "statistics": {
167
+ "blocks_processed": result.statistics.blocks_processed,
168
+ "detections_found": result.statistics.detections_found,
169
+ "replacements_applied": result.statistics.replacements_applied,
170
+ "backend_invocations": result.statistics.backend_invocations,
171
+ "local_block_calls": result.statistics.local_block_calls,
172
+ "remote_block_calls": result.statistics.remote_block_calls,
173
+ },
174
+ "warnings": [
175
+ {"code": warning.code, "message": warning.message, "block_id": warning.block_id}
176
+ for warning in result.warnings
177
+ ],
178
+ }
179
+
180
+
181
+ def _detection_payload(report: DetectionReport) -> dict[str, object]:
182
+ return {
183
+ "entity_type": report.entity_type.value,
184
+ "block_id": report.block_id,
185
+ "location": _location_payload(report),
186
+ "start": report.start,
187
+ "end": report.end,
188
+ "confidence": report.confidence,
189
+ "backend": report.backend,
190
+ "detector": report.detector,
191
+ }
192
+
193
+
194
+ def _location_payload(report: DetectionReport) -> dict[str, object]:
195
+ location = report.location
196
+ if isinstance(location, TextOffsetLocation):
197
+ return {"kind": "text_offset", "start": location.start, "end": location.end}
198
+ if isinstance(location, JSONPathLocation):
199
+ return {"kind": "json_path", "path": location.path}
200
+ if isinstance(location, CSVCellLocation):
201
+ return {"kind": "csv_cell", "row": location.row, "column": location.column}
202
+ raise TypeError("unsupported report location")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ from pseudonymize.detectors.base import Detector
2
+ from pseudonymize.detectors.registry import DEFAULT_DETECTORS
3
+
4
+ __all__ = ["DEFAULT_DETECTORS", "Detector"]
@@ -0,0 +1,10 @@
1
+ from typing import Protocol
2
+
3
+ from pseudonymize.result import Detection
4
+
5
+
6
+ class Detector(Protocol):
7
+ @property
8
+ def name(self) -> str: ...
9
+
10
+ def detect(self, text: str) -> list[Detection]: ...
@@ -0,0 +1,21 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _EMAIL = re.compile(
7
+ r"(?<![\w.+-])[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@"
8
+ r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}(?![\w-])"
9
+ )
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class EmailDetector:
14
+ name: str = "email"
15
+
16
+ def detect(self, text: str) -> list[Detection]:
17
+ return [
18
+ Detection(EntityType.EMAIL, match.start(), match.end(), 0.99, self.name)
19
+ for match in _EMAIL.finditer(text)
20
+ if len(match.group().partition("@")[0]) <= 64 and len(match.group()) <= 254
21
+ ]
@@ -0,0 +1,31 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _IBAN = re.compile(r"(?<![A-Z0-9])[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}(?![A-Z0-9])", re.I)
7
+
8
+
9
+ def _valid_mod97(value: str) -> bool:
10
+ compact = "".join(value.split()).upper()
11
+ if not 15 <= len(compact) <= 34:
12
+ return False
13
+ rearranged = compact[4:] + compact[:4]
14
+ remainder = 0
15
+ for character in rearranged:
16
+ digits = str(ord(character) - 55) if character.isalpha() else character
17
+ for digit in digits:
18
+ remainder = (remainder * 10 + int(digit)) % 97
19
+ return remainder == 1
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class IbanDetector:
24
+ name: str = "iban"
25
+
26
+ def detect(self, text: str) -> list[Detection]:
27
+ return [
28
+ Detection(EntityType.IBAN, match.start(), match.end(), 1.0, self.name)
29
+ for match in _IBAN.finditer(text)
30
+ if _valid_mod97(match.group())
31
+ ]
@@ -0,0 +1,27 @@
1
+ import ipaddress
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ from pseudonymize.result import Detection, EntityType
6
+
7
+ _IP_CANDIDATE = re.compile(
8
+ r"(?<![\w:.])(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f]{0,4}:[0-9A-Fa-f:]+)"
9
+ r"(?![\w:]|\.[\w:])"
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class IpAddressDetector:
15
+ name: str = "ip_address"
16
+
17
+ def detect(self, text: str) -> list[Detection]:
18
+ detections: list[Detection] = []
19
+ for match in _IP_CANDIDATE.finditer(text):
20
+ try:
21
+ ipaddress.ip_address(match.group())
22
+ except ValueError:
23
+ continue
24
+ detections.append(
25
+ Detection(EntityType.IP_ADDRESS, match.start(), match.end(), 0.99, self.name)
26
+ )
27
+ return detections
@@ -0,0 +1,33 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _CARD = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
7
+
8
+
9
+ def _valid_luhn(value: str) -> bool:
10
+ digits = [int(character) for character in value if character.isdigit()]
11
+ if not 13 <= len(digits) <= 19 or len(set(digits)) == 1:
12
+ return False
13
+ total = 0
14
+ parity = len(digits) % 2
15
+ for index, digit in enumerate(digits):
16
+ if index % 2 == parity:
17
+ digit *= 2
18
+ if digit > 9:
19
+ digit -= 9
20
+ total += digit
21
+ return total % 10 == 0
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PaymentCardDetector:
26
+ name: str = "payment_card"
27
+
28
+ def detect(self, text: str) -> list[Detection]:
29
+ return [
30
+ Detection(EntityType.PAYMENT_CARD, match.start(), match.end(), 1.0, self.name)
31
+ for match in _CARD.finditer(text)
32
+ if _valid_luhn(match.group())
33
+ ]