deidkit 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.
Files changed (45) hide show
  1. deidkit/__init__.py +74 -0
  2. deidkit/__main__.py +6 -0
  3. deidkit/api.py +316 -0
  4. deidkit/cli.py +399 -0
  5. deidkit/config.py +285 -0
  6. deidkit/data/NOTICES.md +25 -0
  7. deidkit/data/context_triggers_en.txt +17 -0
  8. deidkit/data/context_triggers_es.txt +36 -0
  9. deidkit/data/en_given_names.txt +203 -0
  10. deidkit/data/en_surnames.txt +118 -0
  11. deidkit/data/es_given_names.txt +273 -0
  12. deidkit/data/es_surnames.txt +168 -0
  13. deidkit/data/honorifics_en.txt +22 -0
  14. deidkit/data/honorifics_es.txt +35 -0
  15. deidkit/data/medical_stoplist_en.txt +345 -0
  16. deidkit/data/medical_stoplist_es.txt +499 -0
  17. deidkit/data/medical_vocab.txt +21445 -0
  18. deidkit/dates.py +229 -0
  19. deidkit/detect/__init__.py +19 -0
  20. deidkit/detect/checksums.py +160 -0
  21. deidkit/detect/context.py +137 -0
  22. deidkit/detect/gazetteer.py +158 -0
  23. deidkit/detect/patterns.py +147 -0
  24. deidkit/detect/pipeline.py +234 -0
  25. deidkit/detect/spacy_ner.py +53 -0
  26. deidkit/detect/types.py +53 -0
  27. deidkit/engine.py +644 -0
  28. deidkit/generators/__init__.py +4 -0
  29. deidkit/generators/names.py +111 -0
  30. deidkit/generators/surrogates.py +76 -0
  31. deidkit/io.py +184 -0
  32. deidkit/learn.py +83 -0
  33. deidkit/mapping.py +108 -0
  34. deidkit/report.py +219 -0
  35. deidkit/resources.py +100 -0
  36. deidkit/schema.py +234 -0
  37. deidkit/secret.py +77 -0
  38. deidkit/textnorm.py +66 -0
  39. deidkit/version.py +3 -0
  40. deidkit-0.1.0.dist-info/METADATA +768 -0
  41. deidkit-0.1.0.dist-info/RECORD +45 -0
  42. deidkit-0.1.0.dist-info/WHEEL +5 -0
  43. deidkit-0.1.0.dist-info/entry_points.txt +2 -0
  44. deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. deidkit-0.1.0.dist-info/top_level.txt +1 -0
deidkit/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """deidkit — schema-driven pseudonymization for clinical / tabular datasets.
2
+
3
+ Quick start (library)::
4
+
5
+ import deidkit as dk
6
+
7
+ policy = dk.Policy(lang="es", mode="conservative", ignore=["blood_type"])
8
+ deid = dk.Deidentifier(
9
+ policy,
10
+ secret="keep-this-secret",
11
+ dict_index=dk.load_dictionary("diccionario_columnas_dataset.json"),
12
+ mapping_path="private_map.json",
13
+ )
14
+
15
+ clean = deid.run_table(df, table="tbl_notes") # a pandas DataFrame in, out
16
+ deid.save_mapping()
17
+ deid.write_report("audit.xlsx")
18
+
19
+ What it does, per the policy:
20
+
21
+ * replaces person names (structured columns *and* names inside free text) with
22
+ realistic, gender/structure-preserving synthetic names,
23
+ * shifts every date by a per-patient offset that preserves all intervals,
24
+ * replaces identifiers (cédula, phone, email, MRN ...) with stable surrogates,
25
+ * records a full before/after audit of every change.
26
+ """
27
+
28
+ from .version import __version__
29
+ from .config import (
30
+ FieldRule,
31
+ Policy,
32
+ AGE_BAND,
33
+ DATE_SHIFT,
34
+ DROP,
35
+ FREETEXT,
36
+ GENERALIZE_YEAR,
37
+ IDENTIFIER,
38
+ PASSTHROUGH,
39
+ REDACT,
40
+ SYNTHETIC_NAME,
41
+ )
42
+ from .engine import Deidentifier
43
+ from .detect import Detector
44
+ from .mapping import Mapping
45
+ from .schema import load_dictionary, parse_spark_schema
46
+ from .secret import generate_secret
47
+ from .api import deidentify, deidentify_dataframe, deidentify_database, Result
48
+ from . import io
49
+
50
+ __all__ = [
51
+ "__version__",
52
+ "deidentify",
53
+ "deidentify_dataframe",
54
+ "deidentify_database",
55
+ "Result",
56
+ "Policy",
57
+ "FieldRule",
58
+ "Deidentifier",
59
+ "Detector",
60
+ "Mapping",
61
+ "io",
62
+ "load_dictionary",
63
+ "parse_spark_schema",
64
+ "generate_secret",
65
+ "PASSTHROUGH",
66
+ "DATE_SHIFT",
67
+ "SYNTHETIC_NAME",
68
+ "IDENTIFIER",
69
+ "FREETEXT",
70
+ "GENERALIZE_YEAR",
71
+ "AGE_BAND",
72
+ "REDACT",
73
+ "DROP",
74
+ ]
deidkit/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m deidkit``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
deidkit/api.py ADDED
@@ -0,0 +1,316 @@
1
+ """One-call convenience API.
2
+
3
+ The whole tool in one function::
4
+
5
+ import deidkit
6
+
7
+ result = deidkit.deidentify("data/", out="deid/") # files in, files out
8
+ result = deidkit.deidentify(df) # -> Result; result.table is the clean df
9
+ df2 = deidkit.deidentify_dataframe(df) # DataFrame in, DataFrame out
10
+
11
+ ``deidentify`` returns a :class:`Result` (``.tables`` / ``.table`` / ``.summary``
12
+ / ``.report_path`` / ``.secret``); ``deidentify_dataframe`` unwraps to the single
13
+ DataFrame. Both handle secrets, schema discovery, the mapping and the audit
14
+ report for you, with safe defaults. Everything is still overridable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import glob
20
+ import os
21
+ import warnings
22
+ from dataclasses import dataclass
23
+ from typing import Any, Dict, List, Optional, Union
24
+
25
+ from . import io
26
+ from .config import Policy
27
+ from .engine import Deidentifier
28
+ from .schema import load_dictionary
29
+
30
+ try:
31
+ import pandas as pd
32
+ except ImportError: # pragma: no cover
33
+ pd = None
34
+
35
+
36
+ @dataclass
37
+ class Result:
38
+ """What :func:`deidentify` returns."""
39
+
40
+ tables: Dict[str, "pd.DataFrame"]
41
+ deid: Deidentifier
42
+ out: Optional[str]
43
+ report_path: Optional[str]
44
+ mapping_path: Optional[str]
45
+ secret: Optional[str] # set only when auto-generated, so you can save it
46
+ private_dir: Optional[str] = None # secret + mapping + audit (NEVER ship)
47
+
48
+ @property
49
+ def table(self):
50
+ """The single output DataFrame (convenience when one table went in)."""
51
+ if len(self.tables) == 1:
52
+ return next(iter(self.tables.values()))
53
+ raise ValueError(f"{len(self.tables)} tables — use .tables[name].")
54
+
55
+ @property
56
+ def summary(self) -> Dict[str, Any]:
57
+ return self.deid.summary()
58
+
59
+ def __repr__(self) -> str: # pragma: no cover
60
+ s = self.deid.summary()
61
+ return (f"<deidkit.Result tables={list(self.tables)} "
62
+ f"cells_changed={s['cells_changed']} review={s['review_detections']}>")
63
+
64
+
65
+ def _find_dictionary(input_path) -> Optional[str]:
66
+ """Look for a data-dictionary JSON next to / inside the input."""
67
+ if not isinstance(input_path, str):
68
+ return None
69
+ search_dir = input_path if os.path.isdir(input_path) else os.path.dirname(input_path) or "."
70
+ for path in sorted(glob.glob(os.path.join(search_dir, "*.json"))):
71
+ try:
72
+ idx = load_dictionary(path)
73
+ if idx:
74
+ return path
75
+ except Exception:
76
+ continue
77
+ return None
78
+
79
+
80
+ _POLICY_KEYS = {
81
+ "entity_key", "date_max_days", "lang", "mode", "name_style", "names_synthetic",
82
+ "shift_dates_in_text", "only", "ignore", "rules", "spacy_model",
83
+ "extra_name_files", "extra_stoplist_files",
84
+ "known_name_columns", "known_id_columns", "extra_known_names",
85
+ "known_token_min_len", "id_checksums", "use_medical_vocab",
86
+ }
87
+
88
+
89
+ def deidentify(
90
+ data: Union[str, "pd.DataFrame", Dict[str, "pd.DataFrame"]],
91
+ out: Optional[str] = None,
92
+ *,
93
+ policy: Optional[Policy] = None,
94
+ schema: Optional[str] = None,
95
+ secret: Optional[str] = None,
96
+ secret_file: Optional[str] = None,
97
+ mapping: Optional[str] = None,
98
+ report: Optional[str] = None,
99
+ private_dir: Optional[str] = None,
100
+ fmt: Optional[str] = None,
101
+ table: str = "table",
102
+ quiet: bool = True,
103
+ **policy_kwargs,
104
+ ) -> Result:
105
+ """De-identify files, a DataFrame, or a dict of DataFrames — in one call.
106
+
107
+ Parameters
108
+ ----------
109
+ data:
110
+ A path / glob / directory of tables, a single DataFrame, or a
111
+ ``{name: DataFrame}`` dict.
112
+ out:
113
+ Output directory. If given, writes de-identified tables, the audit
114
+ workbook, and the private mapping there. If omitted, nothing is written
115
+ and you just use ``result.tables`` / ``result.table``.
116
+ schema:
117
+ Data-dictionary JSON. Auto-discovered from the input folder if omitted.
118
+ secret / secret_file:
119
+ The keying secret. If none is available and ``out`` is set, a fresh
120
+ secret is generated and saved to ``<out>/deidkit-secret.key``. If no
121
+ ``out`` either, an ephemeral secret is generated (returned in
122
+ ``result.secret`` so you can persist it).
123
+ **policy_kwargs:
124
+ Any :class:`~deidkit.Policy` field, e.g. ``lang="multi"``,
125
+ ``mode="balanced"``, ``ignore=["blood_type"]``, ``name_style="placeholder"``.
126
+ """
127
+ if policy is None:
128
+ bad = set(policy_kwargs) - _POLICY_KEYS
129
+ if bad:
130
+ raise TypeError(f"Unknown option(s): {sorted(bad)}. "
131
+ f"Valid Policy fields: {sorted(_POLICY_KEYS)}")
132
+ policy = Policy(**policy_kwargs)
133
+
134
+ # --- resolve input into {name: DataFrame} -------------------------- #
135
+ if pd is not None and isinstance(data, pd.DataFrame):
136
+ tables = {table: data}
137
+ elif isinstance(data, dict):
138
+ tables = data
139
+ elif isinstance(data, str):
140
+ tables = io.read_tables(data)
141
+ if not tables:
142
+ raise FileNotFoundError(f"No tables found at {data!r}")
143
+ else: # pragma: no cover
144
+ raise TypeError("data must be a path, a DataFrame, or a dict of DataFrames.")
145
+
146
+ # --- schema auto-discovery ----------------------------------------- #
147
+ if schema is None:
148
+ schema = _find_dictionary(data)
149
+ dict_index = load_dictionary(schema) if schema else {}
150
+
151
+ # --- private dir: the secret, mapping and raw-PII audit live here, NEVER
152
+ # inside `out/` (which is the shippable de-identified data). Shipping
153
+ # `out/` must never leak the re-identification key. -------------------- #
154
+ if out and private_dir is None:
155
+ private_dir = out.rstrip("/\\") + "-PRIVATE"
156
+ if private_dir:
157
+ os.makedirs(private_dir, exist_ok=True)
158
+ try:
159
+ os.chmod(private_dir, 0o700)
160
+ except OSError:
161
+ pass
162
+
163
+ # --- secret: reuse, or generate a persisted/ephemeral one ---------- #
164
+ generated_secret = None
165
+ have_secret = bool(secret or secret_file or os.environ.get("DEIDKIT_SECRET"))
166
+ if not have_secret:
167
+ from .secret import generate_secret
168
+
169
+ generated_secret = generate_secret()
170
+ secret = generated_secret
171
+ if private_dir:
172
+ key_path = os.path.join(private_dir, "deidkit-secret.key")
173
+ with open(key_path, "w", encoding="utf-8") as fh:
174
+ fh.write(generated_secret + "\n")
175
+ os.chmod(key_path, 0o600)
176
+
177
+ if mapping is None and private_dir:
178
+ mapping = os.path.join(private_dir, "mapping.private.json")
179
+
180
+ deid = Deidentifier(
181
+ policy, secret=secret, secret_file=secret_file,
182
+ mapping_path=mapping, dict_index=dict_index,
183
+ )
184
+
185
+ clean = deid.run_dataset(tables)
186
+
187
+ report_path = None
188
+ if out:
189
+ if (fmt or "csv").lower() in ("parquet", "pq"):
190
+ io._require_pyarrow("write") # fail early, before writing anything
191
+ os.makedirs(out, exist_ok=True)
192
+ for name, df in clean.items():
193
+ ext = fmt or "csv"
194
+ io.write_table(df, os.path.join(out, f"{name}.{ext}"), fmt=ext)
195
+ deid.save_mapping()
196
+ # audit + review queue contain raw PII -> private dir, not the ship dir
197
+ report_path = report or os.path.join(private_dir or out, "deid_audit.PRIVATE.xlsx")
198
+ deid.write_report(report_path)
199
+ if private_dir:
200
+ deid.write_review(os.path.join(private_dir, "review.csv"))
201
+ if not quiet:
202
+ s = deid.summary()
203
+ print(f"[deidkit] {len(clean)} table(s), {s['cells_changed']} cells changed, "
204
+ f"{s['review_detections']} to review -> {out}")
205
+
206
+ return Result(
207
+ tables=clean, deid=deid, out=out, report_path=report_path,
208
+ mapping_path=mapping, secret=generated_secret, private_dir=private_dir,
209
+ )
210
+
211
+
212
+ def deidentify_dataframe(df, table: str = "table", **kwargs):
213
+ """De-identify a single DataFrame and return the clean DataFrame."""
214
+ return deidentify(df, table=table, **kwargs).table
215
+
216
+
217
+ def deidentify_database(
218
+ source: str,
219
+ out_db: str,
220
+ *,
221
+ tables=None,
222
+ schema: Optional[str] = None,
223
+ out_schema: Optional[str] = None,
224
+ if_exists: str = "fail",
225
+ policy: Optional[Policy] = None,
226
+ secret: Optional[str] = None,
227
+ secret_file: Optional[str] = None,
228
+ mapping: Optional[str] = None,
229
+ private_dir: Optional[str] = None,
230
+ report: Optional[str] = None,
231
+ quiet: bool = True,
232
+ **policy_kwargs,
233
+ ) -> Result:
234
+ """Read a SQL source DB, de-identify it, and write to a SEPARATE output DB
235
+ with the same structure — never editing the source in place.
236
+
237
+ ``source`` / ``out_db`` are SQLAlchemy URLs (e.g. a Fabric SQL endpoint for
238
+ the source and any writable DB for the output). The secret, mapping and audit
239
+ go to ``private_dir`` (default ``./deidkit-db-PRIVATE``), never the output DB.
240
+ Requires ``pip install deidkit[sql]``.
241
+ """
242
+ sa = io._require_sqlalchemy()
243
+ if not out_db:
244
+ raise ValueError(
245
+ "an output database is REQUIRED — deidkit never edits the source in "
246
+ "place. Create an empty output DB and pass its URL as out_db/--out-db."
247
+ )
248
+ if str(source) == str(out_db):
249
+ raise ValueError("source and output database must differ — deidkit never edits in place.")
250
+ src_engine = sa.create_engine(source)
251
+
252
+ # The output DB is an environment YOU provision. Fail early with the process
253
+ # if it isn't reachable (SQLite auto-creates a local file; a server DB does not).
254
+ # create_engine itself imports the driver, so it is inside the guard too.
255
+ try:
256
+ out_engine = sa.create_engine(out_db)
257
+ with out_engine.connect() as conn:
258
+ conn.execute(sa.text("SELECT 1"))
259
+ except Exception as exc: # noqa: BLE001
260
+ raise ValueError(
261
+ f"cannot reach the output database.\n {exc}\n\n"
262
+ "The output database must be created by YOU first (deidkit never writes\n"
263
+ "back to the source). Process:\n"
264
+ " 1. Create an EMPTY database on your server: CREATE DATABASE deid_db;\n"
265
+ " 2. Create a user/role with write access to it.\n"
266
+ " 3. Pass its SQLAlchemy URL as --out-db, e.g.\n"
267
+ " postgresql://user:pw@host:5432/deid_db\n"
268
+ " mssql+pyodbc://user:pw@host/deid_db?driver=ODBC+Driver+18+for+SQL+Server\n"
269
+ "deidkit then creates the tables (mirroring the source structure) inside it."
270
+ ) from exc
271
+
272
+ dataframes, columns_meta = io.read_sql_all(src_engine, tables, schema)
273
+ if not dataframes:
274
+ raise ValueError("no tables found in the source database.")
275
+
276
+ if policy is None:
277
+ bad = set(policy_kwargs) - _POLICY_KEYS
278
+ if bad:
279
+ raise TypeError(f"Unknown option(s): {sorted(bad)}.")
280
+ policy = Policy(**policy_kwargs)
281
+
282
+ dict_index = load_dictionary(schema) if (schema and os.path.exists(str(schema))) else {}
283
+
284
+ private_dir = private_dir or "deidkit-db-PRIVATE"
285
+ os.makedirs(private_dir, exist_ok=True)
286
+ try:
287
+ os.chmod(private_dir, 0o700)
288
+ except OSError:
289
+ pass
290
+
291
+ generated_secret = None
292
+ if not (secret or secret_file or os.environ.get("DEIDKIT_SECRET")):
293
+ from .secret import generate_secret
294
+
295
+ generated_secret = generate_secret()
296
+ secret = generated_secret
297
+ key_path = os.path.join(private_dir, "deidkit-secret.key")
298
+ with open(key_path, "w", encoding="utf-8") as fh:
299
+ fh.write(generated_secret + "\n")
300
+ os.chmod(key_path, 0o600)
301
+ if mapping is None:
302
+ mapping = os.path.join(private_dir, "mapping.private.json")
303
+
304
+ deid = Deidentifier(policy, secret=secret, secret_file=secret_file,
305
+ mapping_path=mapping, dict_index=dict_index)
306
+ clean = deid.run_dataset(dataframes)
307
+ for name, df in clean.items():
308
+ io.write_sql_mirrored(out_engine, name, df, columns_meta[name], out_schema, if_exists)
309
+
310
+ deid.save_mapping()
311
+ report_path = report or os.path.join(private_dir, "deid_audit.PRIVATE.xlsx")
312
+ deid.write_report(report_path)
313
+ deid.write_review(os.path.join(private_dir, "review.csv"))
314
+
315
+ return Result(tables=clean, deid=deid, out=out_db, report_path=report_path,
316
+ mapping_path=mapping, secret=generated_secret, private_dir=private_dir)