sieve-crowd 0.2.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.
- sieve_crowd/__init__.py +16 -0
- sieve_crowd/__main__.py +3 -0
- sieve_crowd/cli.py +206 -0
- sieve_crowd/config.py +118 -0
- sieve_crowd/csvio.py +34 -0
- sieve_crowd/engine.py +205 -0
- sieve_crowd/generalize.py +209 -0
- sieve_crowd/hints.py +38 -0
- sieve_crowd/jsonio.py +106 -0
- sieve_crowd/measure.py +37 -0
- sieve_crowd/model.py +19 -0
- sieve_crowd/prng.py +31 -0
- sieve_crowd/pseudonym.py +19 -0
- sieve_crowd/report.py +139 -0
- sieve_crowd/search.py +145 -0
- sieve_crowd/tableio.py +51 -0
- sieve_crowd/xmlio.py +111 -0
- sieve_crowd-0.2.0.dist-info/METADATA +188 -0
- sieve_crowd-0.2.0.dist-info/RECORD +23 -0
- sieve_crowd-0.2.0.dist-info/WHEEL +5 -0
- sieve_crowd-0.2.0.dist-info/entry_points.txt +2 -0
- sieve_crowd-0.2.0.dist-info/licenses/LICENSE +21 -0
- sieve_crowd-0.2.0.dist-info/top_level.txt +1 -0
sieve_crowd/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Sieve Crowd: make every record one of a crowd, and measure it.
|
|
2
|
+
|
|
3
|
+
Deterministic, offline, zero-dependency k-anonymity (with optional l-diversity)
|
|
4
|
+
for CSV, JSON and XML tables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
from .config import Config, parse_config, parse_key # noqa: E402
|
|
10
|
+
from .engine import Result, anonymize, inspect_table # noqa: E402
|
|
11
|
+
from .model import ConfigError, InputError, Role # noqa: E402
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__", "anonymize", "inspect_table", "Result", "Config", "parse_config", "parse_key",
|
|
15
|
+
"ConfigError", "InputError", "Role",
|
|
16
|
+
]
|
sieve_crowd/__main__.py
ADDED
sieve_crowd/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Command line: inspect / keygen / run. Cell values never appear in messages or logs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import parse_config, parse_key
|
|
14
|
+
from .engine import anonymize, inspect_table
|
|
15
|
+
from .hints import hint
|
|
16
|
+
from .model import ConfigError, InputError
|
|
17
|
+
from .tableio import FORMATS, detect_format, read_any, write_any
|
|
18
|
+
|
|
19
|
+
EXIT_OK, EXIT_IO, EXIT_CONFIG, EXIT_REFUSED = 0, 1, 2, 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _atomic_write(path: str, data: bytes, private: bool = False) -> None:
|
|
23
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
24
|
+
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".sieve-crowd-")
|
|
25
|
+
try:
|
|
26
|
+
with os.fdopen(fd, "wb") as fh:
|
|
27
|
+
fh.write(data)
|
|
28
|
+
if private:
|
|
29
|
+
os.chmod(tmp, 0o600)
|
|
30
|
+
os.replace(tmp, path)
|
|
31
|
+
except BaseException:
|
|
32
|
+
try:
|
|
33
|
+
os.unlink(tmp)
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parser() -> argparse.ArgumentParser:
|
|
40
|
+
p = argparse.ArgumentParser(prog="sieve-crowd",
|
|
41
|
+
description="Make every record one of a crowd of at least k, and measure it.")
|
|
42
|
+
p.add_argument("--version", action="version", version=f"sieve-crowd {__version__}")
|
|
43
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
44
|
+
|
|
45
|
+
i = sub.add_parser("inspect", help="describe the columns of a table (csv, json or xml) and write a config template")
|
|
46
|
+
i.add_argument("input")
|
|
47
|
+
i.add_argument("--delimiter", help="CSV only")
|
|
48
|
+
i.add_argument("--in-format", choices=FORMATS,
|
|
49
|
+
help="force the input format (default: from the file extension)")
|
|
50
|
+
i.add_argument("--lang", choices=["en", "ja"], default="en")
|
|
51
|
+
i.add_argument("--config-out", metavar="FILE", help="write a template with role '?' for every column")
|
|
52
|
+
i.add_argument("--force", action="store_true")
|
|
53
|
+
|
|
54
|
+
k = sub.add_parser("keygen", help="create a key for pseudonyms")
|
|
55
|
+
k.add_argument("-o", "--output", required=True)
|
|
56
|
+
k.add_argument("--force", action="store_true")
|
|
57
|
+
|
|
58
|
+
r = sub.add_parser("run", help="anonymize a table (csv, json or xml)")
|
|
59
|
+
r.add_argument("input")
|
|
60
|
+
r.add_argument("-c", "--config", required=True)
|
|
61
|
+
r.add_argument("-o", "--output")
|
|
62
|
+
r.add_argument("--dry-run", action="store_true", help="measure only; write no table")
|
|
63
|
+
r.add_argument("--delimiter", help="CSV only")
|
|
64
|
+
r.add_argument("--in-format", choices=FORMATS,
|
|
65
|
+
help="force the input format (default: from the file extension)")
|
|
66
|
+
r.add_argument("--out-format", choices=FORMATS,
|
|
67
|
+
help="force the output format (default: from the output extension)")
|
|
68
|
+
r.add_argument("--key-file")
|
|
69
|
+
r.add_argument("--k", type=int)
|
|
70
|
+
r.add_argument("--l", type=int)
|
|
71
|
+
r.add_argument("--max-suppression", type=float)
|
|
72
|
+
r.add_argument("--keep-order", action="store_true", help="relaxation: do not shuffle the rows (recorded)")
|
|
73
|
+
r.add_argument("--shuffle-seed", type=int, default=0)
|
|
74
|
+
r.add_argument("--lang", choices=["en", "ja"], default="en")
|
|
75
|
+
r.add_argument("--max-rows", type=int, default=200_000)
|
|
76
|
+
r.add_argument("--max-evals", type=int, default=20_000)
|
|
77
|
+
r.add_argument("--report-json", metavar="FILE")
|
|
78
|
+
r.add_argument("--quiet", action="store_true")
|
|
79
|
+
r.add_argument("--force", action="store_true")
|
|
80
|
+
return p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read(path: str) -> bytes:
|
|
84
|
+
if path == "-":
|
|
85
|
+
return sys.stdin.buffer.read()
|
|
86
|
+
with open(path, "rb") as fh:
|
|
87
|
+
return fh.read()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_format(path: str, explicit: Optional[str], *, dash_default: str) -> str:
|
|
91
|
+
if explicit:
|
|
92
|
+
return explicit
|
|
93
|
+
if path == "-":
|
|
94
|
+
return dash_default
|
|
95
|
+
return detect_format(os.path.splitext(path)[1])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _delimiter(given: Optional[str], sides: set[str]) -> Optional[str]:
|
|
99
|
+
if given is None:
|
|
100
|
+
return None
|
|
101
|
+
if sides - {"csv"}:
|
|
102
|
+
raise InputError("--delimiter applies to csv only (the table is json/xml)")
|
|
103
|
+
return given
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cmd_inspect(a: argparse.Namespace) -> int:
|
|
107
|
+
try:
|
|
108
|
+
in_fmt = _resolve_format(a.input, a.in_format, dash_default="csv")
|
|
109
|
+
delim = _delimiter(a.delimiter, {in_fmt})
|
|
110
|
+
header, rows = read_any(_read(a.input), in_fmt, delim)
|
|
111
|
+
except InputError as exc:
|
|
112
|
+
print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
print(f"error: cannot read the input ({exc.strerror})", file=sys.stderr); return EXIT_IO
|
|
115
|
+
info = inspect_table(header, rows)
|
|
116
|
+
en = a.lang == "en"
|
|
117
|
+
print(f"records: {len(rows)}" if en else f"レコード数: {len(rows)}")
|
|
118
|
+
hints = {}
|
|
119
|
+
for c in info:
|
|
120
|
+
role, text_en, text_ja = hint(c["name"], c, len(rows))
|
|
121
|
+
hints[c["name"]] = (role, text_en if en else text_ja)
|
|
122
|
+
shown = f" {c['name']}: {c['distinct']} distinct, {c['empty']} empty, {c['unique_ratio']:.0%} unique values, looks like {c['shape']}" \
|
|
123
|
+
if en else f" {c['name']}: 異なる値 {c['distinct']}、空 {c['empty']}、一意な値 {c['unique_ratio']:.0%}、形は {c['shape']}"
|
|
124
|
+
print(shown)
|
|
125
|
+
if role:
|
|
126
|
+
print(f" -> {hints[c['name']][1]}")
|
|
127
|
+
if a.config_out:
|
|
128
|
+
if os.path.exists(a.config_out) and not a.force:
|
|
129
|
+
print("error: the config file exists (use --force)", file=sys.stderr); return EXIT_IO
|
|
130
|
+
template = {"k": 5, "l": 1, "max_suppression": 0.05,
|
|
131
|
+
"columns": {c["name"]: ({"role": "?", "_hint": hints[c["name"]][1]} if hints[c["name"]][0]
|
|
132
|
+
else {"role": "?"}) for c in info}}
|
|
133
|
+
try:
|
|
134
|
+
_atomic_write(a.config_out, (json.dumps(template, ensure_ascii=False, indent=2) + "\n").encode())
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
print(f"error: cannot write ({exc.strerror})", file=sys.stderr); return EXIT_IO
|
|
137
|
+
return EXIT_OK
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cmd_keygen(a: argparse.Namespace) -> int:
|
|
141
|
+
if os.path.exists(a.output) and not a.force:
|
|
142
|
+
print("error: the key file exists (use --force)", file=sys.stderr); return EXIT_IO
|
|
143
|
+
try:
|
|
144
|
+
_atomic_write(a.output, (secrets.token_hex(32) + "\n").encode(), private=True)
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
print(f"error: cannot write ({exc.strerror})", file=sys.stderr); return EXIT_IO
|
|
147
|
+
return EXIT_OK
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cmd_run(a: argparse.Namespace) -> int:
|
|
151
|
+
if not a.dry_run and not a.output:
|
|
152
|
+
print("error: give -o/--output or --dry-run", file=sys.stderr); return EXIT_CONFIG
|
|
153
|
+
if a.output and a.input != "-" and os.path.realpath(a.output) == os.path.realpath(a.input):
|
|
154
|
+
print("error: the output must not be the input file", file=sys.stderr); return EXIT_CONFIG
|
|
155
|
+
if a.output and os.path.exists(a.output) and not a.force:
|
|
156
|
+
print("error: output exists (use --force to replace it)", file=sys.stderr); return EXIT_IO
|
|
157
|
+
try:
|
|
158
|
+
in_fmt = _resolve_format(a.input, a.in_format, dash_default="csv")
|
|
159
|
+
out_fmt = _resolve_format(a.output, a.out_format, dash_default="csv") if a.output else None
|
|
160
|
+
delim = _delimiter(a.delimiter, {in_fmt} | (set() if out_fmt is None else {out_fmt}))
|
|
161
|
+
except InputError as exc:
|
|
162
|
+
print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
|
|
163
|
+
try:
|
|
164
|
+
with open(a.config, "rb") as fh:
|
|
165
|
+
raw = json.loads(fh.read().decode("utf-8-sig"))
|
|
166
|
+
key = parse_key(open(a.key_file, encoding="utf-8").read()) if a.key_file else None
|
|
167
|
+
cfg = parse_config(raw, k=a.k, l=a.l, max_suppression=a.max_suppression,
|
|
168
|
+
shuffle=not a.keep_order, shuffle_seed=a.shuffle_seed, lang=a.lang,
|
|
169
|
+
max_rows=a.max_rows, max_evals=a.max_evals, key=key)
|
|
170
|
+
except ConfigError as exc:
|
|
171
|
+
print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
|
|
172
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
173
|
+
print("error: the configuration is not valid JSON", file=sys.stderr); return EXIT_CONFIG
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
print(f"error: cannot read the configuration or key ({exc.strerror})", file=sys.stderr); return EXIT_IO
|
|
176
|
+
try:
|
|
177
|
+
header, rows = read_any(_read(a.input), in_fmt, delim)
|
|
178
|
+
except InputError as exc:
|
|
179
|
+
print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
print(f"error: cannot read the input ({exc.strerror})", file=sys.stderr); return EXIT_IO
|
|
182
|
+
try:
|
|
183
|
+
result = anonymize(header, rows, cfg, io=(in_fmt, out_fmt))
|
|
184
|
+
except ConfigError as exc:
|
|
185
|
+
print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
|
|
186
|
+
if not a.quiet:
|
|
187
|
+
print(result.report.text(cfg.lang), file=sys.stderr)
|
|
188
|
+
try:
|
|
189
|
+
if a.report_json:
|
|
190
|
+
_atomic_write(a.report_json, (result.report.to_json() + "\n").encode("utf-8"))
|
|
191
|
+
if result.status == "refused":
|
|
192
|
+
return EXIT_REFUSED
|
|
193
|
+
if not a.dry_run:
|
|
194
|
+
data = write_any(result.header, result.rows, out_fmt, delim)
|
|
195
|
+
if a.output == "-":
|
|
196
|
+
sys.stdout.buffer.write(data); sys.stdout.buffer.flush()
|
|
197
|
+
else:
|
|
198
|
+
_atomic_write(a.output, data)
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
print(f"error: cannot write ({exc.strerror})", file=sys.stderr); return EXIT_IO
|
|
201
|
+
return EXIT_OK
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
205
|
+
a = _parser().parse_args(argv)
|
|
206
|
+
return {"inspect": _cmd_inspect, "keygen": _cmd_keygen, "run": _cmd_run}[a.cmd](a)
|
sieve_crowd/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Configuration: which column plays which role, and how much to protect."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
from .generalize import make_generalizer
|
|
9
|
+
from .model import ConfigError, Role
|
|
10
|
+
|
|
11
|
+
_TOP_KEYS = {"k", "l", "max_suppression", "columns"}
|
|
12
|
+
_COL_KEYS = {"role", "action", "type", "widths", "length", "hierarchy"}
|
|
13
|
+
_HEX = re.compile(r"^[0-9a-fA-F]{32,}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ColumnSpec:
|
|
18
|
+
name: str
|
|
19
|
+
role: Role
|
|
20
|
+
action: str = "drop" # role id: "drop" | "pseudonymize"
|
|
21
|
+
gen: Optional[Mapping[str, Any]] = None # role qi: generalizer settings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Config:
|
|
26
|
+
columns: Mapping[str, ColumnSpec]
|
|
27
|
+
k: int = 5
|
|
28
|
+
l: int = 1
|
|
29
|
+
max_suppression: float = 0.05
|
|
30
|
+
shuffle: bool = True # keep-order is a relaxation, recorded in the report
|
|
31
|
+
shuffle_seed: int = 0
|
|
32
|
+
lang: str = "en"
|
|
33
|
+
max_rows: int = 200_000
|
|
34
|
+
max_evals: int = 20_000
|
|
35
|
+
key: Optional[bytes] = None
|
|
36
|
+
|
|
37
|
+
def names(self, role: Role) -> list[str]:
|
|
38
|
+
return [n for n, c in self.columns.items() if c.role is role]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_key(text: str) -> bytes:
|
|
42
|
+
t = text.strip()
|
|
43
|
+
if not _HEX.match(t):
|
|
44
|
+
raise ConfigError("the key must be at least 32 hexadecimal characters (see 'sieve-crowd keygen')")
|
|
45
|
+
return bytes.fromhex(t if len(t) % 2 == 0 else t[:-1])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_config(obj: Mapping[str, Any], **overrides: Any) -> Config:
|
|
49
|
+
if not isinstance(obj, Mapping):
|
|
50
|
+
raise ConfigError("the configuration must be a JSON object")
|
|
51
|
+
obj = {k: v for k, v in obj.items() if not str(k).startswith("_")}
|
|
52
|
+
unknown = set(obj) - _TOP_KEYS
|
|
53
|
+
if unknown:
|
|
54
|
+
raise ConfigError(f"unknown configuration key(s): {', '.join(sorted(unknown))}")
|
|
55
|
+
cols_raw = obj.get("columns")
|
|
56
|
+
if not isinstance(cols_raw, Mapping) or not cols_raw:
|
|
57
|
+
raise ConfigError("'columns' must list every column of the table with a role")
|
|
58
|
+
|
|
59
|
+
columns: dict[str, ColumnSpec] = {}
|
|
60
|
+
for name, spec in cols_raw.items():
|
|
61
|
+
if not isinstance(spec, Mapping):
|
|
62
|
+
raise ConfigError(f"column {name!r}: expected an object")
|
|
63
|
+
spec = {k: v for k, v in spec.items() if not str(k).startswith("_")}
|
|
64
|
+
extra = set(spec) - _COL_KEYS
|
|
65
|
+
if extra:
|
|
66
|
+
raise ConfigError(f"column {name!r}: unknown key(s): {', '.join(sorted(extra))}")
|
|
67
|
+
role_text = spec.get("role")
|
|
68
|
+
if role_text == "?" or role_text is None:
|
|
69
|
+
raise ConfigError(f"column {name!r} has no role yet (choose id, qi, sensitive, keep or drop)")
|
|
70
|
+
try:
|
|
71
|
+
role = Role(role_text)
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise ConfigError(f"column {name!r}: unknown role {role_text!r}") from None
|
|
74
|
+
action, gen = "drop", None
|
|
75
|
+
if role is Role.ID:
|
|
76
|
+
action = spec.get("action", "drop")
|
|
77
|
+
if action not in ("drop", "pseudonymize"):
|
|
78
|
+
raise ConfigError(f"column {name!r}: action must be 'drop' or 'pseudonymize'")
|
|
79
|
+
if role is Role.QI:
|
|
80
|
+
gen = {k: v for k, v in spec.items() if k not in ("role", "action")}
|
|
81
|
+
make_generalizer(gen) # validate now
|
|
82
|
+
columns[name] = ColumnSpec(name, role, action, gen)
|
|
83
|
+
|
|
84
|
+
def pick(key: str, default: Any) -> Any:
|
|
85
|
+
v = overrides.get(key)
|
|
86
|
+
return obj.get(key, default) if v is None else v
|
|
87
|
+
|
|
88
|
+
k = pick("k", 5)
|
|
89
|
+
l = pick("l", 1)
|
|
90
|
+
ms = pick("max_suppression", 0.05)
|
|
91
|
+
if not isinstance(k, int) or isinstance(k, bool) or k < 2:
|
|
92
|
+
raise ConfigError("k must be an integer >= 2")
|
|
93
|
+
if not isinstance(l, int) or isinstance(l, bool) or l < 1:
|
|
94
|
+
raise ConfigError("l must be an integer >= 1")
|
|
95
|
+
if not isinstance(ms, (int, float)) or isinstance(ms, bool) or not 0 <= ms < 1:
|
|
96
|
+
raise ConfigError("max_suppression must be a number in [0, 1)")
|
|
97
|
+
|
|
98
|
+
cfg = Config(
|
|
99
|
+
columns=columns, k=k, l=l, max_suppression=float(ms),
|
|
100
|
+
shuffle=overrides.get("shuffle", True),
|
|
101
|
+
shuffle_seed=overrides.get("shuffle_seed", 0),
|
|
102
|
+
lang=overrides.get("lang", "en"),
|
|
103
|
+
max_rows=overrides.get("max_rows", 200_000),
|
|
104
|
+
max_evals=overrides.get("max_evals", 20_000),
|
|
105
|
+
key=overrides.get("key"),
|
|
106
|
+
)
|
|
107
|
+
if not cfg.names(Role.QI):
|
|
108
|
+
raise ConfigError("at least one column needs the role 'qi' (what an outsider could already know)")
|
|
109
|
+
if cfg.l > 1 and not cfg.names(Role.SENSITIVE):
|
|
110
|
+
raise ConfigError("l > 1 needs at least one column with the role 'sensitive'")
|
|
111
|
+
if cfg.lang not in ("en", "ja"):
|
|
112
|
+
raise ConfigError("lang must be 'en' or 'ja'")
|
|
113
|
+
if any(c.role is Role.ID and c.action == "pseudonymize" for c in columns.values()) and cfg.key is None:
|
|
114
|
+
raise ConfigError("pseudonymize needs a key (create one with 'sieve-crowd keygen')")
|
|
115
|
+
for field, value in (("max_rows", cfg.max_rows), ("max_evals", cfg.max_evals)):
|
|
116
|
+
if not isinstance(value, int) or value < 1:
|
|
117
|
+
raise ConfigError(f"{field} must be a positive integer")
|
|
118
|
+
return cfg
|
sieve_crowd/csvio.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
from .model import InputError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_table(data: bytes, delimiter: str = ",") -> tuple[list[str], list[list[str]]]:
|
|
10
|
+
"""UTF-8 (BOM allowed) CSV with a header row."""
|
|
11
|
+
try:
|
|
12
|
+
text = data.decode("utf-8-sig")
|
|
13
|
+
except UnicodeDecodeError:
|
|
14
|
+
raise InputError("the input is not valid UTF-8") from None
|
|
15
|
+
try:
|
|
16
|
+
rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter))
|
|
17
|
+
except csv.Error as exc:
|
|
18
|
+
raise InputError(f"the input is not valid CSV ({exc})") from None
|
|
19
|
+
if not rows or not rows[0]:
|
|
20
|
+
raise InputError("the input has no header row")
|
|
21
|
+
header, body = rows[0], rows[1:]
|
|
22
|
+
body = [r for r in body if r != []] # blank lines
|
|
23
|
+
for i, r in enumerate(body, start=2):
|
|
24
|
+
if len(r) != len(header):
|
|
25
|
+
raise InputError(f"row {i} has {len(r)} fields, the header has {len(header)}")
|
|
26
|
+
return header, body
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_table(header: list[str], rows: list[list[str]], delimiter: str = ",") -> bytes:
|
|
30
|
+
buf = io.StringIO(newline="")
|
|
31
|
+
w = csv.writer(buf, delimiter=delimiter, lineterminator="\n")
|
|
32
|
+
w.writerow(header)
|
|
33
|
+
w.writerows(rows)
|
|
34
|
+
return buf.getvalue().encode("utf-8")
|
sieve_crowd/engine.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Orchestration: read roles -> generalize -> search -> suppress -> shuffle -> measure -> receipt."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .generalize import Generalizer, make_generalizer
|
|
12
|
+
from .measure import measure
|
|
13
|
+
from .model import ConfigError, Role
|
|
14
|
+
from .prng import SplitMix64
|
|
15
|
+
from .pseudonym import pseudonym
|
|
16
|
+
from .report import Report, base
|
|
17
|
+
from .search import Infeasible, Searcher, SearchLimitExceeded
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Result:
|
|
22
|
+
status: str # "anonymized" | "refused"
|
|
23
|
+
header: Optional[list[str]]
|
|
24
|
+
rows: Optional[list[list[str]]]
|
|
25
|
+
report: Report
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _norm(v: str) -> str:
|
|
29
|
+
return unicodedata.normalize("NFC", v).strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _refuse(reason: str, n_in: int, cfg: Config,
|
|
33
|
+
io: Optional[tuple[str, Optional[str]]] = None) -> Result:
|
|
34
|
+
d = base("refused", reason=reason)
|
|
35
|
+
d["input"] = {"records": n_in}
|
|
36
|
+
_add_io(d, io)
|
|
37
|
+
return Result("refused", None, None, Report(d))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_io(d: dict, io: Optional[tuple[str, Optional[str]]]) -> None:
|
|
41
|
+
if io:
|
|
42
|
+
entry: dict = {"input_format": io[0]}
|
|
43
|
+
if io[1]:
|
|
44
|
+
entry["output_format"] = io[1]
|
|
45
|
+
d["io"] = entry
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_columns(header: Sequence[str], cfg: Config) -> None:
|
|
49
|
+
if len(set(header)) != len(header):
|
|
50
|
+
raise ConfigError("the table has duplicate column names")
|
|
51
|
+
missing = [h for h in header if h not in cfg.columns]
|
|
52
|
+
if missing:
|
|
53
|
+
raise ConfigError(f"column(s) without a role: {', '.join(missing)}")
|
|
54
|
+
extra = [c for c in cfg.columns if c not in header]
|
|
55
|
+
if extra:
|
|
56
|
+
raise ConfigError(f"configuration names column(s) not in the table: {', '.join(extra)}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def anonymize(header: Sequence[str], rows: Sequence[Sequence[str]], cfg: Config,
|
|
60
|
+
*, io: Optional[tuple[str, Optional[str]]] = None) -> Result:
|
|
61
|
+
"""io, when given, is (input_format, output_format or None) and is recorded in the receipt."""
|
|
62
|
+
header = list(header)
|
|
63
|
+
_check_columns(header, cfg)
|
|
64
|
+
n = len(rows)
|
|
65
|
+
if n == 0:
|
|
66
|
+
return _refuse("no_records", n, cfg, io)
|
|
67
|
+
if n > cfg.max_rows:
|
|
68
|
+
return _refuse("resource_limit_rows", n, cfg, io)
|
|
69
|
+
|
|
70
|
+
pos = {name: i for i, name in enumerate(header)}
|
|
71
|
+
qi_cols = [h for h in header if cfg.columns[h].role is Role.QI]
|
|
72
|
+
sens_cols = [h for h in header if cfg.columns[h].role is Role.SENSITIVE]
|
|
73
|
+
|
|
74
|
+
# --- generalization tables ---------------------------------------------------------
|
|
75
|
+
gens: list[Generalizer] = []
|
|
76
|
+
values: list[list[str]] = []
|
|
77
|
+
codes: list[list[list[int]]] = []
|
|
78
|
+
cards: list[list[int]] = []
|
|
79
|
+
for c in qi_cols:
|
|
80
|
+
col = [_norm(r[pos[c]]) for r in rows]
|
|
81
|
+
distinct = list(dict.fromkeys(col))
|
|
82
|
+
g = make_generalizer(cfg.columns[c].gen or {})
|
|
83
|
+
g.prepare(distinct)
|
|
84
|
+
per_level, per_card = [], []
|
|
85
|
+
for lvl in range(g.levels + 1):
|
|
86
|
+
ids: dict[str, int] = {}
|
|
87
|
+
of_value = {v: ids.setdefault(g.label(v, lvl), len(ids)) for v in distinct}
|
|
88
|
+
per_level.append([of_value[v] for v in col])
|
|
89
|
+
per_card.append(len(ids))
|
|
90
|
+
gens.append(g); values.append(col); codes.append(per_level); cards.append(per_card)
|
|
91
|
+
|
|
92
|
+
sens_codes: list[list[int]] = []
|
|
93
|
+
for c in sens_cols:
|
|
94
|
+
ids2: dict[str, int] = {}
|
|
95
|
+
sens_codes.append([ids2.setdefault(_norm(r[pos[c]]), len(ids2)) for r in rows])
|
|
96
|
+
|
|
97
|
+
# --- search ------------------------------------------------------------------------
|
|
98
|
+
budget = min(n - 1, int(math.floor(cfg.max_suppression * n)))
|
|
99
|
+
searcher = Searcher(codes, cards, sens_codes, n, cfg.k, cfg.l, budget, cfg.max_evals)
|
|
100
|
+
try:
|
|
101
|
+
found = searcher.run()
|
|
102
|
+
except Infeasible:
|
|
103
|
+
return _refuse("infeasible", n, cfg, io)
|
|
104
|
+
except SearchLimitExceeded:
|
|
105
|
+
return _refuse("resource_limit_search", n, cfg, io)
|
|
106
|
+
|
|
107
|
+
# --- build the output table --------------------------------------------------------
|
|
108
|
+
dropped = set(found.suppressed)
|
|
109
|
+
chosen = dict(zip(qi_cols, found.levels))
|
|
110
|
+
qi_labels = {}
|
|
111
|
+
for c, g, col in zip(qi_cols, gens, values):
|
|
112
|
+
lvl = chosen[c]
|
|
113
|
+
qi_labels[c] = {v: g.label(v, lvl) for v in dict.fromkeys(col)}
|
|
114
|
+
qi_index = {c: i for i, c in enumerate(qi_cols)}
|
|
115
|
+
|
|
116
|
+
out_header = [h for h in header
|
|
117
|
+
if cfg.columns[h].role not in (Role.DROP,)
|
|
118
|
+
and not (cfg.columns[h].role is Role.ID and cfg.columns[h].action == "drop")]
|
|
119
|
+
out_rows: list[list[str]] = []
|
|
120
|
+
for ri, r in enumerate(rows):
|
|
121
|
+
if ri in dropped:
|
|
122
|
+
continue
|
|
123
|
+
out = []
|
|
124
|
+
for h in out_header:
|
|
125
|
+
spec = cfg.columns[h]
|
|
126
|
+
if spec.role is Role.QI:
|
|
127
|
+
out.append(qi_labels[h][values[qi_index[h]][ri]])
|
|
128
|
+
elif spec.role is Role.ID:
|
|
129
|
+
out.append(pseudonym(cfg.key, h, r[pos[h]]))
|
|
130
|
+
else:
|
|
131
|
+
out.append(r[pos[h]])
|
|
132
|
+
out_rows.append(out)
|
|
133
|
+
|
|
134
|
+
if cfg.shuffle:
|
|
135
|
+
SplitMix64(cfg.shuffle_seed).shuffle(out_rows)
|
|
136
|
+
|
|
137
|
+
# --- verify on the output itself ---------------------------------------------------
|
|
138
|
+
m = measure(out_header, out_rows, qi_cols, sens_cols)
|
|
139
|
+
if m["k"] < cfg.k or any(v < cfg.l for v in m["diversity"].values()) or m["records"] == 0:
|
|
140
|
+
return _refuse("verification_failed", n, cfg, io)
|
|
141
|
+
|
|
142
|
+
# --- receipt -----------------------------------------------------------------------
|
|
143
|
+
before = measure(header, rows, qi_cols, [])
|
|
144
|
+
tops = searcher.tops
|
|
145
|
+
out_pos = {h: i for i, h in enumerate(out_header)}
|
|
146
|
+
cols_report = []
|
|
147
|
+
for h in header:
|
|
148
|
+
spec = cfg.columns[h]
|
|
149
|
+
entry = {"name": h, "role": spec.role.value}
|
|
150
|
+
if spec.role is Role.ID:
|
|
151
|
+
entry["action"] = spec.action
|
|
152
|
+
elif spec.role is Role.QI:
|
|
153
|
+
g, lvl = gens[qi_index[h]], chosen[h]
|
|
154
|
+
entry.update(level=lvl, top_level=g.levels,
|
|
155
|
+
description_en=g.describe(lvl, "en"), description_ja=g.describe(lvl, "ja"))
|
|
156
|
+
elif spec.role is Role.KEEP:
|
|
157
|
+
col_vals = [r[out_pos[h]] for r in out_rows]
|
|
158
|
+
entry["unique_ratio"] = round(len(set(col_vals)) / len(col_vals), 4) if col_vals else 0.0
|
|
159
|
+
cols_report.append(entry)
|
|
160
|
+
|
|
161
|
+
d = base("anonymized")
|
|
162
|
+
d["input"] = {"records": n, "columns": len(header)}
|
|
163
|
+
d["settings"] = {"k": cfg.k, "l": cfg.l, "max_suppression": cfg.max_suppression}
|
|
164
|
+
d["result"] = {"records_out": len(out_rows), "suppressed": len(dropped),
|
|
165
|
+
"suppression_rate": len(dropped) / n}
|
|
166
|
+
d["privacy"] = {
|
|
167
|
+
"quasi_identifiers": qi_cols,
|
|
168
|
+
"k_requested": cfg.k, "k_achieved": m["k"], "crowds": m["crowds"],
|
|
169
|
+
"smallest_crowds": m["smallest_crowds"], "records_alone_in_crowd": m["records_in_crowd_of_1"],
|
|
170
|
+
"max_risk": m["max_risk"], "average_risk": m["average_risk"],
|
|
171
|
+
"l_requested": cfg.l, "diversity": m["diversity"],
|
|
172
|
+
"before": {"records_alone": before["records_in_crowd_of_1"],
|
|
173
|
+
"records_alone_ratio": before["records_in_crowd_of_1"] / n,
|
|
174
|
+
"smallest_crowd": before["k"]},
|
|
175
|
+
}
|
|
176
|
+
d["information_loss"] = {"height": sum(found.levels), "max_height": sum(tops),
|
|
177
|
+
"precision_loss": float(searcher.loss(found.levels) / len(tops))}
|
|
178
|
+
d["columns"] = cols_report
|
|
179
|
+
d["order"] = {"shuffled": cfg.shuffle, "seed": cfg.shuffle_seed if cfg.shuffle else None}
|
|
180
|
+
d["relaxations"] = [] if cfg.shuffle else ["keep_order"]
|
|
181
|
+
d["search"] = {"evaluations": found.evaluations, "refinement_truncated": found.truncated}
|
|
182
|
+
d["verification"] = "passed"
|
|
183
|
+
_add_io(d, io)
|
|
184
|
+
return Result("anonymized", out_header, out_rows, Report(d))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def inspect_table(header: Sequence[str], rows: Sequence[Sequence[str]]) -> list[dict]:
|
|
191
|
+
"""Observations per column (no roles are guessed)."""
|
|
192
|
+
n = len(rows)
|
|
193
|
+
out = []
|
|
194
|
+
for i, h in enumerate(header):
|
|
195
|
+
col = [r[i].strip() for r in rows]
|
|
196
|
+
nonempty = [v for v in col if v != ""]
|
|
197
|
+
if nonempty and all(re.match(r"^[+-]?\d+$", v) for v in nonempty):
|
|
198
|
+
shape = "integer"
|
|
199
|
+
elif nonempty and all(_DATE.match(v) for v in nonempty):
|
|
200
|
+
shape = "date"
|
|
201
|
+
else:
|
|
202
|
+
shape = "text"
|
|
203
|
+
out.append({"name": h, "distinct": len(set(col)), "empty": len(col) - len(nonempty),
|
|
204
|
+
"unique_ratio": round(len(set(col)) / n, 4) if n else 0.0, "shape": shape})
|
|
205
|
+
return out
|