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.
- deidkit/__init__.py +74 -0
- deidkit/__main__.py +6 -0
- deidkit/api.py +316 -0
- deidkit/cli.py +399 -0
- deidkit/config.py +285 -0
- deidkit/data/NOTICES.md +25 -0
- deidkit/data/context_triggers_en.txt +17 -0
- deidkit/data/context_triggers_es.txt +36 -0
- deidkit/data/en_given_names.txt +203 -0
- deidkit/data/en_surnames.txt +118 -0
- deidkit/data/es_given_names.txt +273 -0
- deidkit/data/es_surnames.txt +168 -0
- deidkit/data/honorifics_en.txt +22 -0
- deidkit/data/honorifics_es.txt +35 -0
- deidkit/data/medical_stoplist_en.txt +345 -0
- deidkit/data/medical_stoplist_es.txt +499 -0
- deidkit/data/medical_vocab.txt +21445 -0
- deidkit/dates.py +229 -0
- deidkit/detect/__init__.py +19 -0
- deidkit/detect/checksums.py +160 -0
- deidkit/detect/context.py +137 -0
- deidkit/detect/gazetteer.py +158 -0
- deidkit/detect/patterns.py +147 -0
- deidkit/detect/pipeline.py +234 -0
- deidkit/detect/spacy_ner.py +53 -0
- deidkit/detect/types.py +53 -0
- deidkit/engine.py +644 -0
- deidkit/generators/__init__.py +4 -0
- deidkit/generators/names.py +111 -0
- deidkit/generators/surrogates.py +76 -0
- deidkit/io.py +184 -0
- deidkit/learn.py +83 -0
- deidkit/mapping.py +108 -0
- deidkit/report.py +219 -0
- deidkit/resources.py +100 -0
- deidkit/schema.py +234 -0
- deidkit/secret.py +77 -0
- deidkit/textnorm.py +66 -0
- deidkit/version.py +3 -0
- deidkit-0.1.0.dist-info/METADATA +768 -0
- deidkit-0.1.0.dist-info/RECORD +45 -0
- deidkit-0.1.0.dist-info/WHEEL +5 -0
- deidkit-0.1.0.dist-info/entry_points.txt +2 -0
- deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
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)
|