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 +12 -0
- twinwise/__main__.py +3 -0
- twinwise/analyze.py +371 -0
- twinwise/cli.py +173 -0
- twinwise/crypto.py +89 -0
- twinwise/demo.py +163 -0
- twinwise/document.py +319 -0
- twinwise/engine.py +237 -0
- twinwise/keyfile.py +128 -0
- twinwise/library.py +337 -0
- twinwise/numbers.py +110 -0
- twinwise/rag.py +207 -0
- twinwise/server.py +468 -0
- twinwise/textmask.py +217 -0
- twinwise/twin.py +408 -0
- twinwise/ui/app.css +213 -0
- twinwise/ui/app.js +648 -0
- twinwise/ui/index.html +30 -0
- twinwise/ui/logo.svg +1 -0
- twinwise/verify.py +304 -0
- twinwise/vocab.py +112 -0
- twinwise-0.3.6.dist-info/METADATA +250 -0
- twinwise-0.3.6.dist-info/RECORD +28 -0
- twinwise-0.3.6.dist-info/WHEEL +5 -0
- twinwise-0.3.6.dist-info/entry_points.txt +2 -0
- twinwise-0.3.6.dist-info/licenses/LICENSE +177 -0
- twinwise-0.3.6.dist-info/licenses/NOTICE +4 -0
- twinwise-0.3.6.dist-info/top_level.txt +1 -0
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
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)
|