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.
@@ -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
+ )