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.
Files changed (71) hide show
  1. {deidkit-0.1.1/src/deidkit.egg-info → deidkit-0.1.2}/PKG-INFO +1 -1
  2. {deidkit-0.1.1 → deidkit-0.1.2}/pyproject.toml +1 -1
  3. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/api.py +21 -1
  4. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/pipeline.py +13 -6
  5. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/io.py +14 -3
  6. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/version.py +1 -1
  7. {deidkit-0.1.1 → deidkit-0.1.2/src/deidkit.egg-info}/PKG-INFO +1 -1
  8. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/SOURCES.txt +1 -0
  9. deidkit-0.1.2/tests/test_qa_hardening.py +89 -0
  10. {deidkit-0.1.1 → deidkit-0.1.2}/LICENSE +0 -0
  11. {deidkit-0.1.1 → deidkit-0.1.2}/MANIFEST.in +0 -0
  12. {deidkit-0.1.1 → deidkit-0.1.2}/README.md +0 -0
  13. {deidkit-0.1.1 → deidkit-0.1.2}/examples/benchmark.py +0 -0
  14. {deidkit-0.1.1 → deidkit-0.1.2}/examples/make_sample.py +0 -0
  15. {deidkit-0.1.1 → deidkit-0.1.2}/examples/policy.example.yaml +0 -0
  16. {deidkit-0.1.1 → deidkit-0.1.2}/examples/policy.meridian.yaml +0 -0
  17. {deidkit-0.1.1 → deidkit-0.1.2}/examples/quickstart.md +0 -0
  18. {deidkit-0.1.1 → deidkit-0.1.2}/setup.cfg +0 -0
  19. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/__init__.py +0 -0
  20. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/__main__.py +0 -0
  21. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/cli.py +0 -0
  22. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/config.py +0 -0
  23. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/NOTICES.md +0 -0
  24. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/context_triggers_en.txt +0 -0
  25. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/context_triggers_es.txt +0 -0
  26. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/en_given_names.txt +0 -0
  27. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/en_surnames.txt +0 -0
  28. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/es_given_names.txt +0 -0
  29. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/es_surnames.txt +0 -0
  30. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/honorifics_en.txt +0 -0
  31. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/honorifics_es.txt +0 -0
  32. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/medical_stoplist_en.txt +0 -0
  33. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/medical_stoplist_es.txt +0 -0
  34. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/data/medical_vocab.txt +0 -0
  35. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/dates.py +0 -0
  36. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/__init__.py +0 -0
  37. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/checksums.py +0 -0
  38. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/context.py +0 -0
  39. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/gazetteer.py +0 -0
  40. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/patterns.py +0 -0
  41. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/spacy_ner.py +0 -0
  42. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/detect/types.py +0 -0
  43. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/engine.py +0 -0
  44. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/generators/__init__.py +0 -0
  45. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/generators/names.py +0 -0
  46. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/generators/surrogates.py +0 -0
  47. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/learn.py +0 -0
  48. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/mapping.py +0 -0
  49. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/report.py +0 -0
  50. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/resources.py +0 -0
  51. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/schema.py +0 -0
  52. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/secret.py +0 -0
  53. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit/textnorm.py +0 -0
  54. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/dependency_links.txt +0 -0
  55. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/entry_points.txt +0 -0
  56. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/requires.txt +0 -0
  57. {deidkit-0.1.1 → deidkit-0.1.2}/src/deidkit.egg-info/top_level.txt +0 -0
  58. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_api.py +0 -0
  59. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_checksums.py +0 -0
  60. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_dates.py +0 -0
  61. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_db.py +0 -0
  62. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_detect.py +0 -0
  63. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_engine.py +0 -0
  64. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_hardening.py +0 -0
  65. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_known_phi.py +0 -0
  66. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_learn.py +0 -0
  67. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_medical_vocab.py +0 -0
  68. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_names.py +0 -0
  69. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_qa_fixes.py +0 -0
  70. {deidkit-0.1.1 → deidkit-0.1.2}/tests/test_qa_freetext_leaks.py +0 -0
  71. {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.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.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/cp1252)."""
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
- ident = f"{schema}.{table}" if schema else table
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,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deidkit
3
- Version: 0.1.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
@@ -65,4 +65,5 @@ tests/test_medical_vocab.py
65
65
  tests/test_names.py
66
66
  tests/test_qa_fixes.py
67
67
  tests/test_qa_freetext_leaks.py
68
+ tests/test_qa_hardening.py
68
69
  tests/test_schema_lang.py
@@ -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