data-sampler 3.2.1__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.
- data_sampler/__init__.py +82 -0
- data_sampler/__main__.py +7 -0
- data_sampler/_logging.py +61 -0
- data_sampler/_names.py +60 -0
- data_sampler/anonymize.py +463 -0
- data_sampler/cli.py +363 -0
- data_sampler/engine.py +628 -0
- data_sampler/io.py +75 -0
- data_sampler/report.py +289 -0
- data_sampler/sampling.py +193 -0
- data_sampler/stats.py +174 -0
- data_sampler/tui/__init__.py +16 -0
- data_sampler/tui/app.py +904 -0
- data_sampler/workflow.py +285 -0
- data_sampler-3.2.1.dist-info/METADATA +427 -0
- data_sampler-3.2.1.dist-info/RECORD +19 -0
- data_sampler-3.2.1.dist-info/WHEEL +4 -0
- data_sampler-3.2.1.dist-info/entry_points.txt +3 -0
- data_sampler-3.2.1.dist-info/licenses/LICENSE +21 -0
data_sampler/workflow.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Guided anonymization workflow: pick a *column type* per column, three ways.
|
|
2
|
+
|
|
3
|
+
The one shared abstraction is :class:`AnonymizationPlan` — a mapping of each
|
|
4
|
+
column to a "type" (an anonymizer kind plus its options). A plan can be built:
|
|
5
|
+
|
|
6
|
+
- **programmatically (pre-specify through a function):**
|
|
7
|
+
``plan.assign("salary", "numeric_jitter", pct=0.1)``, or
|
|
8
|
+
:meth:`AnonymizationPlan.suggest` to auto-infer a type for every column from
|
|
9
|
+
its :class:`~data_sampler.stats.ColumnStats`;
|
|
10
|
+
- **interactively (choose from options):**
|
|
11
|
+
:meth:`AnonymizationPlan.choose_interactively` walks the columns and offers a
|
|
12
|
+
numbered menu of type options for each, defaulting to the suggested type;
|
|
13
|
+
- **from the TUI (click):** the columns screen writes the user's clicks into a
|
|
14
|
+
plan (the TUI's per-column config *is* a plan).
|
|
15
|
+
|
|
16
|
+
Then :meth:`AnonymizationPlan.apply` runs :func:`data_sampler.anonymize`.
|
|
17
|
+
|
|
18
|
+
The available types are the anonymizer kinds (see
|
|
19
|
+
:data:`data_sampler.anonymize.KINDS`) plus ``"none"`` (leave unchanged);
|
|
20
|
+
:data:`TYPE_OPTIONS` is the canonical, human-labelled menu shared by the CLI
|
|
21
|
+
wizard and the TUI.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Callable, Iterable
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
from ._logging import get_logger
|
|
33
|
+
from .anonymize import anonymize, make_anonymizer
|
|
34
|
+
from .stats import ColumnStats, compute_column_stats
|
|
35
|
+
|
|
36
|
+
log = get_logger(__name__)
|
|
37
|
+
|
|
38
|
+
# Canonical column-type menu: (kind, label, help). ``kind`` is a key into
|
|
39
|
+
# ``anonymize.KINDS`` (or "none"). Shared by the CLI wizard and the TUI so the
|
|
40
|
+
# option list stays in one place.
|
|
41
|
+
TYPE_OPTIONS: list[tuple[str, str, str]] = [
|
|
42
|
+
("none", "leave unchanged", "write the column through as-is"),
|
|
43
|
+
("names", "names", "replace with realistic names from the bundled library"),
|
|
44
|
+
("sequential_id", "sequential id", "renumber as start, start+interval, …"),
|
|
45
|
+
("numeric_jitter", "numeric jitter", "perturb numbers within ± a percentage"),
|
|
46
|
+
("datetime_jitter", "datetime jitter", "shift dates/times within a ± window"),
|
|
47
|
+
("random_string", "random string", "replace with random character strings"),
|
|
48
|
+
("hex", "hex string", "replace with random hexadecimal strings"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
TYPE_LABELS: dict[str, str] = {kind: label for kind, label, _ in TYPE_OPTIONS}
|
|
52
|
+
TYPE_KINDS: tuple[str, ...] = tuple(kind for kind, _, _ in TYPE_OPTIONS)
|
|
53
|
+
|
|
54
|
+
# alias → canonical menu kind (anonymize.KINDS accepts e.g. "seq"/"jitter";
|
|
55
|
+
# the plan stores the canonical name so the wizard/TUI menus recognize it)
|
|
56
|
+
_CANONICAL_KINDS: dict[str, str] = {
|
|
57
|
+
"name": "names",
|
|
58
|
+
"sequential": "sequential_id",
|
|
59
|
+
"seq": "sequential_id",
|
|
60
|
+
"numbers": "numeric_jitter",
|
|
61
|
+
"jitter": "numeric_jitter",
|
|
62
|
+
"datetime": "datetime_jitter",
|
|
63
|
+
"dates": "datetime_jitter",
|
|
64
|
+
"string": "random_string",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# column-name substrings that hint at a semantic type
|
|
68
|
+
_NAME_HINTS = ("name", "surname", "firstname", "lastname", "fullname", "contact")
|
|
69
|
+
_EMAIL_HINTS = ("email", "e-mail", "mail")
|
|
70
|
+
_ID_HINTS = ("id", "uuid", "guid", "account", "ssn", "customer", "cust", "user")
|
|
71
|
+
# date hints are matched per name *token* (equality or prefix) rather than as a
|
|
72
|
+
# bare substring, so "signup_date"/"created_at" match but "candidate"/"mandate"
|
|
73
|
+
# /"update_reason" (which merely contain "date"/"update") do not
|
|
74
|
+
_DATE_HINTS = (
|
|
75
|
+
"date", "datetime", "dob", "birth", "birthday", "timestamp", "created",
|
|
76
|
+
"updated", "modified", "joined", "signup", "expire", "expiry",
|
|
77
|
+
"expiration", "hired", "start", "end",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _has_date_token(lname: str) -> bool:
|
|
82
|
+
tokens = [t for t in re.split(r"[^a-z0-9]+", lname) if t]
|
|
83
|
+
return any(t == h or t.startswith(h) for t in tokens for h in _DATE_HINTS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def suggest_type(stats: ColumnStats) -> str:
|
|
87
|
+
"""Suggest an anonymizer kind for a column from its :class:`ColumnStats`.
|
|
88
|
+
|
|
89
|
+
Heuristics, in order: datetime columns → ``datetime_jitter``; email-ish and
|
|
90
|
+
name-ish column names → ``hex`` / ``names``; date-ish column names (even
|
|
91
|
+
when the dates were loaded as strings) → ``datetime_jitter``; id-ish names
|
|
92
|
+
that are almost all unique → ``sequential_id``; numeric → ``numeric_jitter``;
|
|
93
|
+
free text → ``random_string``. Categorical / boolean columns default to
|
|
94
|
+
``"none"`` because they are usually what you stratify on — anonymizing them
|
|
95
|
+
would destroy the categories the sample is built to preserve.
|
|
96
|
+
|
|
97
|
+
Suggestions are advisory: every entry point (the interactive wizard, the
|
|
98
|
+
TUI, ``--suggest``) lets you review and override them before anything runs.
|
|
99
|
+
"""
|
|
100
|
+
lname = stats.name.lower()
|
|
101
|
+
|
|
102
|
+
def name_has(hints: Iterable[str]) -> bool:
|
|
103
|
+
return any(h in lname for h in hints)
|
|
104
|
+
|
|
105
|
+
if stats.kind == "datetime":
|
|
106
|
+
return "datetime_jitter"
|
|
107
|
+
if name_has(_EMAIL_HINTS):
|
|
108
|
+
return "hex"
|
|
109
|
+
if name_has(_NAME_HINTS):
|
|
110
|
+
return "names"
|
|
111
|
+
# only string-ish columns can hold parseable date strings; "other" kinds
|
|
112
|
+
# (TIME, INTERVAL, nested types) must never be datetime-jittered
|
|
113
|
+
if stats.kind in ("categorical", "text") and _has_date_token(lname):
|
|
114
|
+
return "datetime_jitter"
|
|
115
|
+
id_like = name_has(_ID_HINTS) and stats.unique_pct >= 90
|
|
116
|
+
if stats.kind == "numeric":
|
|
117
|
+
return "sequential_id" if id_like else "numeric_jitter"
|
|
118
|
+
if id_like:
|
|
119
|
+
return "sequential_id"
|
|
120
|
+
if stats.kind == "text":
|
|
121
|
+
return "random_string"
|
|
122
|
+
return "none"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class AnonymizationPlan:
|
|
127
|
+
"""A column → (anonymizer kind, options) plan for a set of columns.
|
|
128
|
+
|
|
129
|
+
``assignments`` maps each column name to a ``(kind, options)`` pair, where
|
|
130
|
+
``kind`` is ``"none"`` or a key of :data:`data_sampler.anonymize.KINDS` and
|
|
131
|
+
``options`` are that anonymizer's keyword arguments.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
assignments: dict[str, tuple[str, dict]] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
# ── construction ────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def for_columns(cls, columns: Iterable[str]) -> "AnonymizationPlan":
|
|
140
|
+
"""An empty plan (every column ``"none"``) for the given columns."""
|
|
141
|
+
return cls({str(c): ("none", {}) for c in columns})
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def suggest(
|
|
145
|
+
cls, df: pd.DataFrame, columns: Iterable[str] | None = None
|
|
146
|
+
) -> "AnonymizationPlan":
|
|
147
|
+
"""Build a plan by auto-inferring a type for each column via
|
|
148
|
+
:func:`suggest_type`."""
|
|
149
|
+
cols = list(df.columns) if columns is None else [str(c) for c in columns]
|
|
150
|
+
plan = cls()
|
|
151
|
+
n_rows = len(df)
|
|
152
|
+
for col in cols:
|
|
153
|
+
stats = compute_column_stats(df[col], n_rows)
|
|
154
|
+
kind = suggest_type(stats)
|
|
155
|
+
plan.assignments[col] = (kind, {})
|
|
156
|
+
log.debug("suggest_type(%r) → %s", col, kind)
|
|
157
|
+
return plan
|
|
158
|
+
|
|
159
|
+
# ── editing (pre-specify through a function) ──────────────────────────────
|
|
160
|
+
|
|
161
|
+
def assign(self, column: str, kind: str, **options) -> "AnonymizationPlan":
|
|
162
|
+
"""Assign ``column`` an anonymizer ``kind`` with ``options``.
|
|
163
|
+
|
|
164
|
+
Validates eagerly: an unknown kind or bad options raise immediately
|
|
165
|
+
(via :func:`~data_sampler.anonymize.make_anonymizer`). ``"none"`` clears
|
|
166
|
+
any assignment. Returns ``self`` for chaining.
|
|
167
|
+
"""
|
|
168
|
+
key = kind.strip().lower()
|
|
169
|
+
if key == "none":
|
|
170
|
+
self.assignments[column] = ("none", {})
|
|
171
|
+
return self
|
|
172
|
+
make_anonymizer(key, **options) # validate kind + options now
|
|
173
|
+
# store the canonical kind so menus (wizard/TUI) recognize it — an
|
|
174
|
+
# alias like "seq" would otherwise default the wizard back to "none"
|
|
175
|
+
self.assignments[column] = (_CANONICAL_KINDS.get(key, key), dict(options))
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def clear(self, column: str) -> "AnonymizationPlan":
|
|
179
|
+
"""Set ``column`` back to ``"none"``."""
|
|
180
|
+
self.assignments[column] = ("none", {})
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def type_of(self, column: str) -> str:
|
|
184
|
+
"""The assigned kind for ``column`` (``"none"`` if unassigned)."""
|
|
185
|
+
return self.assignments.get(column, ("none", {}))[0]
|
|
186
|
+
|
|
187
|
+
# ── interactive (choose from options) ─────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def choose_interactively(
|
|
190
|
+
self,
|
|
191
|
+
df: pd.DataFrame | None = None,
|
|
192
|
+
columns: Iterable[str] | None = None,
|
|
193
|
+
*,
|
|
194
|
+
prompt: Callable[[str], str] = input,
|
|
195
|
+
echo: Callable[[str], None] = print,
|
|
196
|
+
) -> "AnonymizationPlan":
|
|
197
|
+
"""Walk the columns and let the user pick a type for each from a menu.
|
|
198
|
+
|
|
199
|
+
For every column a numbered list of :data:`TYPE_OPTIONS` is shown; the
|
|
200
|
+
suggested type (from :func:`suggest_type`, when ``df`` is given) is
|
|
201
|
+
marked and used as the default when the user just presses Enter. An
|
|
202
|
+
out-of-range or non-numeric entry falls back to the suggestion.
|
|
203
|
+
|
|
204
|
+
``prompt`` and ``echo`` are injectable for testing (defaults are the
|
|
205
|
+
builtin :func:`input` / :func:`print`). Returns ``self``.
|
|
206
|
+
"""
|
|
207
|
+
if columns is not None:
|
|
208
|
+
cols = [str(c) for c in columns]
|
|
209
|
+
elif df is not None:
|
|
210
|
+
cols = list(df.columns)
|
|
211
|
+
else:
|
|
212
|
+
cols = list(self.assignments)
|
|
213
|
+
|
|
214
|
+
echo("Anonymization workflow — choose a type for each column.")
|
|
215
|
+
echo("Press Enter to accept the [suggested] type, or type an option number.")
|
|
216
|
+
echo("")
|
|
217
|
+
n_rows = len(df) if df is not None else 0
|
|
218
|
+
for col in cols:
|
|
219
|
+
suggestion = "none"
|
|
220
|
+
info = ""
|
|
221
|
+
if df is not None and col in df.columns:
|
|
222
|
+
stats = compute_column_stats(df[col], n_rows)
|
|
223
|
+
suggestion = suggest_type(stats)
|
|
224
|
+
info = (
|
|
225
|
+
f" ({stats.kind}, {stats.unique:,} unique, "
|
|
226
|
+
f"{stats.missing_pct:.0f}% missing)"
|
|
227
|
+
)
|
|
228
|
+
# an assignment already on the plan (e.g. seeded from CLI --anon)
|
|
229
|
+
# wins as the default; otherwise fall back to the suggestion
|
|
230
|
+
current = self.assignments.get(col, ("none", {}))[0]
|
|
231
|
+
default_kind = current if current and current != "none" else suggestion
|
|
232
|
+
echo(f"Column: {col}{info}")
|
|
233
|
+
for i, (kind, label, help_) in enumerate(TYPE_OPTIONS, 1):
|
|
234
|
+
mark = " [suggested]" if kind == suggestion else ""
|
|
235
|
+
echo(f" {i}. {label}{mark} — {help_}")
|
|
236
|
+
default_idx = TYPE_KINDS.index(default_kind) + 1 if default_kind in TYPE_KINDS else 1
|
|
237
|
+
raw = prompt(f" choice [{default_idx}]: ").strip()
|
|
238
|
+
choice = default_idx
|
|
239
|
+
if raw:
|
|
240
|
+
try:
|
|
241
|
+
n = int(raw)
|
|
242
|
+
if 1 <= n <= len(TYPE_OPTIONS):
|
|
243
|
+
choice = n
|
|
244
|
+
except ValueError:
|
|
245
|
+
pass
|
|
246
|
+
kind = TYPE_OPTIONS[choice - 1][0]
|
|
247
|
+
# accepting the column's current kind keeps its options (e.g. a
|
|
248
|
+
# seeded --anon "id=sequential_id:start=1000"); choosing a
|
|
249
|
+
# different kind starts from that kind's defaults
|
|
250
|
+
cur_kind, cur_opts = self.assignments.get(col, ("none", {}))
|
|
251
|
+
self.assignments[col] = (kind, dict(cur_opts) if kind == cur_kind else {})
|
|
252
|
+
echo(f" → {TYPE_LABELS[kind]}")
|
|
253
|
+
echo("")
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
# ── output ────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def build_spec(self) -> dict:
|
|
259
|
+
"""The ``{column: anonymizer}`` spec for :func:`data_sampler.anonymize`.
|
|
260
|
+
|
|
261
|
+
Columns typed ``"none"`` are omitted.
|
|
262
|
+
"""
|
|
263
|
+
spec = {}
|
|
264
|
+
for col, (kind, options) in self.assignments.items():
|
|
265
|
+
if kind and kind != "none":
|
|
266
|
+
spec[col] = make_anonymizer(kind, **options)
|
|
267
|
+
return spec
|
|
268
|
+
|
|
269
|
+
def active_columns(self) -> list[str]:
|
|
270
|
+
"""Columns with a non-``none`` type, in assignment order."""
|
|
271
|
+
return [c for c, (k, _) in self.assignments.items() if k and k != "none"]
|
|
272
|
+
|
|
273
|
+
def apply(self, df: pd.DataFrame, seed: int | None = None) -> pd.DataFrame:
|
|
274
|
+
"""Anonymize ``df`` according to this plan (returns a new frame)."""
|
|
275
|
+
return anonymize(df, self.build_spec(), seed=seed)
|
|
276
|
+
|
|
277
|
+
def summary(self) -> str:
|
|
278
|
+
"""A short one-line-per-column summary of the active assignments."""
|
|
279
|
+
active = self.active_columns()
|
|
280
|
+
if not active:
|
|
281
|
+
return "no columns anonymized"
|
|
282
|
+
return "\n".join(
|
|
283
|
+
f" {col} → {TYPE_LABELS.get(self.assignments[col][0], self.assignments[col][0])}"
|
|
284
|
+
for col in active
|
|
285
|
+
)
|