isomorphism 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.
@@ -0,0 +1,8 @@
1
+ """Reversible anonymisation of Swiss personal data. Local only: no network, no telemetry."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from .core import Finding, Mapping, anonymize, detect, restore
6
+
7
+ __version__ = version("isomorphism")
8
+ __all__ = ["Finding", "Mapping", "__version__", "anonymize", "detect", "restore"]
isomorphism/_checks.py ADDED
@@ -0,0 +1,29 @@
1
+ """Check-digit algorithms. Pure functions on digit strings, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def ahv_ok(digits: str) -> bool:
7
+ """AHV number: 13 digits, prefix 756, EAN-13 check digit."""
8
+ if len(digits) != 13 or not digits.startswith("756"):
9
+ return False
10
+ total = sum(int(c) * (3 if i % 2 else 1) for i, c in enumerate(digits[:12]))
11
+ return (10 - total % 10) % 10 == int(digits[12])
12
+
13
+
14
+ def iban_ok(compact: str) -> bool:
15
+ """ISO 13616 mod-97 on an IBAN without spaces."""
16
+ if len(compact) < 5 or not compact.isalnum():
17
+ return False
18
+ rearranged = compact[4:] + compact[:4]
19
+ return int("".join(str(int(c, 36)) for c in rearranged)) % 97 == 1
20
+
21
+
22
+ def uid_ok(digits: str) -> bool:
23
+ """UID (CHE) number: 9 digits, mod-11 check digit with weights 5,4,3,2,7,6,5,4."""
24
+ if len(digits) != 9:
25
+ return False
26
+ check = (
27
+ 11 - sum(int(c) * w for c, w in zip(digits[:8], (5, 4, 3, 2, 7, 6, 5, 4), strict=True)) % 11
28
+ )
29
+ return check != 10 and check % 11 == int(digits[8])
isomorphism/core.py ADDED
@@ -0,0 +1,115 @@
1
+ """anonymize / restore. The mapping holds real values: this module never logs or stores it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from collections.abc import Mapping as AbstractMapping
8
+ from dataclasses import dataclass, replace
9
+
10
+ from .detectors import DETECTORS
11
+
12
+ LANGS = frozenset({"de", "fr", "it", "en"})
13
+
14
+ # Tolerant on restore: models sometimes change case or add spaces inside the brackets.
15
+ _TOKEN = re.compile(r"\[\s*([A-Za-z]+)\s*_\s*(\d+)\s*\]")
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Finding:
20
+ """One hit for the audit log. Positions refer to the original text; the value is not kept."""
21
+
22
+ kind: str
23
+ start: int
24
+ end: int
25
+ confidence: float
26
+ detector: str
27
+ checksum_ok: bool | None = None
28
+ placeholder: str = ""
29
+
30
+
31
+ class Mapping(dict[str, str]):
32
+ """placeholder -> real value, plus the findings. repr() hides the values."""
33
+
34
+ findings: list[Finding]
35
+
36
+ def __init__(self) -> None:
37
+ super().__init__()
38
+ self.findings = []
39
+
40
+ def __repr__(self) -> str:
41
+ return f"Mapping(<{len(self)} placeholders, values hidden>)"
42
+
43
+
44
+ def _check_lang(lang: str | None) -> None:
45
+ # shortcut: lang has no effect yet (the checksum recognisers are language-neutral);
46
+ # it selects context words once names and addresses arrive.
47
+ if lang is not None and lang.lower().split("-")[0] not in LANGS:
48
+ raise ValueError(f"unsupported lang {lang!r}, expected one of {sorted(LANGS)}")
49
+
50
+
51
+ def detect(text: str, lang: str | None = None, *, keep: Iterable[str] = ()) -> list[Finding]:
52
+ """Find personal data. Overlaps go to the higher confidence, then the longer match.
53
+
54
+ ``keep``: exact values (case-insensitive) that must stay in the text.
55
+ """
56
+ _check_lang(lang)
57
+ kept = {k.casefold() for k in keep}
58
+ found = sorted(
59
+ (c for d in DETECTORS for c in d(text) if text[c.start : c.end].casefold() not in kept),
60
+ key=lambda c: (-c.confidence, c.start - c.end, c.start),
61
+ )
62
+ taken: list[tuple[int, int]] = []
63
+ out: list[Finding] = []
64
+ for c in found:
65
+ if all(c.end <= s or c.start >= e for s, e in taken):
66
+ taken.append((c.start, c.end))
67
+ out.append(Finding(c.kind, c.start, c.end, c.confidence, c.detector, c.checksum_ok))
68
+ return sorted(out, key=lambda f: f.start)
69
+
70
+
71
+ def anonymize(
72
+ text: str, lang: str | None = None, *, keep: Iterable[str] = ()
73
+ ) -> tuple[str, Mapping]:
74
+ """Replace personal data by placeholders like ``[AHV_1]``.
75
+
76
+ Identical values get the same placeholder. Returns ``(text, mapping)``; the mapping has
77
+ the real values and a ``findings`` list. Storing the mapping is up to the caller.
78
+ """
79
+ # Never reuse a placeholder that already stands in the text.
80
+ in_text = {_name(m) for m in _TOKEN.finditer(text)}
81
+ mapping = Mapping()
82
+ by_value: dict[tuple[str, str], str] = {}
83
+ counters: dict[str, int] = {}
84
+ parts: list[str] = []
85
+ pos = 0
86
+ for f in detect(text, lang, keep=keep):
87
+ value = text[f.start : f.end]
88
+ ph = by_value.get((f.kind, value))
89
+ if ph is None:
90
+ n = counters.get(f.kind, 0)
91
+ while True:
92
+ n += 1
93
+ ph = f"[{f.kind}_{n}]"
94
+ if ph not in in_text:
95
+ break
96
+ counters[f.kind] = n
97
+ by_value[(f.kind, value)] = ph
98
+ mapping[ph] = value
99
+ mapping.findings.append(replace(f, placeholder=ph))
100
+ parts += (text[pos : f.start], ph)
101
+ pos = f.end
102
+ parts.append(text[pos:])
103
+ return "".join(parts), mapping
104
+
105
+
106
+ def _name(m: re.Match[str]) -> str:
107
+ return f"[{m[1].upper()}_{int(m[2])}]"
108
+
109
+
110
+ def restore(text: str, mapping: AbstractMapping[str, str]) -> str:
111
+ """Put the real values back. Single pass, so restored values are never rescanned.
112
+
113
+ Unknown placeholders stay unchanged.
114
+ """
115
+ return _TOKEN.sub(lambda m: mapping.get(_name(m), m[0]), text)
@@ -0,0 +1,85 @@
1
+ """Swiss recognisers. Each yields candidates; overlaps are resolved in core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Iterator
7
+ from dataclasses import dataclass, replace
8
+
9
+ from ._checks import ahv_ok, iban_ok, uid_ok
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Candidate:
14
+ kind: str
15
+ start: int
16
+ end: int
17
+ confidence: float
18
+ detector: str
19
+ checksum_ok: bool | None = None
20
+
21
+
22
+ def _digits(s: str) -> str:
23
+ return "".join(c for c in s if c.isdigit())
24
+
25
+
26
+ def _checked(
27
+ kind: str,
28
+ detector: str,
29
+ pattern: re.Pattern[str],
30
+ ok: Callable[[str], bool],
31
+ clean: Callable[[str], str],
32
+ text: str,
33
+ valid: float,
34
+ invalid: float,
35
+ ) -> Iterator[Candidate]:
36
+ # Invalid check digits are still reported, with lower confidence (better too many than too few).
37
+ for m in pattern.finditer(text):
38
+ good = ok(clean(m[0]))
39
+ yield Candidate(kind, m.start(), m.end(), valid if good else invalid, detector, good)
40
+
41
+
42
+ _AHV = re.compile(r"(?<!\d)756[ .\-]?\d{4}[ .\-]?\d{4}[ .\-]?\d{2}(?!\d)")
43
+ _IBAN = re.compile(r"(?<![A-Za-z0-9])(?:CH|LI)\d{2}(?: ?[0-9A-Z]){17}(?![A-Za-z0-9])", re.I)
44
+ _UID = re.compile(r"(?<![A-Za-z0-9])CHE[- ]?\d{3}[. ]?\d{3}[. ]?\d{3}(?!\d)", re.I)
45
+ _EMAIL = re.compile(r"(?<![\w.+\-])[\w.+\-]+@[\w\-]+(?:\.[\w\-]+)*\.[^\W\d_]{2,}")
46
+ _PHONE = re.compile(
47
+ r"""(?<![\w+])(?<!\d[ .\-/])
48
+ (?:(?:\+|00)41[ .\-]?(?:\(0\)[ .\-]?)?|\(?0)
49
+ [1-9]\d\)?
50
+ [ .\-/]?\d{3}[ .\-]?\d{2}[ .\-]?\d{2}(?!\d)""",
51
+ re.X,
52
+ )
53
+
54
+
55
+ def ahv(text: str) -> Iterator[Candidate]:
56
+ for c in _checked("AHV", "ahv-ean13", _AHV, ahv_ok, _digits, text, 0.99, 0.6):
57
+ if "." not in text[c.start : c.end]: # bare digits are weaker evidence than the dotted form
58
+ c = replace(c, confidence=0.9 if c.checksum_ok else 0.4)
59
+ yield c
60
+
61
+
62
+ def iban(text: str) -> Iterator[Candidate]:
63
+ yield from _checked(
64
+ "IBAN", "iban-mod97", _IBAN, iban_ok, lambda s: s.replace(" ", "").upper(), text, 0.99, 0.6
65
+ )
66
+
67
+
68
+ def uid(text: str) -> Iterator[Candidate]:
69
+ yield from _checked("UID", "uid-mod11", _UID, uid_ok, _digits, text, 0.98, 0.6)
70
+
71
+
72
+ def email(text: str) -> Iterator[Candidate]:
73
+ for m in _EMAIL.finditer(text):
74
+ yield Candidate("EMAIL", m.start(), m.end(), 0.95, "email-regex")
75
+
76
+
77
+ def phone(text: str) -> Iterator[Candidate]:
78
+ for m in _PHONE.finditer(text):
79
+ s = m[0]
80
+ international = s.startswith(("+", "00"))
81
+ confidence = 0.95 if international else 0.85 if not s.isdigit() else 0.6
82
+ yield Candidate("PHONE", m.start(), m.end(), confidence, "phone-ch")
83
+
84
+
85
+ DETECTORS: tuple[Callable[[str], Iterator[Candidate]], ...] = (ahv, iban, uid, email, phone)
isomorphism/py.typed ADDED
File without changes
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: isomorphism
3
+ Version: 0.1.0
4
+ Summary: Reversible anonymisation of Swiss personal data (AHV, IBAN, UID, phone, email) before text goes to a language model. Fully local, no dependencies.
5
+ Project-URL: Homepage, https://packages.sweber.dev/isomorphism
6
+ Project-URL: Documentation, https://packages.sweber.dev/isomorphism/docs
7
+ Project-URL: Repository, https://github.com/Weber-Development/isomorphism
8
+ Project-URL: Changelog, https://github.com/Weber-Development/isomorphism/blob/main/CHANGELOG.md
9
+ Author: Seya Weber
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ahv,anonymization,dsg,iban,llm,pii,privacy,pseudonymization,switzerland
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+
24
+ # isomorphism
25
+
26
+ Reversible anonymisation of Swiss personal data for Python. It replaces AHV numbers, IBANs, UID numbers, phone numbers and email addresses with placeholders before a text goes to a language model, and puts the real values back into the answer.
27
+
28
+ - **Local only.** No network calls, no telemetry, no dependencies, nothing downloaded at runtime.
29
+ - **Reversible.** `anonymize(text)` returns the text with placeholders and a mapping, `restore(text, mapping)` undoes it.
30
+ - **Same value, same placeholder.** The model can follow that `[AHV_1]` appears three times.
31
+ - **Checksums.** AHV (EAN-13), IBAN (mod 97) and UID (mod 11) are verified. Numbers that look right but fail the check are reported anyway, with lower confidence.
32
+ - **Audit-friendly.** Every hit comes back as kind, position, confidence and recogniser, without the value.
33
+
34
+ Python 3.12 or newer. MIT license.
35
+
36
+ ```sh
37
+ pip install isomorphism
38
+ ```
39
+
40
+ ```python
41
+ from isomorphism import anonymize, restore
42
+
43
+ text = "Anna Muster, AHV 756.1234.5678.97, IBAN CH93 0076 2011 6238 5295 7, Tel. 071 123 45 67"
44
+ safe, mapping = anonymize(text, lang="de")
45
+ # 'Anna Muster, AHV [AHV_1], IBAN [IBAN_1], Tel. [PHONE_1]'
46
+
47
+ answer = call_your_model(safe) # the model only sees placeholders
48
+ print(restore(answer, mapping)) # real values are back
49
+ ```
50
+
51
+ The mapping holds the real values. This package never logs or stores it; keeping it (in memory, per request) is up to you. `repr(mapping)` shows only the number of placeholders.
52
+
53
+ ## What it finds
54
+
55
+ | Kind | Placeholder | Formats | Check |
56
+ |---|---|---|---|
57
+ | AHV number | `[AHV_n]` | `756.1234.5678.97`, with spaces or without separators | prefix 756, EAN-13 |
58
+ | IBAN (CH, LI) | `[IBAN_n]` | with or without spaces, upper or lower case | mod 97 |
59
+ | UID number | `[UID_n]` | `CHE-123.456.788`, `CHE123456788`; `MWST`/`TVA`/`IVA`/`HR` stay in the text | mod 11 |
60
+ | Phone (Switzerland) | `[PHONE_n]` | `+41 71 123 45 67`, `0041 …`, `+41 (0)71 …`, `071 123 45 67`, `071/123 45 67`, `(071) …`, `0711234567` | format |
61
+ | Email | `[EMAIL_n]` | | format |
62
+
63
+ `detect(text)` returns the hits without changing the text. Passing `keep=["info@gemeinde.example"]` leaves exact values untouched (case-insensitive), for example the address of an authority.
64
+
65
+ ## Measured on the test cases
66
+
67
+ `tests/cases.py` holds invented Swiss texts (payslip extract, client email, receipt, tax query, French and Italian letters, written-out variants, numbers with wrong check digits, and a text full of look-alikes that must not match). The numbers below come from `python -m tests.measure`, and a test fails if they are out of date or if a listed value slips through.
68
+
69
+ <!-- measure:start -->
70
+ | Kind | Expected | Found | Missed | False alarms |
71
+ |---|---|---|---|---|
72
+ | AHV | 10 | 10 | 0 | 0 |
73
+ | IBAN | 10 | 10 | 0 | 0 |
74
+ | UID | 9 | 9 | 0 | 0 |
75
+ | PHONE | 15 | 15 | 0 | 0 |
76
+ | EMAIL | 6 | 6 | 0 | 0 |
77
+ <!-- measure:end -->
78
+
79
+ This is a small invented set, not a benchmark on real documents. Read it as a regression guard, and test with your own (invented) samples before you rely on it.
80
+
81
+ ## Not in this version
82
+
83
+ Personal names, postal addresses, company names, dates of birth, account and card numbers, and restoring a streamed answer arrive in the next version. Until then names and addresses stay in the text. `lang` ("de", "fr", "it", "en") is accepted and checked already but has no effect yet. Foreign IBANs and the old 11-digit AHV format are not recognised.
84
+
85
+ No tool finds all personal data. Treat the result as one protective layer, not as a guarantee of anonymity.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ uv venv && uv pip install -e . pytest ruff mypy
91
+ ruff check . && ruff format --check . && mypy && pytest
92
+ ```
93
+
94
+ Docs: <https://packages.sweber.dev/isomorphism/docs>
@@ -0,0 +1,9 @@
1
+ isomorphism/__init__.py,sha256=T8_xkinGuGhYmu3TuRRyFaoNprtMlHLAOXqrgtBTut0,317
2
+ isomorphism/_checks.py,sha256=jk1ziyCxfEqlDCfFwpNZcTgyxYwXZqdIIiZg-DnjiBw,1041
3
+ isomorphism/core.py,sha256=Ci6Vwh7WlSW8XftZ87R0E3xi0UCYVYcap4B_YBhc-Kk,3944
4
+ isomorphism/detectors.py,sha256=ijxEpzXUbUu_LKGjEYsQSaY9gxc4gWT2DzGSJyYkG50,2699
5
+ isomorphism/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ isomorphism-0.1.0.dist-info/METADATA,sha256=0S9Bvtwe-wfMfhTq9mcMYvlKjwXtoUfXVsbDpXuObgM,4798
7
+ isomorphism-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ isomorphism-0.1.0.dist-info/licenses/LICENSE,sha256=QFF5JWsp6cFDT2lWuxtdGEbj3QbvysjcM5Rj15Prk2U,1061
9
+ isomorphism-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seya
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.