twinwise 0.3.6__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.
twinwise/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Twinwise - make a safe twin of business spreadsheets before you use AI.
2
+
3
+ Everything runs on your own computer. Nothing is uploaded.
4
+ """
5
+ __version__ = "0.3.6"
6
+
7
+ from .engine import Job # noqa: E402
8
+ from .keyfile import load_key, restore # noqa: E402
9
+ from .library import Library # noqa: E402
10
+ from .twin import Settings # noqa: E402
11
+
12
+ __all__ = ["Job", "Library", "Settings", "restore", "load_key", "__version__"]
twinwise/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
twinwise/analyze.py ADDED
@@ -0,0 +1,371 @@
1
+ """Understanding a sheet: cell kinds, header row, column roles, totals and formulas.
2
+
3
+ Nothing here changes data. The output (``SheetAnalysis``) tells the twin builder
4
+ what each cell is and how numbers relate to each other, so the twin can be
5
+ rebuilt to "add up" like the original.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+
12
+ from .document import Document, Sheet
13
+ from .numbers import is_date_text, is_date_value, is_number, parse_number_text
14
+ from .vocab import (CATEGORY_KEYWORDS, COUNT_KEYWORDS, DATE_KEYWORDS, ID_KEYWORDS, PERCENT_KEYWORDS,
15
+ PRICE_KEYWORDS)
16
+
17
+ _TOTAL = re.compile(r"\b(grand\s+)?(sub\s*-?\s*)?totals?\b", re.I)
18
+
19
+ ROLE_LABELS = {
20
+ "measure": "Number · scale",
21
+ "count": "Count · scale",
22
+ "price": "Price · scale",
23
+ "percent": "Percent · scale",
24
+ "id": "ID · replace",
25
+ "date": "Date · keep",
26
+ "entity": "Name · replace",
27
+ "text": "Text · check",
28
+ "empty": "Empty",
29
+ }
30
+
31
+
32
+ def cell_kind(v) -> str:
33
+ if v is None or (isinstance(v, str) and not v.strip()):
34
+ return "empty"
35
+ if isinstance(v, bool):
36
+ return "bool"
37
+ if is_number(v):
38
+ return "number"
39
+ if is_date_value(v):
40
+ return "date"
41
+ if isinstance(v, str):
42
+ if v.startswith("="):
43
+ return "formula"
44
+ if parse_number_text(v):
45
+ return "numtext"
46
+ if is_date_text(v):
47
+ return "date"
48
+ return "text"
49
+ return "other"
50
+
51
+
52
+ def _has_kw(text: str, keywords) -> bool:
53
+ t = " " + re.sub(r"[_\-]+", " ", text.lower()) + " "
54
+ for k in keywords:
55
+ if not k.isalnum() and len(k) <= 2:
56
+ if k in t:
57
+ return True
58
+ elif re.search(r"(?<![a-z0-9])" + re.escape(k) + r"(?![a-z0-9])", t):
59
+ return True
60
+ return False
61
+
62
+
63
+ def category_for(label: str) -> str | None:
64
+ for cat, kws in CATEGORY_KEYWORDS:
65
+ if _has_kw(label, kws):
66
+ return cat
67
+ return None
68
+
69
+
70
+ @dataclass
71
+ class Column:
72
+ index: int
73
+ header: str = ""
74
+ role: str = "text"
75
+ category: str | None = None
76
+
77
+ @property
78
+ def label(self) -> str:
79
+ return ROLE_LABELS.get(self.role, self.role)
80
+
81
+
82
+ @dataclass
83
+ class SheetAnalysis:
84
+ sheet: Sheet
85
+ header_row: int = 0
86
+ columns: dict[int, Column] = field(default_factory=dict)
87
+ kinds: dict[tuple[int, int], str] = field(default_factory=dict)
88
+ total_rows: set[int] = field(default_factory=set)
89
+ data_rows: list[int] = field(default_factory=list)
90
+ # value cells next to labels above the table: (r,c) -> ("id"|"entity"|"measure", category)
91
+ labelled: dict[tuple[int, int], tuple[str, str | None]] = field(default_factory=dict)
92
+ header_rows: list[int] = field(default_factory=list)
93
+ label_rows: set[int] = field(default_factory=set)
94
+ # derived[col] = (a, b, divisor, rows) meaning col = a*b/divisor on those rows
95
+ derived: dict[int, tuple[int, int, float, set[int]]] = field(default_factory=dict)
96
+ # totals[(r,c)] = list of rows summed
97
+ totals: dict[tuple[int, int], list[int]] = field(default_factory=dict)
98
+
99
+ def fingerprint(self) -> list[str]:
100
+ return [_norm(c.header) for _, c in sorted(self.columns.items()) if c.header]
101
+
102
+
103
+ def _norm(s) -> str:
104
+ return re.sub(r"\s+", " ", str(s or "").strip().lower())
105
+
106
+
107
+ def num_value(v):
108
+ if is_number(v):
109
+ return float(v)
110
+ p = parse_number_text(v) if isinstance(v, str) else None
111
+ return p[0] if p else None
112
+
113
+
114
+ def analyze_sheet(sheet: Sheet) -> SheetAnalysis:
115
+ a = SheetAnalysis(sheet=sheet)
116
+ for (r, c), v in sheet.values.items():
117
+ a.kinds[(r, c)] = cell_kind(v)
118
+
119
+ a.header_row = _find_header(a)
120
+ header_rows = [a.header_row] if a.header_row else []
121
+ # multi-row headers: a text-only row right below (or above) the header joins it
122
+ if a.header_row:
123
+ r = a.header_row + 1
124
+ while r <= sheet.max_row and _is_text_row(a, r, min_texts=2) and r - a.header_row <= 2:
125
+ header_rows.append(r)
126
+ r += 1
127
+ r = a.header_row - 1
128
+ if r >= 1 and _is_text_row(a, r, min_texts=2):
129
+ header_rows.insert(0, r)
130
+ a.header_rows = header_rows
131
+ if header_rows:
132
+ a.header_row = max(header_rows)
133
+ hr = a.header_row
134
+
135
+ # data rows and total rows
136
+ rows = sorted({r for (r, _), k in a.kinds.items() if r > hr and k != "empty"})
137
+ for r in rows:
138
+ texts = [sheet.get(r, c) for c in range(1, sheet.max_col + 1) if a.kinds.get((r, c)) == "text"]
139
+ if any(_TOTAL.search(t) for t in texts):
140
+ a.total_rows.add(r)
141
+ else:
142
+ a.data_rows.append(r)
143
+
144
+ # small label rows inside the table ("Adjustments: | Month | Year | Amount")
145
+ from .vocab import ACRONYMS, COMMON, MONTHS, STOP_WORDS
146
+ known = COMMON | STOP_WORDS | ACRONYMS | MONTHS
147
+ for r in rows:
148
+ if _is_text_row(a, r, min_texts=2):
149
+ words = [w.lower().strip(".:'") for c in range(1, sheet.max_col + 1) if a.kinds.get((r, c)) == "text"
150
+ for w in re.findall(r"[A-Za-z][A-Za-z'.]*", str(sheet.get(r, c)))]
151
+ if words and all(w in known for w in words):
152
+ a.label_rows.add(r)
153
+
154
+ # columns
155
+ for c in range(1, sheet.max_col + 1):
156
+ parts = [re.sub(r"\s+", " ", str(sheet.get(r, c))).strip() for r in header_rows if a.kinds.get((r, c)) == "text"]
157
+ header = " ".join(p for p in parts if p)
158
+ col = Column(index=c, header=header)
159
+ ks = [a.kinds.get((r, c), "empty") for r in a.data_rows]
160
+ ks = [k for k in ks if k != "empty"]
161
+ col.role = _role(header, ks, [sheet.get(r, c) for r in a.data_rows], a)
162
+ if col.role == "entity":
163
+ col.category = category_for(header)
164
+ a.columns[c] = col
165
+
166
+ _find_labels(a)
167
+ _find_derived(a)
168
+ _find_totals(a)
169
+ return a
170
+
171
+
172
+ def _is_text_row(a: SheetAnalysis, r: int, min_texts: int = 2) -> bool:
173
+ ks = [a.kinds.get((r, c), "empty") for c in range(1, a.sheet.max_col + 1)]
174
+ filled = [k for k in ks if k != "empty"]
175
+ return len(filled) >= min_texts and all(k == "text" for k in filled) and not any(
176
+ _TOTAL.search(str(a.sheet.get(r, c))) for c in range(1, a.sheet.max_col + 1) if a.kinds.get((r, c)) == "text")
177
+
178
+
179
+ def _find_header(a: SheetAnalysis) -> int:
180
+ sh = a.sheet
181
+ best, best_score = 0, 0
182
+ for r in range(1, min(sh.max_row, 40) + 1):
183
+ cells = [(c, a.kinds.get((r, c), "empty")) for c in range(1, sh.max_col + 1)]
184
+ filled = [(c, k) for c, k in cells if k != "empty"]
185
+ texts = [c for c, k in filled if k == "text"]
186
+ if len(texts) < 2 or len(texts) / max(len(filled), 1) < 0.75:
187
+ continue
188
+ below_rows = range(r + 1, min(r + 6, sh.max_row) + 1)
189
+ numeric_cols = sum(
190
+ 1 for c in texts
191
+ if any(a.kinds.get((rr, c)) in ("number", "numtext", "date", "formula") for rr in below_rows)
192
+ )
193
+ filled_cols_below = sum(1 for c in texts if any(a.kinds.get((rr, c), "empty") != "empty" for rr in below_rows))
194
+ if filled_cols_below == 0:
195
+ continue
196
+ score = len(texts) + 2 * numeric_cols + filled_cols_below
197
+ if score > best_score:
198
+ best, best_score = r, score
199
+ return best
200
+
201
+
202
+ def _role(header: str, kinds: list[str], values: list, a: SheetAnalysis) -> str:
203
+ if not kinds:
204
+ return "empty"
205
+ n = len(kinds)
206
+ num = sum(1 for k in kinds if k in ("number", "numtext"))
207
+ dat = sum(1 for k in kinds if k == "date")
208
+ txt = sum(1 for k in kinds if k == "text")
209
+ if dat / n >= 0.6:
210
+ return "date"
211
+ if num / n >= 0.6:
212
+ h = header
213
+ if h and _has_kw(h, ("%", "percent", "pct", "percentage")):
214
+ return "percent"
215
+ nums = [num_value(v) for v in values if num_value(v) is not None]
216
+ if h and _has_kw(h, COUNT_KEYWORDS) and nums and sum(float(x).is_integer() for x in nums) >= 0.9 * len(nums):
217
+ return "count"
218
+ if h and _has_kw(h, PRICE_KEYWORDS):
219
+ return "price"
220
+ if h and _has_kw(h, PERCENT_KEYWORDS):
221
+ return "percent"
222
+ if h and _has_kw(h, ID_KEYWORDS):
223
+ ints = [v for v in values if isinstance(v, int) or (isinstance(v, str) and v.strip().isdigit())]
224
+ if len(ints) >= num * 0.9:
225
+ return "id"
226
+ if h and _has_kw(h, DATE_KEYWORDS):
227
+ ints = [v for v in values if isinstance(v, int)]
228
+ if ints and all(1 <= v <= 12 or 1900 <= v <= 2100 for v in ints):
229
+ return "date"
230
+ if any(isinstance(v, str) and parse_number_text(v) and parse_number_text(v)[1]["pct"] for v in values):
231
+ return "percent"
232
+ return "measure"
233
+ if txt / n >= 0.5:
234
+ codes = [v for v in values if isinstance(v, str) and re.search(r"\d", v) and len(v.split()) == 1]
235
+ if header and _has_kw(header, ID_KEYWORDS) and (not category_for(header) or len(codes) >= 0.6 * txt):
236
+ return "id"
237
+ if header and category_for(header):
238
+ return "entity"
239
+ return "text"
240
+ return "text"
241
+
242
+
243
+ def _find_labels(a: SheetAnalysis) -> None:
244
+ """Key/value pairs above (or without) a table: 'Vendor Number | 418207'."""
245
+ sh = a.sheet
246
+ limit = a.header_row - 1 if a.header_row else sh.max_row
247
+ for r in range(1, limit + 1):
248
+ c = 1
249
+ while c <= sh.max_col:
250
+ if a.kinds.get((r, c)) == "text":
251
+ label = str(sh.get(r, c))
252
+ if len(label) <= 40:
253
+ nc = c + 1
254
+ while nc <= sh.max_col and a.kinds.get((r, nc), "empty") == "empty":
255
+ nc += 1
256
+ if nc <= sh.max_col and nc - c <= 6:
257
+ k = a.kinds.get((r, nc))
258
+ lbl = label.rstrip(": ")
259
+ if _has_kw(lbl, ID_KEYWORDS) and not _has_kw(lbl, COUNT_KEYWORDS):
260
+ a.labelled[(r, nc)] = ("id", "ID")
261
+ elif k == "text" and category_for(lbl):
262
+ a.labelled[(r, nc)] = ("entity", category_for(lbl))
263
+ elif k in ("number", "numtext"):
264
+ a.labelled[(r, nc)] = ("measure", None)
265
+ if (r, nc) in a.labelled:
266
+ c = nc + 1
267
+ continue
268
+ c += 1
269
+
270
+
271
+ def _numeric_cols(a: SheetAnalysis) -> list[int]:
272
+ return [c for c, col in a.columns.items() if col.role in ("measure", "count", "price", "percent")]
273
+
274
+
275
+ def _find_derived(a: SheetAnalysis) -> None:
276
+ cols = _numeric_cols(a)
277
+ sh = a.sheet
278
+ vals = {c: {r: num_value(sh.get(r, c)) for r in a.data_rows} for c in cols}
279
+ for t in cols:
280
+ if a.columns[t].role == "count":
281
+ continue
282
+ best = None
283
+ for i, x in enumerate(cols):
284
+ for y in cols[i:]:
285
+ if t in (x, y):
286
+ continue
287
+ for div in (1.0, 100.0):
288
+ ok, tested = set(), 0
289
+ for r in a.data_rows:
290
+ vx, vy, vt = vals[x].get(r), vals[y].get(r), vals[t].get(r)
291
+ if vx is None or vy is None or vt is None:
292
+ continue
293
+ tested += 1
294
+ exp = vx * vy / div
295
+ if abs(exp - vt) <= max(0.011, abs(vt) * 0.002):
296
+ ok.add(r)
297
+ if tested >= 2 and len(ok) >= max(2, 0.9 * tested):
298
+ if not any(abs(vals[x].get(r) or 0) in (0.0, 1.0) and abs(vals[y].get(r) or 0) in (0.0, 1.0) for r in ok):
299
+ if best is None or len(ok) > len(best[3]):
300
+ best = (x, y, div, ok)
301
+ if best is None:
302
+ # t = x - y or t = x + y (net = gross - deductions)
303
+ for x in cols:
304
+ for y in cols:
305
+ if len({t, x, y}) < 3:
306
+ continue
307
+ for code, fn in ((-1.0, lambda p, q: p - q), (-2.0, lambda p, q: p + q), (-3.0, lambda p, q: (p + q) / 2)):
308
+ if code in (-2.0, -3.0) and y < x:
309
+ continue
310
+ ok, tested = set(), 0
311
+ for r in a.data_rows:
312
+ vx, vy, vt = vals[x].get(r), vals[y].get(r), vals[t].get(r)
313
+ if vx is None or vy is None or vt is None:
314
+ continue
315
+ tested += 1
316
+ if abs(fn(vx, vy) - vt) <= max(0.011, abs(vt) * 0.002) and vy != 0:
317
+ ok.add(r)
318
+ if tested >= 2 and len(ok) >= max(2, 0.9 * tested) and (best is None or len(ok) > len(best[3])):
319
+ best = (x, y, code, ok)
320
+ if best is None:
321
+ # t = max(x, y) - e.g. "Greater Value" of a split rate and a minimum rate
322
+ for i, x in enumerate(cols):
323
+ for y in cols[i + 1:]:
324
+ if t in (x, y):
325
+ continue
326
+ ok, tested = set(), 0
327
+ for r in a.data_rows:
328
+ vx, vy, vt = vals[x].get(r), vals[y].get(r), vals[t].get(r)
329
+ if vx is None or vy is None or vt is None:
330
+ continue
331
+ tested += 1
332
+ if abs(max(vx, vy) - vt) <= max(0.011, abs(vt) * 0.002):
333
+ ok.add(r)
334
+ if tested >= 2 and len(ok) >= max(2, 0.9 * tested) and (best is None or len(ok) > len(best[3])):
335
+ best = (x, y, 0.0, ok)
336
+ if best:
337
+ a.derived[t] = best
338
+ # avoid cycles: a derived column may not feed one of its own inputs
339
+ for t, (x, y, _, _) in list(a.derived.items()):
340
+ for src in (x, y):
341
+ if src in a.derived and t in a.derived[src][:2]:
342
+ a.derived.pop(t, None)
343
+
344
+
345
+ def _find_totals(a: SheetAnalysis) -> None:
346
+ sh = a.sheet
347
+ cols = _numeric_cols(a)
348
+ data = a.data_rows
349
+ tot_sorted = sorted(a.total_rows)
350
+ for c in cols:
351
+ prev_total = a.header_row
352
+ earlier_totals: list[int] = []
353
+ for tr in tot_sorted:
354
+ tv = num_value(sh.get(tr, c))
355
+ if tv is None:
356
+ prev_total = tr
357
+ earlier_totals.append(tr)
358
+ continue
359
+ block = [r for r in data if prev_total < r < tr and num_value(sh.get(r, c)) is not None]
360
+ allrows = [r for r in data if r < tr and num_value(sh.get(r, c)) is not None]
361
+ cands = [block, allrows, [r for r in earlier_totals if num_value(sh.get(r, c)) is not None]]
362
+ for rows in cands:
363
+ if rows and abs(sum(num_value(sh.get(r, c)) for r in rows) - tv) <= max(0.02, abs(tv) * 0.001):
364
+ a.totals[(tr, c)] = rows
365
+ break
366
+ prev_total = tr
367
+ earlier_totals.append(tr)
368
+
369
+
370
+ def analyze(doc: Document) -> list[SheetAnalysis]:
371
+ return [analyze_sheet(s) for s in doc.sheets]
twinwise/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """Command line: ``twinwise twin``, ``twinwise restore``, ``twinwise ui``, ``twinwise demo``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+
10
+
11
+ def _unlock_library() -> None:
12
+ """If the library is password-protected, get the password (environment variable or prompt)."""
13
+ import getpass
14
+ import os
15
+
16
+ from . import library as L
17
+
18
+ if not L.is_protected() or L.is_unlocked():
19
+ return
20
+ pw = os.environ.get("TWINWISE_PASSWORD")
21
+ for attempt in range(3):
22
+ if not pw:
23
+ pw = getpass.getpass("Library password: ")
24
+ try:
25
+ L.unlock(pw)
26
+ return
27
+ except L.WrongPassword:
28
+ print("Wrong password.", file=sys.stderr)
29
+ pw = None
30
+ if os.environ.get("TWINWISE_PASSWORD"):
31
+ break
32
+ raise SystemExit("Could not unlock the library.")
33
+
34
+
35
+ def _twin(args) -> int:
36
+ from .engine import Job
37
+ from .twin import Settings
38
+
39
+ import os
40
+
41
+ _unlock_library()
42
+ settings = Settings(mode=args.mode, price_points=not args.no_price_points, stable_factors=not args.no_stable,
43
+ variation=args.variation)
44
+ key_password = args.key_password or os.environ.get("TWINWISE_KEY_PASSWORD") or None
45
+ job = Job.create(args.files, settings=settings)
46
+ job._settings_locked = True
47
+ out = Path(args.out or (Path.home() / "Twinwise" / "exports"))
48
+ for fj in job.files:
49
+ c = fj.twin.counts()
50
+ tmpl = f"template '{fj.template_name}' ({fj.template_score:.0%})" if fj.template else "new layout"
51
+ print(f"• {fj.doc.name}\n {c.get('number', 0)} numbers, {c.get('name', 0) + c.get('id', 0)} names/IDs, "
52
+ f"{c.get('review', 0)} auto-masked for review · {tmpl}")
53
+ rep = job.export(out, save_learning=not args.no_learn, context=not args.no_context, key_password=key_password)
54
+ print()
55
+ for f in rep["files"]:
56
+ status = "0 leaks" if f["leak"]["ok"] else f"{f['leak']['leak_count']} LEAKS - do not share"
57
+ print(f" {f['twin_name']} · {status} · risk {f['risk']['level']}")
58
+ for c in f.get("context", []):
59
+ print(f" + {Path(c).name}")
60
+ if f.get("removed"):
61
+ print(" removed: " + ", ".join(f"{x['count']} × {x['what']}" for x in f["removed"]))
62
+ lock_note = "password-protected" if rep["key_protected"] else "NOT password-protected - use --key-password"
63
+ print(f"\nSaved to {rep['out_dir']}\nPrivate key: {rep['key_path']} ({lock_note}; never share this file)")
64
+ job.cleanup()
65
+ return 0 if rep["all_clear"] else 2
66
+
67
+
68
+ def _restore(args) -> int:
69
+ import getpass
70
+ import os
71
+
72
+ from . import library as L
73
+ from .keyfile import WrongPassword, is_encrypted_key, load_key, restore
74
+
75
+ pw = args.password or os.environ.get("TWINWISE_KEY_PASSWORD") or os.environ.get("TWINWISE_PASSWORD")
76
+ if is_encrypted_key(args.key) and not pw:
77
+ pw = getpass.getpass("Key file password: ")
78
+ try:
79
+ key = load_key(args.key, pw)
80
+ except WrongPassword:
81
+ raise SystemExit("Wrong password for this key file.")
82
+ text = Path(args.file).read_text(encoding="utf-8") if args.file and args.file != "-" else sys.stdin.read()
83
+ res = restore(text, key)
84
+ print(res["text"])
85
+ if res["note"]:
86
+ print("\n# " + res["note"], file=sys.stderr)
87
+ return 0
88
+
89
+
90
+ def _protect(args) -> int:
91
+ import getpass
92
+
93
+ from . import library as L
94
+
95
+ _unlock_library()
96
+ if args.off:
97
+ L.set_protection(None)
98
+ print("Library protection is off. The library is stored as plain files again.")
99
+ return 0
100
+ a = getpass.getpass("New library password (at least 8 characters): ")
101
+ if a != getpass.getpass("Repeat the password: "):
102
+ raise SystemExit("The two passwords are not the same.")
103
+ try:
104
+ L.set_protection(a)
105
+ except ValueError as e:
106
+ raise SystemExit(str(e))
107
+ print("Library protection is on. There is no password recovery - keep the password safe.")
108
+ return 0
109
+
110
+
111
+ def _ui(args) -> int:
112
+ from .server import run
113
+
114
+ run(host=args.host, port=args.port, open_browser=not args.no_browser)
115
+ return 0
116
+
117
+
118
+ def _demo(args) -> int:
119
+ from .demo import write_demo
120
+
121
+ out = Path(args.out or (Path.home() / "Twinwise" / "demo"))
122
+ for p in write_demo(out):
123
+ print(p)
124
+ return 0
125
+
126
+
127
+ def main(argv=None) -> int:
128
+ p = argparse.ArgumentParser(prog="twinwise", description="Make a safe twin of business spreadsheets before you use AI. Runs locally.")
129
+ p.add_argument("--version", action="version", version=f"twinwise {__version__}")
130
+ sub = p.add_subparsers(dest="cmd")
131
+
132
+ t = sub.add_parser("twin", help="create twins of .xlsx/.csv/.zip files")
133
+ t.add_argument("files", nargs="+")
134
+ t.add_argument("-o", "--out", help="output folder (default ~/Twinwise/exports)")
135
+ t.add_argument("--mode", choices=["consistent", "scale", "random"], default="consistent",
136
+ help="consistent: totals and formulas still add up (default); scale: one hidden factor; random: noise")
137
+ t.add_argument("--no-price-points", action="store_true", help="do not round prices to .99 points")
138
+ t.add_argument("--no-stable", action="store_true", help="new hidden factors every time (trends across months not comparable)")
139
+ t.add_argument("--no-learn", action="store_true", help="do not save templates or names to your library")
140
+ t.add_argument("--no-context", action="store_true", help="skip the AI-ready context doc and RAG chunks")
141
+ t.add_argument("--variation", type=float, default=0.05, choices=[0, 0.05, 0.1, 0.2],
142
+ help="hidden extra change per row in consistent mode (default 0.05 = up to ±5%%)")
143
+ t.add_argument("--key-password", help="encrypt the key file with this password (or set TWINWISE_KEY_PASSWORD)")
144
+ t.set_defaults(fn=_twin)
145
+
146
+ r = sub.add_parser("restore", help="turn an AI answer about the twin back into real names")
147
+ r.add_argument("--key", required=True, help="the twin-key_*.json file")
148
+ r.add_argument("--password", help="password of the key file (you are asked if it is needed)")
149
+ r.add_argument("file", nargs="?", help="text file with the AI answer (default: read from stdin)")
150
+ r.set_defaults(fn=_restore)
151
+
152
+ pr = sub.add_parser("protect", help="password-protect the library (names, IDs, factors, history)")
153
+ pr.add_argument("--off", action="store_true", help="turn protection off")
154
+ pr.set_defaults(fn=_protect)
155
+
156
+ u = sub.add_parser("ui", help="open the review screen in your browser")
157
+ u.add_argument("--port", type=int, default=8765)
158
+ u.add_argument("--host", default="127.0.0.1")
159
+ u.add_argument("--no-browser", action="store_true")
160
+ u.set_defaults(fn=_ui)
161
+
162
+ d = sub.add_parser("demo", help="write made-up demo files to try Twinwise")
163
+ d.add_argument("-o", "--out")
164
+ d.set_defaults(fn=_demo)
165
+
166
+ args = p.parse_args(argv)
167
+ if not getattr(args, "fn", None):
168
+ args = p.parse_args(["ui"])
169
+ return args.fn(args)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ raise SystemExit(main())
twinwise/crypto.py ADDED
@@ -0,0 +1,89 @@
1
+ """Password protection for the key file and the library.
2
+
3
+ Uses AES-256-GCM with a key derived from your password by scrypt, both from the
4
+ widely used ``cryptography`` package. Nothing here is home-made cryptography.
5
+
6
+ There is no password recovery. If the password is lost, the protected file
7
+ cannot be opened.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+
15
+ from cryptography.exceptions import InvalidTag
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
18
+
19
+ FORMAT = "twinwise-encrypted/1"
20
+ MIN_PASSWORD = 8
21
+ _N, _R, _P = 2 ** 15, 8, 1
22
+ _KEY_CACHE: dict[tuple[str, bytes], bytes] = {}
23
+
24
+
25
+ class WrongPassword(Exception):
26
+ """The password is missing or does not open this file."""
27
+
28
+
29
+ def check_password(password: str) -> None:
30
+ if not password or len(password) < MIN_PASSWORD:
31
+ raise ValueError(f"Use a password of at least {MIN_PASSWORD} characters.")
32
+
33
+
34
+ def _derive(password: str, salt: bytes) -> bytes:
35
+ ck = (password, salt)
36
+ if ck not in _KEY_CACHE:
37
+ if len(_KEY_CACHE) > 8:
38
+ _KEY_CACHE.clear()
39
+ _KEY_CACHE[ck] = Scrypt(salt=salt, length=32, n=_N, r=_R, p=_P).derive(password.encode("utf-8"))
40
+ return _KEY_CACHE[ck]
41
+
42
+
43
+ def forget_keys() -> None:
44
+ _KEY_CACHE.clear()
45
+
46
+
47
+ def is_envelope(obj) -> bool:
48
+ return isinstance(obj, dict) and obj.get("format") == FORMAT
49
+
50
+
51
+ def encrypt(data: bytes, password: str, kind: str, salt: bytes | None = None) -> dict:
52
+ """Return a JSON-serialisable envelope holding ``data`` encrypted with ``password``."""
53
+ salt = salt or os.urandom(16)
54
+ nonce = os.urandom(12)
55
+ ct = AESGCM(_derive(password, salt)).encrypt(nonce, data, f"{FORMAT}|{kind}".encode())
56
+ b64 = lambda b: base64.b64encode(b).decode("ascii")
57
+ return {"format": FORMAT, "kind": kind, "cipher": "AES-256-GCM", "kdf": "scrypt", "n": _N, "r": _R, "p": _P,
58
+ "salt": b64(salt), "nonce": b64(nonce), "data": b64(ct),
59
+ "note": "Encrypted by Twinwise. Open it with Twinwise and your password."}
60
+
61
+
62
+ def decrypt(envelope: dict, password: str | None) -> bytes:
63
+ if not is_envelope(envelope):
64
+ raise ValueError("Not a Twinwise encrypted file.")
65
+ if not password:
66
+ raise WrongPassword("This file is password-protected.")
67
+ try:
68
+ salt = base64.b64decode(envelope["salt"])
69
+ n, r, p = int(envelope["n"]), int(envelope["r"]), int(envelope["p"])
70
+ if not (2 ** 14 <= n <= 2 ** 20 and r == 8 and 1 <= p <= 4):
71
+ raise ValueError("Unsupported protection settings in this file.")
72
+ if n == _N and r == _R and p == _P:
73
+ key = _derive(password, salt)
74
+ else:
75
+ key = Scrypt(salt=salt, length=32, n=n, r=r, p=p).derive(password.encode("utf-8"))
76
+ data = AESGCM(key).decrypt(base64.b64decode(envelope["nonce"]), base64.b64decode(envelope["data"]),
77
+ f"{FORMAT}|{envelope.get('kind', '')}".encode())
78
+ except InvalidTag:
79
+ _KEY_CACHE.pop((password, base64.b64decode(envelope["salt"])), None)
80
+ raise WrongPassword("Wrong password.") from None
81
+ return data
82
+
83
+
84
+ def envelope_salt(envelope: dict) -> bytes:
85
+ return base64.b64decode(envelope["salt"])
86
+
87
+
88
+ def dumps(envelope: dict) -> str:
89
+ return json.dumps(envelope, indent=2)