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.
- isomorphism/__init__.py +8 -0
- isomorphism/_checks.py +29 -0
- isomorphism/core.py +115 -0
- isomorphism/detectors.py +85 -0
- isomorphism/py.typed +0 -0
- isomorphism-0.1.0.dist-info/METADATA +94 -0
- isomorphism-0.1.0.dist-info/RECORD +9 -0
- isomorphism-0.1.0.dist-info/WHEEL +4 -0
- isomorphism-0.1.0.dist-info/licenses/LICENSE +21 -0
isomorphism/__init__.py
ADDED
|
@@ -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)
|
isomorphism/detectors.py
ADDED
|
@@ -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,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.
|