polanon 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.
polanon/__init__.py ADDED
@@ -0,0 +1,255 @@
1
+ """polanon: reversible PII anonymization for LLM calls, Polish-first. Zero web/DB dependencies.
2
+
3
+ This package is deliberately self-contained, so it can be dropped into any project: a CLI tool, a
4
+ LangChain callback, an MCP server, an Open WebUI filter. It imports no web or database framework,
5
+ and nothing from the hub built on it (enforced by tests/test_layering.py).
6
+
7
+ Public API (stable):
8
+
9
+ from polanon import Anonymizer, default_guards
10
+
11
+ anonymizer = Anonymizer(default_guards(with_ner=False))
12
+ result = anonymizer.anonymize_sync("Jan Kowalski, PESEL 90010112349") # or: await .anonymize()
13
+ result.anonymized # "<OSOBA_1>, PESEL <PESEL_1>"
14
+ result.mapping # {"<OSOBA_1>": "Jan Kowalski", "<PESEL_1>": "90010112349"}
15
+ Anonymizer.restore("Dzień dobry <OSOBA_1>", result.mapping) # original back in the answer
16
+ restorer = Anonymizer.stream_restorer(result.mapping) # for token-by-token streams
17
+
18
+ Runtime dependencies of the core: the standard library only. `NerGuard` needs spaCy and a language
19
+ model, the remote judges need httpx, and both are imported lazily so neither is required to mask
20
+ identifiers.
21
+ """
22
+
23
+ import importlib.util
24
+ import re
25
+ from collections.abc import Iterable
26
+
27
+ from polanon._detection import custom_type_problem
28
+ from polanon.allowlist import WELL_KNOWN_NAMES, Allowlist
29
+ from polanon.guards.base import Guard
30
+ from polanon.guards.company_pl import CompanyGuard
31
+ from polanon.guards.denylist import DenylistGuard
32
+ from polanon.guards.international import InternationalGuard
33
+ from polanon.guards.names_pl import PersonNameGuard
34
+ from polanon.guards.recognizers_pl import RegexGuard
35
+ from polanon.guards.secrets import SecretsGuard
36
+ from polanon.judge.base import Judge, JudgeError, JudgeFinding
37
+ from polanon.judge.mock import MockJudge
38
+ from polanon.output_sweep import SweepReport, sweep
39
+ from polanon.pipeline import _anonymize_stage1, anonymize, mask_known_values
40
+ from polanon.policy import ENTITY_TYPES, TOKEN_PATTERN, token_for
41
+ from polanon.stream_restore import StreamRestorer
42
+ from polanon.types import AnonymizationResult, JudgeFallback, Span, merge_spans
43
+
44
+ __all__ = [
45
+ "DEFAULT_ENABLED",
46
+ "ENTITY_TYPES",
47
+ "TOKEN_PATTERN",
48
+ "WELL_KNOWN_NAMES",
49
+ "Allowlist",
50
+ "AnonymizationResult",
51
+ "Anonymizer",
52
+ "CompanyGuard",
53
+ "DenylistGuard",
54
+ "Guard",
55
+ "InternationalGuard",
56
+ "Judge",
57
+ "JudgeError",
58
+ "JudgeFallback",
59
+ "JudgeFinding",
60
+ "MockJudge",
61
+ "PersonNameGuard",
62
+ "RegexGuard",
63
+ "SecretsGuard",
64
+ "Span",
65
+ "StreamRestorer",
66
+ "SweepReport",
67
+ "default_guards",
68
+ "mask_known_values",
69
+ "merge_spans",
70
+ "sweep",
71
+ "token_for",
72
+ ]
73
+
74
+ __version__ = "0.1.0"
75
+
76
+ #: The entity types an Anonymizer masks unless told otherwise: every built-in type except DATE.
77
+ #: A date rarely identifies anyone on its own, and masking every deadline and invoice date leaves
78
+ #: the model unable to answer the question the text was sent with. Add it back with
79
+ #: ``DEFAULT_ENABLED | {"DATE"}``.
80
+ DEFAULT_ENABLED = frozenset(ENTITY_TYPES) - {"DATE"}
81
+
82
+ _NER_INSTALL = (
83
+ 'pip install "polanon[ner]", then python -m spacy download {model}; '
84
+ "or call default_guards(with_ner=False) to mask with the rules alone"
85
+ )
86
+
87
+
88
+ def default_guards(
89
+ *, denylist: Iterable[str] = (), with_ner: bool = True, spacy_model: str = "pl_core_news_md"
90
+ ) -> list[Guard]:
91
+ """Guards in recommended order. NER guard is imported lazily because spaCy is heavy/optional.
92
+
93
+ The Polish rules come first, so where a Polish and a foreign rule read the same characters —
94
+ a "PL" VAT number is also a NIP — the checksum-validated Polish reading keeps its label.
95
+
96
+ Args:
97
+ denylist: terms that must always be masked, as a list.
98
+ with_ner: add the spaCy guard for places and the names no rule can see.
99
+ spacy_model: the spaCy model that guard loads on first use.
100
+
101
+ Raises:
102
+ TypeError: when `denylist` is a single string, which would be read letter by letter.
103
+ ModuleNotFoundError: when `with_ner` is on and spaCy or the model is not installed. It is
104
+ raised here rather than on the first text to mask, with the command that fixes it.
105
+ """
106
+ if isinstance(denylist, str):
107
+ # The term itself stays out of the message: it is what the caller wants kept hidden.
108
+ raise TypeError("denylist takes a list of terms, not a single string")
109
+ guards: list[Guard] = [
110
+ RegexGuard(),
111
+ SecretsGuard(),
112
+ CompanyGuard(),
113
+ PersonNameGuard(),
114
+ InternationalGuard(),
115
+ DenylistGuard(list(denylist)),
116
+ ]
117
+ if with_ner:
118
+ _require_spacy(spacy_model)
119
+ from polanon.guards.ner import NerGuard
120
+
121
+ guards.append(NerGuard(spacy_model=spacy_model))
122
+ return guards
123
+
124
+
125
+ def _require_spacy(spacy_model: str) -> None:
126
+ """Fail now, with the command that fixes it, instead of on the first text to mask."""
127
+ hint = _NER_INSTALL.format(model=spacy_model)
128
+ if importlib.util.find_spec("spacy") is None:
129
+ raise ModuleNotFoundError(f"the language model guard needs spaCy: {hint}")
130
+ # A model can also be loaded from a path, which find_spec cannot check; spaCy reports that one.
131
+ if spacy_model.isidentifier() and importlib.util.find_spec(spacy_model) is None:
132
+ raise ModuleNotFoundError(f"the spaCy model {spacy_model!r} is not installed: {hint}")
133
+
134
+
135
+ class Anonymizer:
136
+ """Facade over the anonymization pipeline.
137
+
138
+ Create one instance per process and pass the per-conversation `mapping` on every call. An
139
+ instance keeps no per-call state, so threads and coroutines can share it.
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ guards: list[Guard],
145
+ judge: Judge | None = None,
146
+ *,
147
+ enabled: Iterable[str] | None = None,
148
+ judge_fallback: JudgeFallback | str = JudgeFallback.SEND_STAGE1,
149
+ allowlist: Allowlist | None = None,
150
+ ) -> None:
151
+ """Configure the guards, the optional judge and the policy this facade applies.
152
+
153
+ Args:
154
+ guards: stage-1 detectors, run in order.
155
+ judge: optional stage-2 reviewer.
156
+ enabled: entity types to mask. When omitted, :data:`DEFAULT_ENABLED` plus the custom
157
+ types the guards declare in ``Guard.entity_types``.
158
+ judge_fallback: what to do when the judge fails: a :class:`JudgeFallback`, or its
159
+ value as text ("send_stage1", "block").
160
+ allowlist: public names left unmasked, such as :data:`WELL_KNOWN_NAMES`. Nothing is
161
+ exempt when omitted.
162
+
163
+ Raises:
164
+ ValueError: for an entity type that is neither built in nor declared in a guard's
165
+ ``entity_types``, such as a typo, which would otherwise switch masking off
166
+ without a word; for a declared type that cannot label a token; and for an
167
+ unknown fallback.
168
+ """
169
+ for guard in guards:
170
+ for entity_type in sorted(guard.entity_types):
171
+ problem = custom_type_problem(entity_type)
172
+ if problem is not None:
173
+ raise ValueError(f"guard {guard.name!r} declares the entity type {problem}")
174
+ declared = frozenset().union(*(guard.entity_types for guard in guards))
175
+ self.guards = guards
176
+ self.judge = judge
177
+ self.enabled = set(DEFAULT_ENABLED | declared) if enabled is None else set(enabled)
178
+ unknown = self.enabled - set(ENTITY_TYPES) - declared
179
+ if unknown:
180
+ raise ValueError(
181
+ f"unknown entity types {sorted(unknown)}: nothing would mask them. The built-in "
182
+ f"types are {', '.join(ENTITY_TYPES)}; a custom guard lists its own in "
183
+ "Guard.entity_types."
184
+ )
185
+ self.judge_fallback = JudgeFallback(judge_fallback)
186
+ self.allowlist = allowlist
187
+
188
+ async def anonymize(
189
+ self, text: str, mapping: dict[str, str] | None = None
190
+ ) -> AnonymizationResult:
191
+ """Mask `text`, reusing any tokens already allocated in `mapping`.
192
+
193
+ The detectors run in a worker thread, so the event loop stays free. Without an event loop
194
+ to await on, use :meth:`anonymize_sync`.
195
+ """
196
+ return await anonymize(
197
+ text,
198
+ enabled=self.enabled,
199
+ guards=self.guards,
200
+ judge=self.judge,
201
+ existing_mapping=dict(mapping or {}),
202
+ judge_fallback=self.judge_fallback,
203
+ allowlist=self.allowlist,
204
+ )
205
+
206
+ def anonymize_sync(
207
+ self, text: str, mapping: dict[str, str] | None = None
208
+ ) -> AnonymizationResult:
209
+ """Mask `text` without an event loop, reusing any tokens already allocated in `mapping`.
210
+
211
+ The blocking twin of :meth:`anonymize`, for scripts, Django and Flask views, Celery tasks
212
+ and notebooks. It returns the same result for the same input and never touches asyncio, so
213
+ it works whether or not an event loop is running in the calling thread, and from several
214
+ threads at once. Inside a coroutine prefer ``await anonymize()``, which keeps the work off
215
+ the event loop.
216
+
217
+ Args:
218
+ text: the text to mask.
219
+ mapping: token -> original value from earlier turns of the same conversation. It is
220
+ read, never modified; the returned mapping holds it plus any new tokens.
221
+
222
+ Returns:
223
+ The masked text with its spans, entity counts and the conversation's full mapping.
224
+
225
+ Raises:
226
+ RuntimeError: when this anonymizer has a stage-2 judge. Judges are asynchronous, and
227
+ running one behind a blocking call would hide its network I/O and its failures.
228
+ """
229
+ if self.judge is not None:
230
+ raise RuntimeError(
231
+ f"anonymize_sync() cannot run the stage-2 judge {self.judge.name!r}, because "
232
+ "judges are asynchronous: use 'await anonymizer.anonymize(...)', or an Anonymizer "
233
+ "without a judge."
234
+ )
235
+ return _anonymize_stage1(
236
+ text,
237
+ enabled=self.enabled,
238
+ guards=self.guards,
239
+ existing_mapping=dict(mapping or {}),
240
+ allowlist=self.allowlist,
241
+ )
242
+
243
+ @staticmethod
244
+ def restore(text: str, mapping: dict[str, str]) -> str:
245
+ """Replace every known token with its original value. Unknown tokens are left untouched."""
246
+ return re.sub(TOKEN_PATTERN, lambda m: mapping.get(m.group(0), m.group(0)), text)
247
+
248
+ @staticmethod
249
+ def stream_restorer(mapping: dict[str, str]) -> StreamRestorer:
250
+ """Return a restorer for a streamed provider response."""
251
+ return StreamRestorer(mapping)
252
+
253
+ def sweep(self, restored_text: str, mapping: dict[str, str]) -> SweepReport:
254
+ """Check a restored answer for invented tokens and unexpected personal data."""
255
+ return sweep(restored_text, mapping, self.guards, self.enabled, self.allowlist)
polanon/__main__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Command line for the anonymizer.
2
+
3
+ Text comes from the arguments or from standard input. The masked text goes to stdout and the token
4
+ mapping to stderr, so a pipeline can keep the two apart:
5
+
6
+ polanon "Jan Kowalski, PESEL 90010112349"
7
+ cat umowa.txt | polanon --ner > masked.txt 2> mapping.json
8
+
9
+ The mapping is what makes the masking reversible, so treat that second file the way you would treat
10
+ the original document.
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from typing import TextIO
18
+
19
+ from polanon import Anonymizer, default_guards
20
+
21
+ DESCRIPTION = "Mask personal data in text before it goes to an LLM; the mapping goes to stderr."
22
+
23
+ #: Exit status when the language model was asked for but is not installed.
24
+ MISSING_MODEL = 3
25
+
26
+
27
+ def main(argv: Sequence[str] | None = None) -> int:
28
+ """Anonymize text from the command line or standard input.
29
+
30
+ Returns:
31
+ 0 on success, 2 when there was no text to mask, 3 when --ner is given without spaCy.
32
+ """
33
+ parser = argparse.ArgumentParser(prog="polanon", description=DESCRIPTION)
34
+ parser.add_argument("text", nargs="*", help="text to mask; omit to read standard input")
35
+ parser.add_argument(
36
+ "--ner", action="store_true", help="also load the spaCy model for names and places"
37
+ )
38
+ parser.add_argument(
39
+ "--spacy-model",
40
+ default="pl_core_news_md",
41
+ help="spaCy model to load with --ner (default: %(default)s)",
42
+ )
43
+ args = parser.parse_args(argv)
44
+
45
+ _write_utf8(sys.stdout)
46
+ _write_utf8(sys.stderr)
47
+ text = " ".join(args.text) if args.text else _read_stdin()
48
+ if not text.strip():
49
+ print("nothing to mask: pass text as arguments or on standard input", file=sys.stderr)
50
+ return 2
51
+ try:
52
+ guards = default_guards(with_ner=args.ner, spacy_model=args.spacy_model)
53
+ except ModuleNotFoundError as exc:
54
+ print(f"polanon: {exc}", file=sys.stderr)
55
+ return MISSING_MODEL
56
+
57
+ result = Anonymizer(guards).anonymize_sync(text)
58
+ print(result.anonymized)
59
+ print(json.dumps(result.mapping, ensure_ascii=False, indent=2), file=sys.stderr)
60
+ return 0
61
+
62
+
63
+ def _read_stdin() -> str:
64
+ """Read standard input as UTF-8, whatever the console's code page.
65
+
66
+ Windows decodes piped input with the ANSI code page, so "Wiśniewski" arrived garbled, no guard
67
+ recognised it, and it went out unmasked. Files from older Polish software are cp1250, so that is
68
+ the fallback before replacement characters.
69
+ """
70
+ stream = getattr(sys.stdin, "buffer", None)
71
+ if stream is None:
72
+ return sys.stdin.read()
73
+ raw = stream.read()
74
+ for encoding in ("utf-8-sig", "cp1250"):
75
+ try:
76
+ return str(raw.decode(encoding))
77
+ except UnicodeDecodeError:
78
+ continue
79
+ return str(raw.decode("utf-8", errors="replace"))
80
+
81
+
82
+ def _write_utf8(stream: TextIO) -> None:
83
+ """Write UTF-8 even where the console would not: the mapping must stay valid JSON."""
84
+ reconfigure = getattr(stream, "reconfigure", None)
85
+ if reconfigure is not None:
86
+ reconfigure(encoding="utf-8")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())
polanon/_detection.py ADDED
@@ -0,0 +1,79 @@
1
+ """Running the guards over a text, and the checks a guard's output has to pass first.
2
+
3
+ Shared by the pipeline, the output sweep and the facade, which refuses a guard whose declared types
4
+ cannot label a token before it is ever run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from polanon._view import View
12
+ from polanon.guards.base import Guard
13
+ from polanon.policy import ENTITY_TYPES
14
+ from polanon.types import Span
15
+
16
+ #: What a custom entity type may be called. It becomes the label inside its tokens, and
17
+ #: TOKEN_PATTERN only recognises uppercase letters and underscores there: a type spelled any other
18
+ #: way produced tokens that restore could not find, and the original stayed in the answer.
19
+ _CUSTOM_TYPE_RE = re.compile(r"[A-Z][A-Z_]*")
20
+
21
+
22
+ #: Token labels of the built-in types. A custom type spelled like one would share its tokens:
23
+ #: a guard's "OSOBA" made "<OSOBA_2>" a second person, and every count filed it as one.
24
+ _BUILT_IN_LABELS = frozenset(ENTITY_TYPES.values()) - frozenset(ENTITY_TYPES)
25
+
26
+
27
+ def detect(text: str, view: View | None, enabled: set[str], guards: list[Guard]) -> list[Span]:
28
+ """Run every guard over the text and return their spans, unmerged, in original coordinates.
29
+
30
+ `view` is the text's :func:`~polanon._view.detection_view`, computed once by the caller.
31
+ """
32
+ seen = text if view is None else view.text
33
+ detected: list[Span] = []
34
+ for guard in guards:
35
+ for span in guard.detect(seen, enabled):
36
+ if _usable(span, guard, len(seen)):
37
+ detected.append(span if view is None else view.to_original(span))
38
+ return detected
39
+
40
+
41
+ def _usable(span: Span, guard: Guard, length: int) -> bool:
42
+ """Check a span before the pipeline trusts it; an empty one is skipped.
43
+
44
+ A guard with an off-by-one would otherwise corrupt the text without a sound: Python slices take
45
+ a negative start from the end, and the document went out with half of it repeated.
46
+
47
+ Raises:
48
+ ValueError: when the span lies outside the text, or its type cannot be a token label.
49
+ """
50
+ if not 0 <= span.start <= span.end <= length:
51
+ raise ValueError(
52
+ f"guard {guard.name!r} returned the span {span.start}..{span.end}, "
53
+ f"outside a text of {length} characters"
54
+ )
55
+ problem = custom_type_problem(span.entity_type)
56
+ if problem is not None:
57
+ raise ValueError(f"guard {guard.name!r} returned the entity type {problem}")
58
+ return span.start < span.end
59
+
60
+
61
+ def custom_type_problem(entity_type: str) -> str | None:
62
+ """Return why `entity_type` cannot name a custom type, or None when it can.
63
+
64
+ The message completes "returned the entity type" or "declares the entity type", so a guard's
65
+ author reads what to rename and why.
66
+ """
67
+ if entity_type in ENTITY_TYPES:
68
+ return None
69
+ if not _CUSTOM_TYPE_RE.fullmatch(entity_type):
70
+ return (
71
+ f"{entity_type!r}. A custom type becomes the label of its tokens, so it must be "
72
+ "uppercase letters and underscores, such as 'EMPLOYEE_ID'."
73
+ )
74
+ if entity_type in _BUILT_IN_LABELS:
75
+ return (
76
+ f"{entity_type!r}, which is the token label of a built-in type, so its tokens would "
77
+ "read as that type. Name it differently, such as 'EMPLOYEE_ID'."
78
+ )
79
+ return None