deidkit 0.1.1__tar.gz → 0.1.2__tar.gz
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.
- {deidkit-0.1.1/src/deidkit.egg-info → deidkit-0.1.2}/PKG-INFO +1 -1
- {deidkit-0.1.1 → deidkit-0.1.2}/pyproject.toml +1 -1
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/api.py +21 -1
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/pipeline.py +13 -6
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/io.py +14 -3
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/version.py +1 -1
- {deidkit-0.1.1 → deidkit-0.1.2/src/deidkit.egg-info}/PKG-INFO +1 -1
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/SOURCES.txt +1 -0
- deidkit-0.1.2/tests/test_qa_hardening.py +89 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/LICENSE +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/MANIFEST.in +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/README.md +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/examples/benchmark.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/examples/make_sample.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/examples/policy.example.yaml +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/examples/policy.meridian.yaml +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/examples/quickstart.md +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/setup.cfg +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/__init__.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/__main__.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/cli.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/config.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/NOTICES.md +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/context_triggers_en.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/context_triggers_es.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/en_given_names.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/en_surnames.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/es_given_names.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/es_surnames.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/honorifics_en.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/honorifics_es.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/medical_stoplist_en.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/medical_stoplist_es.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/medical_vocab.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/dates.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/__init__.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/checksums.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/context.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/gazetteer.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/patterns.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/spacy_ner.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/types.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/engine.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/generators/__init__.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/generators/names.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/generators/surrogates.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/learn.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/mapping.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/report.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/resources.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/schema.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/secret.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/textnorm.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/dependency_links.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/entry_points.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/requires.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/top_level.txt +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_api.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_checksums.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_dates.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_db.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_detect.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_engine.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_hardening.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_known_phi.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_learn.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_medical_vocab.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_names.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_qa_fixes.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_qa_freetext_leaks.py +0 -0
- {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_schema_lang.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deidkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Schema-driven pseudonymization for clinical/tabular datasets: synthetic names, interval-preserving date shifting, multi-stage free-text PII detection, and before/after audit exports.
|
|
5
5
|
Author: Meridian Data
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deidkit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Schema-driven pseudonymization for clinical/tabular datasets: synthetic names, interval-preserving date shifting, multi-stage free-text PII detection, and before/after audit exports."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -62,6 +62,26 @@ class Result:
|
|
|
62
62
|
f"cells_changed={s['cells_changed']} review={s['review_detections']}>")
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def _safe_table_name(name) -> str:
|
|
66
|
+
"""A table name becomes an output filename, so it must be a bare name — never
|
|
67
|
+
a path. Reject separators / ``..`` / absolute paths so a crafted table name
|
|
68
|
+
(e.g. a dict key like ``"../escaped"``) cannot write outside the ship folder."""
|
|
69
|
+
n = str(name)
|
|
70
|
+
if (
|
|
71
|
+
n in ("", ".", "..")
|
|
72
|
+
or "/" in n
|
|
73
|
+
or "\\" in n
|
|
74
|
+
or "\x00" in n
|
|
75
|
+
or os.path.isabs(n)
|
|
76
|
+
or os.path.basename(n) != n
|
|
77
|
+
):
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"unsafe table name {name!r}: table names become output filenames and "
|
|
80
|
+
f"must not contain path separators, '..', or be absolute."
|
|
81
|
+
)
|
|
82
|
+
return n
|
|
83
|
+
|
|
84
|
+
|
|
65
85
|
def _find_dictionary(input_path) -> Optional[str]:
|
|
66
86
|
"""Look for a data-dictionary JSON next to / inside the input."""
|
|
67
87
|
if not isinstance(input_path, str):
|
|
@@ -191,7 +211,7 @@ def deidentify(
|
|
|
191
211
|
os.makedirs(out, exist_ok=True)
|
|
192
212
|
for name, df in clean.items():
|
|
193
213
|
ext = fmt or "csv"
|
|
194
|
-
io.write_table(df, os.path.join(out, f"{name}.{ext}"), fmt=ext)
|
|
214
|
+
io.write_table(df, os.path.join(out, f"{_safe_table_name(name)}.{ext}"), fmt=ext)
|
|
195
215
|
deid.save_mapping()
|
|
196
216
|
# audit + review queue contain raw PII -> private dir, not the ship dir
|
|
197
217
|
report_path = report or os.path.join(private_dir or out, "deid_audit.PRIVATE.xlsx")
|
|
@@ -101,7 +101,7 @@ class Detector:
|
|
|
101
101
|
persons = [d for d in raw if d.entity_type == PERSON]
|
|
102
102
|
|
|
103
103
|
accepted_idents = self._resolve_identifiers(idents)
|
|
104
|
-
person_dets = self._resolve_persons(persons, accepted_idents)
|
|
104
|
+
person_dets = self._resolve_persons(persons, accepted_idents, text)
|
|
105
105
|
|
|
106
106
|
final = accepted_idents + person_dets
|
|
107
107
|
final.sort(key=lambda d: (d.start, -(d.end - d.start)))
|
|
@@ -140,9 +140,9 @@ class Detector:
|
|
|
140
140
|
return kept
|
|
141
141
|
|
|
142
142
|
def _resolve_persons(
|
|
143
|
-
self, persons: List[Detection], idents: List[Detection]
|
|
143
|
+
self, persons: List[Detection], idents: List[Detection], text: str
|
|
144
144
|
) -> List[Detection]:
|
|
145
|
-
clusters = _merge_person_spans(persons)
|
|
145
|
+
clusters = _merge_person_spans(persons, text)
|
|
146
146
|
out: List[Detection] = []
|
|
147
147
|
for c in clusters:
|
|
148
148
|
# suppress person spans overlapping an accepted identifier
|
|
@@ -170,8 +170,14 @@ class Detector:
|
|
|
170
170
|
|
|
171
171
|
|
|
172
172
|
# --------------------------------------------------------------------------- #
|
|
173
|
-
def _merge_person_spans(persons: List[Detection]) -> List[Detection]:
|
|
174
|
-
"""Merge overlapping person detections into clusters (union span + meta).
|
|
173
|
+
def _merge_person_spans(persons: List[Detection], text: str) -> List[Detection]:
|
|
174
|
+
"""Merge overlapping person detections into clusters (union span + meta).
|
|
175
|
+
|
|
176
|
+
``.text`` is recomputed from ``text`` for the *final* span of each cluster, so
|
|
177
|
+
a merged span (e.g. 0..10 built from 'Juan' + 'Juan Pérez') reports the whole
|
|
178
|
+
'Juan Pérez', not the first fragment. Downstream code keys the surrogate and
|
|
179
|
+
the audit "before" on ``.text``, so a stale fragment would break referential
|
|
180
|
+
consistency and the audit."""
|
|
175
181
|
if not persons:
|
|
176
182
|
return []
|
|
177
183
|
persons = sorted(persons, key=lambda d: (d.start, d.end))
|
|
@@ -180,12 +186,13 @@ def _merge_person_spans(persons: List[Detection]) -> List[Detection]:
|
|
|
180
186
|
for d in persons[1:]:
|
|
181
187
|
if d.start < cur.end: # overlap
|
|
182
188
|
cur.end = max(cur.end, d.end)
|
|
183
|
-
cur.text += "" # placeholder; text re-set below
|
|
184
189
|
_merge_meta(cur, d)
|
|
185
190
|
else:
|
|
186
191
|
clusters.append(cur)
|
|
187
192
|
cur = _clone(d)
|
|
188
193
|
clusters.append(cur)
|
|
194
|
+
for c in clusters: # align .text with the (possibly widened) span
|
|
195
|
+
c.text = text[c.start : c.end]
|
|
189
196
|
return clusters
|
|
190
197
|
|
|
191
198
|
|
|
@@ -27,9 +27,14 @@ def _require_pyarrow(action: str) -> None:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _read_csv(path: str, sep: str, **kwargs) -> pd.DataFrame:
|
|
30
|
-
"""Read a CSV/TSV, tolerating non-UTF-8 clinical exports (latin-1
|
|
30
|
+
"""Read a CSV/TSV, tolerating non-UTF-8 clinical exports (cp1252 / latin-1).
|
|
31
|
+
|
|
32
|
+
cp1252 is tried before latin-1 because Windows exports use its 0x80-0x9F range
|
|
33
|
+
for smart quotes / dashes; latin-1 would silently mis-decode those (it never
|
|
34
|
+
raises), so ordering it last keeps it only as the can't-fail fallback.
|
|
35
|
+
"""
|
|
31
36
|
kwargs.setdefault("dtype", str)
|
|
32
|
-
for enc in ("utf-8-sig", "latin-1"):
|
|
37
|
+
for enc in ("utf-8-sig", "cp1252", "latin-1"):
|
|
33
38
|
try:
|
|
34
39
|
return pd.read_csv(path, sep=sep, encoding=enc, **kwargs)
|
|
35
40
|
except UnicodeDecodeError:
|
|
@@ -180,5 +185,11 @@ def read_sql_table(connection_string: str, table: str, schema: Optional[str] = N
|
|
|
180
185
|
"`pip install --force-reinstall deidkit`."
|
|
181
186
|
) from exc
|
|
182
187
|
engine = create_engine(connection_string)
|
|
183
|
-
|
|
188
|
+
# Quote identifiers through the dialect so reserved words, dots, spaces, and
|
|
189
|
+
# quote characters in a table/schema name are escaped safely (never raw-
|
|
190
|
+
# interpolated) — robust and injection-safe for arbitrary names.
|
|
191
|
+
prep = engine.dialect.identifier_preparer
|
|
192
|
+
ident = prep.quote(table)
|
|
193
|
+
if schema:
|
|
194
|
+
ident = f"{prep.quote(schema)}.{ident}"
|
|
184
195
|
return pd.read_sql(f"SELECT * FROM {ident}", engine)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deidkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Schema-driven pseudonymization for clinical/tabular datasets: synthetic names, interval-preserving date shifting, multi-stage free-text PII detection, and before/after audit exports.
|
|
5
5
|
Author: Meridian Data
|
|
6
6
|
License: MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Regression tests for the code-review hardening pass (2026-07-06).
|
|
2
|
+
|
|
3
|
+
1. dict table names must not path-traverse out of the ship folder (api.py).
|
|
4
|
+
2. read_sql_table must quote identifiers (reserved/special names) safely (io.py).
|
|
5
|
+
3. merged person spans must report the full span text, not the first fragment
|
|
6
|
+
(pipeline.py) — surrogate + audit key on .text.
|
|
7
|
+
4. CSV reader must decode cp1252 smart punctuation correctly (io.py).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import pytest
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
|
|
16
|
+
import deidkit as dk
|
|
17
|
+
from deidkit.detect.pipeline import _merge_person_spans
|
|
18
|
+
from deidkit.detect.types import Detection, PERSON
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- 1. path traversal ----------------------------------------------------- #
|
|
22
|
+
@pytest.mark.parametrize("bad", ["../escaped", "a/b", "..", "/abs", "x\\y"])
|
|
23
|
+
def test_dict_table_name_traversal_rejected(tmp_path, bad):
|
|
24
|
+
df = pd.DataFrame({"patient_id": ["P1"], "note_full": ["hi"]})
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
dk.deidentify({bad: df}, out=str(tmp_path / "out"), secret="s")
|
|
27
|
+
# nothing was written outside the (never-created) out dir
|
|
28
|
+
assert not (tmp_path / "escaped.csv").exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_normal_table_name_still_writes(tmp_path):
|
|
32
|
+
df = pd.DataFrame({"patient_id": ["P1"], "note_full": ["hi"]})
|
|
33
|
+
dk.deidentify({"tbl_notes": df}, out=str(tmp_path / "out"), secret="s")
|
|
34
|
+
assert (tmp_path / "out" / "tbl_notes.csv").exists()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- 2. SQL identifier quoting -------------------------------------------- #
|
|
38
|
+
def test_read_sql_table_quotes_reserved_and_normal_names(tmp_path):
|
|
39
|
+
url = f"sqlite:///{tmp_path/'t.db'}"
|
|
40
|
+
eng = sa.create_engine(url)
|
|
41
|
+
with eng.begin() as c:
|
|
42
|
+
c.execute(sa.text("CREATE TABLE tbl_notes (patient_id TEXT, note TEXT)"))
|
|
43
|
+
c.execute(sa.text("INSERT INTO tbl_notes VALUES ('P1','hi')"))
|
|
44
|
+
c.execute(sa.text('CREATE TABLE "Order" (id TEXT)')) # reserved keyword
|
|
45
|
+
c.execute(sa.text("INSERT INTO \"Order\" VALUES ('x')"))
|
|
46
|
+
assert len(dk.io.read_sql_table(url, "tbl_notes")) == 1
|
|
47
|
+
assert len(dk.io.read_sql_table(url, "Order")) == 1 # raw f-string would fail
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- 3. merged person span text ------------------------------------------- #
|
|
51
|
+
def test_merged_span_text_matches_full_span():
|
|
52
|
+
src = "Juan Perez fue visto"
|
|
53
|
+
merged = _merge_person_spans(
|
|
54
|
+
[Detection(0, 4, "Juan", PERSON, {"gazetteer"}),
|
|
55
|
+
Detection(0, 10, "Juan Perez", PERSON, {"ner"})],
|
|
56
|
+
src,
|
|
57
|
+
)[0]
|
|
58
|
+
assert (merged.start, merged.end) == (0, 10)
|
|
59
|
+
assert merged.text == "Juan Perez" # not the stale "Juan"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_single_span_text_unchanged():
|
|
63
|
+
src = "hola Juan adios"
|
|
64
|
+
out = _merge_person_spans([Detection(5, 9, "Juan", PERSON, {"gazetteer"})], src)[0]
|
|
65
|
+
assert out.text == "Juan"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_merged_span_surrogate_and_audit_use_full_name():
|
|
69
|
+
"""End-to-end: when spans merge, the replaced span, the surrogate identity,
|
|
70
|
+
and the audit 'before' are all the full name — internally consistent."""
|
|
71
|
+
deid = dk.Deidentifier(dk.Policy(lang="es", mode="conservative"),
|
|
72
|
+
secret="qa-hardening")
|
|
73
|
+
df = pd.DataFrame({"patient_id": ["P1"],
|
|
74
|
+
"note_full": ["Paciente Juan Pérez García consulta."]})
|
|
75
|
+
out = deid.run_table(df, "t")["note_full"].iloc[0]
|
|
76
|
+
assert "Juan Pérez García" not in out # full name scrubbed
|
|
77
|
+
# every freetext PERSON audit row's "before" equals the text that was replaced
|
|
78
|
+
for ch in deid.changes:
|
|
79
|
+
if ch["column"] == "note_full" and ch["entity_type"] == "PERSON":
|
|
80
|
+
assert ch["before"] in "Paciente Juan Pérez García consulta."
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- 4. cp1252 decoding ---------------------------------------------------- #
|
|
84
|
+
def test_cp1252_smart_quotes_decode(tmp_path):
|
|
85
|
+
p = tmp_path / "cp.csv"
|
|
86
|
+
# 0x93/0x94 are curly double quotes in cp1252; latin-1 would mangle them
|
|
87
|
+
p.write_bytes(b"patient_id,note\nP1,\x93hola\x94\n")
|
|
88
|
+
val = dk.io.read_table(str(p))["note"].iloc[0]
|
|
89
|
+
assert val == "“hola”" # “hola”
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|