canot 0.1.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.
- canot/__init__.py +1 -0
- canot/cli.py +195 -0
- canot/convert.py +97 -0
- canot/copy.py +68 -0
- canot/demo.py +98 -0
- canot/drill.py +103 -0
- canot/evidence.py +72 -0
- canot/model.py +36 -0
- canot/report.py +109 -0
- canot/scan.py +48 -0
- canot/source_loader.py +7 -0
- canot/sources/__init__.py +24 -0
- canot/sources/base.py +44 -0
- canot/sources/bigquery_src.py +155 -0
- canot/sources/databricks_src.py +85 -0
- canot/sources/duckdb_src.py +69 -0
- canot/sources/snowflake_src.py +170 -0
- canot/target.py +116 -0
- canot/tolerances.py +59 -0
- canot/verify.py +220 -0
- canot-0.1.0.dist-info/METADATA +93 -0
- canot-0.1.0.dist-info/RECORD +25 -0
- canot-0.1.0.dist-info/WHEEL +4 -0
- canot-0.1.0.dist-info/entry_points.txt +2 -0
- canot-0.1.0.dist-info/licenses/LICENSE +12 -0
canot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
canot/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _p(*a):
|
|
12
|
+
print(*a, flush=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
ap = argparse.ArgumentParser(prog="canot", description="Prouvez que vous pouvez partir.")
|
|
17
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
18
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
19
|
+
|
|
20
|
+
s = sub.add_parser("scan", help="radiographie en lecture seule : inventaire, enfermement, coût de sortie estimé")
|
|
21
|
+
s.add_argument("--from", dest="src", required=True)
|
|
22
|
+
s.add_argument("--out", default="canot-out")
|
|
23
|
+
s.add_argument("--credit-price", type=float, default=3.90, help="prix du crédit Snowflake (est. Enterprise, AWS Paris)")
|
|
24
|
+
|
|
25
|
+
d = sub.add_parser("drill", help="exercice de sortie complet : copie, comparaison, dossier de preuve")
|
|
26
|
+
d.add_argument("--from", dest="src", required=True)
|
|
27
|
+
d.add_argument("--to", dest="dst", required=True, help="dossier local, s3://..., ou scaleway:fr-par, ovh:gra, outscale:eu-west-2, ionos:de..., avec --bucket")
|
|
28
|
+
d.add_argument("--bucket")
|
|
29
|
+
d.add_argument("--out", default="canot-out")
|
|
30
|
+
d.add_argument("--queries", help="optionnel : fichier SQL à rejouer. Par défaut, les requêtes les plus coûteuses de l'historique de la plateforme.")
|
|
31
|
+
d.add_argument("--top", type=int, default=20)
|
|
32
|
+
d.add_argument("--tolerances", help="fichier TOML (voir `canot init-tolerances`)")
|
|
33
|
+
d.add_argument("--tables", nargs="*", help="limiter à ces tables (SCHEMA.TABLE)")
|
|
34
|
+
d.add_argument("--key", default=str(Path.home() / ".canot" / "evidence.key"))
|
|
35
|
+
d.add_argument("--credit-price", type=float, default=3.90)
|
|
36
|
+
|
|
37
|
+
t = sub.add_parser("teardown", help="supprime les données copiées")
|
|
38
|
+
t.add_argument("--to", dest="dst", required=True)
|
|
39
|
+
t.add_argument("--bucket")
|
|
40
|
+
t.add_argument("--out", default="canot-out")
|
|
41
|
+
|
|
42
|
+
v = sub.add_parser("verify-evidence", help="vérifie la chaîne et la signature d'un dossier")
|
|
43
|
+
v.add_argument("dir")
|
|
44
|
+
|
|
45
|
+
ss = sub.add_parser("snowflake-setup", help="affiche le script SQL d'accès en lecture seule à relire")
|
|
46
|
+
ss.add_argument("--generate-key", action="store_true", help="crée la paire de clés ici et met la clé publique dans le script")
|
|
47
|
+
ss.add_argument("--base", default="<BASE>", help="base Snowflake à copier")
|
|
48
|
+
dm = sub.add_parser("demo", help="démonstration complète en 30 secondes, sans compte : distributeur fictif, exercice, dossier de preuve")
|
|
49
|
+
dm.add_argument("--out", default="canot-demo")
|
|
50
|
+
dm.add_argument("--rows", type=int, default=50_000)
|
|
51
|
+
dm.add_argument("--no-open", action="store_true", help="n'ouvre pas le rapport dans le navigateur")
|
|
52
|
+
it = sub.add_parser("init-tolerances", help="écrit un fichier de tolérances à relire avec le client")
|
|
53
|
+
it.add_argument("path", nargs="?", default="tolerances.toml")
|
|
54
|
+
dd = sub.add_parser("demo-data", help="crée une base DuckDB de démonstration")
|
|
55
|
+
dd.add_argument("path")
|
|
56
|
+
dd.add_argument("--rows", type=int, default=20_000)
|
|
57
|
+
dd.add_argument("--retail", action="store_true", help="jeu fictif de distributeur (tickets avec lignes imbriquées)")
|
|
58
|
+
|
|
59
|
+
a = ap.parse_args(argv)
|
|
60
|
+
out = Path(getattr(a, "out", "canot-out"))
|
|
61
|
+
|
|
62
|
+
if a.cmd == "snowflake-setup":
|
|
63
|
+
from .sources.snowflake_src import SETUP_SQL
|
|
64
|
+
|
|
65
|
+
sql = SETUP_SQL.replace("<BASE>", a.base)
|
|
66
|
+
if a.generate_key:
|
|
67
|
+
from cryptography.hazmat.primitives import serialization
|
|
68
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
69
|
+
import os
|
|
70
|
+
|
|
71
|
+
kp = Path.home() / ".canot" / "snowflake_rsa_key.p8"
|
|
72
|
+
if kp.exists():
|
|
73
|
+
key = serialization.load_pem_private_key(kp.read_bytes(), password=None)
|
|
74
|
+
else:
|
|
75
|
+
kp.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
77
|
+
kp.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
|
78
|
+
os.chmod(kp, 0o600)
|
|
79
|
+
pub = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode()
|
|
80
|
+
pub = "".join(l for l in pub.splitlines() if not l.startswith("-----"))
|
|
81
|
+
sql = sql.replace("<clé publique, sans les lignes BEGIN/END>", pub)
|
|
82
|
+
print(f"-- Clé privée : {kp} (reste sur cette machine). Puis : export SNOWFLAKE_PRIVATE_KEY_PATH={kp}", file=sys.stderr)
|
|
83
|
+
_p(sql)
|
|
84
|
+
return 0
|
|
85
|
+
if a.cmd == "demo":
|
|
86
|
+
import webbrowser
|
|
87
|
+
|
|
88
|
+
from .demo import RETAIL_QUERIES, make_retail
|
|
89
|
+
from .drill import run_drill
|
|
90
|
+
from .report import write_report
|
|
91
|
+
from .source_loader import open_demo
|
|
92
|
+
from .target import parse_target
|
|
93
|
+
from .tolerances import Tolerances
|
|
94
|
+
|
|
95
|
+
work = Path(a.out)
|
|
96
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
db = work / "maison.duckdb"
|
|
98
|
+
if db.exists():
|
|
99
|
+
db.unlink()
|
|
100
|
+
_p("Canot : démonstration sur un distributeur fictif (données inventées)\n")
|
|
101
|
+
make_retail(str(db), a.rows)
|
|
102
|
+
qf = work / "requetes.sql"
|
|
103
|
+
qf.write_text(RETAIL_QUERIES)
|
|
104
|
+
src = open_demo(str(db))
|
|
105
|
+
try:
|
|
106
|
+
res = run_drill(src, parse_target(str(work / "lac"), None, work), work / "dossier", Path.home() / ".canot" / "evidence.key", str(qf), Tolerances(), log=_p)
|
|
107
|
+
finally:
|
|
108
|
+
src.close()
|
|
109
|
+
rep = write_report(res, work / "dossier")
|
|
110
|
+
_p(f"\n{res['summary']['parity'] == 'complète' and 'Parité complète' or 'Des écarts ont été trouvés'} : {res['summary']['tables_ok']}/{res['summary']['tables_total']} tables, {res['summary']['queries_match']}/{res['summary']['queries_total']} requêtes.")
|
|
111
|
+
_p(f"Dossier de preuve : {rep}")
|
|
112
|
+
_p("Vérifier qu'il n'a pas été modifié : canot verify-evidence " + str(work / "dossier"))
|
|
113
|
+
_p("\nEt maintenant, sur vos données (lecture seule) : canot scan --from \"bigquery://MON-PROJET/mon_dataset?location=EU\"")
|
|
114
|
+
_p("Besoin d'aide pour l'exécuter sur votre périmètre ? https://canot.io/#contact")
|
|
115
|
+
if not a.no_open:
|
|
116
|
+
webbrowser.open(rep.resolve().as_uri())
|
|
117
|
+
return 0
|
|
118
|
+
if a.cmd == "init-tolerances":
|
|
119
|
+
from .tolerances import DEFAULT_TOML
|
|
120
|
+
|
|
121
|
+
Path(a.path).write_text(DEFAULT_TOML)
|
|
122
|
+
_p(f"écrit : {a.path}")
|
|
123
|
+
return 0
|
|
124
|
+
if a.cmd == "demo-data":
|
|
125
|
+
from .demo import DEMO_QUERIES, RETAIL_QUERIES, make_demo, make_retail
|
|
126
|
+
|
|
127
|
+
if a.retail:
|
|
128
|
+
make_retail(a.path, a.rows)
|
|
129
|
+
else:
|
|
130
|
+
make_demo(a.path, a.rows)
|
|
131
|
+
q = Path(a.path).with_suffix(".queries.sql")
|
|
132
|
+
q.write_text(RETAIL_QUERIES if a.retail else DEMO_QUERIES)
|
|
133
|
+
_p(f"écrit : {a.path} et {q}")
|
|
134
|
+
return 0
|
|
135
|
+
if a.cmd == "verify-evidence":
|
|
136
|
+
from .evidence import verify_dir
|
|
137
|
+
|
|
138
|
+
ok, msg = verify_dir(Path(a.dir))
|
|
139
|
+
_p(("OK : " if ok else "ÉCHEC : ") + msg)
|
|
140
|
+
return 0 if ok else 2
|
|
141
|
+
|
|
142
|
+
if a.cmd == "teardown":
|
|
143
|
+
from .target import parse_target
|
|
144
|
+
|
|
145
|
+
_p(parse_target(a.dst, a.bucket, out).teardown())
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
from .sources import open_source
|
|
149
|
+
|
|
150
|
+
src = open_source(a.src)
|
|
151
|
+
try:
|
|
152
|
+
if a.cmd == "scan":
|
|
153
|
+
from .scan import scan
|
|
154
|
+
|
|
155
|
+
res = scan(src, a.credit_price)
|
|
156
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
(out / "scan.json").write_text(json.dumps(res, indent=2, default=str, ensure_ascii=False))
|
|
158
|
+
tt = res["totals"]
|
|
159
|
+
_p(f"Source : {res['source']}")
|
|
160
|
+
_p(f"{tt['tables']} tables, {tt['rows']} lignes, {tt['gb']} Go")
|
|
161
|
+
e = res["exit_estimate"]
|
|
162
|
+
_p(f"Sortie estimée : {e['duration_hours'][0]} à {e['duration_hours'][1]} h de copie ({e['assumed_throughput_mb_s'][0]}-{e['assumed_throughput_mb_s'][1]} Mo/s, est.)")
|
|
163
|
+
if "cost_usd" in e:
|
|
164
|
+
_p(f"Coût de lecture estimé : {e['cost_usd'][0]} à {e['cost_usd'][1]} $ ({e['credits'][0]}-{e['credits'][1]} crédits)")
|
|
165
|
+
if res["egress_note"]:
|
|
166
|
+
_p(res["egress_note"])
|
|
167
|
+
if res["usage_90d"]:
|
|
168
|
+
_p(f"Consommation 90 jours : {res['usage_90d']}")
|
|
169
|
+
_p(f"Colonnes à type propre à la plateforme : {len(res['non_portable_columns'])}")
|
|
170
|
+
for k, n in res["object_counts"].items():
|
|
171
|
+
if n:
|
|
172
|
+
_p(f" {k} à reconstruire : {n}")
|
|
173
|
+
_p(f"Détail : {out / 'scan.json'}")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
if a.cmd == "drill":
|
|
177
|
+
from .drill import run_drill
|
|
178
|
+
from .report import write_report
|
|
179
|
+
from .target import parse_target
|
|
180
|
+
from .tolerances import Tolerances
|
|
181
|
+
|
|
182
|
+
target = parse_target(a.dst, a.bucket, out)
|
|
183
|
+
res = run_drill(src, target, out, Path(a.key), a.queries, Tolerances.load(a.tolerances), a.top, a.tables, a.credit_price, _p)
|
|
184
|
+
rep = write_report(res, out)
|
|
185
|
+
s = res["summary"]
|
|
186
|
+
_p(f"\nParité : {s['parity']} — {s['tables_ok']}/{s['tables_total']} tables, {s['queries_match']}/{s['queries_total']} requêtes")
|
|
187
|
+
_p(f"Dossier : {rep}")
|
|
188
|
+
return 0 if s["parity"] == "complète" else 1
|
|
189
|
+
finally:
|
|
190
|
+
src.close()
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
sys.exit(main())
|
canot/convert.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Arrow -> Iceberg-compatible schema, row fingerprints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import duckdb
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
|
|
9
|
+
from .model import q
|
|
10
|
+
|
|
11
|
+
BUCKETS = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Conversion:
|
|
16
|
+
table: str
|
|
17
|
+
column: str
|
|
18
|
+
source_type: str
|
|
19
|
+
target_type: str
|
|
20
|
+
lossy: bool
|
|
21
|
+
note: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _convert_type(t: pa.DataType) -> tuple[pa.DataType, str | None, bool]:
|
|
25
|
+
if pa.types.is_timestamp(t) and t.unit == "ns":
|
|
26
|
+
return pa.timestamp("us", tz=t.tz), "ns -> us (Iceberg v2 ne garde pas les nanosecondes)", True
|
|
27
|
+
if pa.types.is_large_string(t):
|
|
28
|
+
return pa.string(), None, False
|
|
29
|
+
if pa.types.is_null(t):
|
|
30
|
+
return pa.string(), "colonne entièrement nulle, typée texte", False
|
|
31
|
+
if pa.types.is_unsigned_integer(t):
|
|
32
|
+
wider = {8: pa.int16(), 16: pa.int32(), 32: pa.int64(), 64: pa.int64()}[t.bit_width]
|
|
33
|
+
return wider, "entier non signé -> signé", t.bit_width == 64
|
|
34
|
+
if pa.types.is_decimal(t) and t.precision > 38:
|
|
35
|
+
return pa.string(), "précision > 38 stockée en texte", True
|
|
36
|
+
if pa.types.is_struct(t) and {f.name.lower() for f in t} >= {"epoch", "timezone"}:
|
|
37
|
+
return pa.string(), "TIMESTAMP_TZ natif Snowflake : fuseau d'origine gardé en texte", True
|
|
38
|
+
if pa.types.is_list(t) or pa.types.is_large_list(t):
|
|
39
|
+
inner, _, lossy = _convert_type(t.value_type)
|
|
40
|
+
return pa.list_(inner), None, lossy
|
|
41
|
+
if pa.types.is_struct(t):
|
|
42
|
+
fields, lossy = [], False
|
|
43
|
+
for f in t:
|
|
44
|
+
ft, _, l = _convert_type(f.type)
|
|
45
|
+
fields.append(pa.field(f.name, ft))
|
|
46
|
+
lossy = lossy or l
|
|
47
|
+
return pa.struct(fields), None, lossy
|
|
48
|
+
if pa.types.is_dictionary(t):
|
|
49
|
+
return t.value_type, None, False
|
|
50
|
+
return t, None, False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def target_schema(table: str, schema: pa.Schema, source_types: dict[str, str]) -> tuple[pa.Schema, list[Conversion]]:
|
|
54
|
+
fields, convs = [], []
|
|
55
|
+
for f in schema:
|
|
56
|
+
nt, note, lossy = _convert_type(f.type)
|
|
57
|
+
if note or nt != f.type:
|
|
58
|
+
convs.append(Conversion(table, f.name, source_types.get(f.name, str(f.type)), str(nt), lossy, note or "type normalisé"))
|
|
59
|
+
fields.append(pa.field(f.name, nt, nullable=True))
|
|
60
|
+
return pa.schema(fields), convs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def conform(batch: pa.RecordBatch | pa.Table, schema: pa.Schema) -> pa.Table:
|
|
64
|
+
t = batch if isinstance(batch, pa.Table) else pa.Table.from_batches([batch])
|
|
65
|
+
cols = []
|
|
66
|
+
for f in schema:
|
|
67
|
+
col = t.column(f.name)
|
|
68
|
+
if col.type != f.type:
|
|
69
|
+
if pa.types.is_string(f.type) and pa.types.is_struct(col.type):
|
|
70
|
+
col = pa.array([None if v is None else str(v) for v in col.to_pylist()], type=pa.string())
|
|
71
|
+
else:
|
|
72
|
+
col = col.cast(f.type, safe=False)
|
|
73
|
+
cols.append(col)
|
|
74
|
+
return pa.Table.from_arrays(cols, schema=schema)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fingerprint(table: pa.Table) -> dict[int, list[int]]:
|
|
78
|
+
"""Per-bucket [row_count, sum_of_row_hashes]. Additive, so batches can be merged."""
|
|
79
|
+
if table.num_rows == 0 or table.num_columns == 0:
|
|
80
|
+
return {}
|
|
81
|
+
con = duckdb.connect()
|
|
82
|
+
con.register("t", table)
|
|
83
|
+
parts = ", ".join(f"({q(n)} is null), coalesce(cast({q(n)} as varchar), '')" for n in table.column_names)
|
|
84
|
+
rows = con.execute(
|
|
85
|
+
f"select h % {BUCKETS} b, count(*), sum(h % 1000000007) from (select hash({parts}) h from t) group by 1"
|
|
86
|
+
).fetchall()
|
|
87
|
+
con.close()
|
|
88
|
+
return {int(b): [int(c), int(s)] for b, c, s in rows}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def merge_fp(a: dict[int, list[int]], b: dict[int, list[int]]) -> dict[int, list[int]]:
|
|
92
|
+
out = {k: list(v) for k, v in a.items()}
|
|
93
|
+
for k, (c, s) in b.items():
|
|
94
|
+
cur = out.setdefault(k, [0, 0])
|
|
95
|
+
cur[0] += c
|
|
96
|
+
cur[1] += s
|
|
97
|
+
return out
|
canot/copy.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
|
|
8
|
+
from .convert import Conversion, conform, fingerprint, merge_fp, target_schema
|
|
9
|
+
from .model import TableInfo
|
|
10
|
+
from .sources.base import Source
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CopyResult:
|
|
15
|
+
table: TableInfo
|
|
16
|
+
rows: int = 0
|
|
17
|
+
bytes: int = 0
|
|
18
|
+
seconds: float = 0.0
|
|
19
|
+
fingerprint: dict[int, list[int]] = field(default_factory=dict)
|
|
20
|
+
conversions: list[Conversion] = field(default_factory=list)
|
|
21
|
+
schema: pa.Schema | None = None
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def copy_table(src: Source, t: TableInfo, catalog, chunk_rows: int = 500_000) -> CopyResult:
|
|
26
|
+
res = CopyResult(t)
|
|
27
|
+
t0 = time.monotonic()
|
|
28
|
+
ns = t.schema
|
|
29
|
+
try:
|
|
30
|
+
catalog.create_namespace_if_not_exists(ns)
|
|
31
|
+
types = {c.name: c.type for c in t.columns}
|
|
32
|
+
ice = None
|
|
33
|
+
buf: list[pa.RecordBatch] = []
|
|
34
|
+
buf_rows = 0
|
|
35
|
+
|
|
36
|
+
def flush():
|
|
37
|
+
nonlocal buf, buf_rows
|
|
38
|
+
if not buf:
|
|
39
|
+
return
|
|
40
|
+
tbl = conform(pa.Table.from_batches(buf), res.schema)
|
|
41
|
+
ice.append(tbl)
|
|
42
|
+
res.fingerprint = merge_fp(res.fingerprint, fingerprint(tbl))
|
|
43
|
+
res.rows += tbl.num_rows
|
|
44
|
+
res.bytes += tbl.nbytes
|
|
45
|
+
buf, buf_rows = [], 0
|
|
46
|
+
|
|
47
|
+
for batch in src.read(t):
|
|
48
|
+
if ice is None:
|
|
49
|
+
res.schema, res.conversions = target_schema(t.fqn, batch.schema, types)
|
|
50
|
+
ident = (ns, t.name)
|
|
51
|
+
if catalog.table_exists(ident):
|
|
52
|
+
catalog.drop_table(ident)
|
|
53
|
+
ice = catalog.create_table(ident, schema=res.schema)
|
|
54
|
+
buf.append(batch)
|
|
55
|
+
buf_rows += batch.num_rows
|
|
56
|
+
if buf_rows >= chunk_rows:
|
|
57
|
+
flush()
|
|
58
|
+
if ice is None: # empty table: schema from source column list is not available as arrow; create from a limit-0 read
|
|
59
|
+
empty = src.query(f"select * from {t.sql} limit 0") if src.dialect == "duckdb" else None
|
|
60
|
+
if empty is not None:
|
|
61
|
+
res.schema, res.conversions = target_schema(t.fqn, empty.schema, types)
|
|
62
|
+
catalog.create_table((ns, t.name), schema=res.schema)
|
|
63
|
+
else:
|
|
64
|
+
flush()
|
|
65
|
+
except Exception as e: # keep going: one broken table must not hide the others
|
|
66
|
+
res.error = f"{type(e).__name__}: {e}"
|
|
67
|
+
res.seconds = time.monotonic() - t0
|
|
68
|
+
return res
|
canot/demo.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Synthetic data that exercises the usual traps (decimals, timestamps, NULLs, JSON, unicode)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import duckdb
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_demo(path: str, n: int = 20_000) -> None:
|
|
8
|
+
con = duckdb.connect(path)
|
|
9
|
+
con.execute("create schema if not exists shop")
|
|
10
|
+
con.execute(f"""
|
|
11
|
+
create or replace table shop.customers as
|
|
12
|
+
select i as id, 'client_' || i as name,
|
|
13
|
+
case when i % 7 = 0 then null else 'ville_' || (i % 50) end as city,
|
|
14
|
+
case when i % 11 = 0 then 'Zoé ☃ ' || i else 'plain ' || i end as note
|
|
15
|
+
from range(1, {n // 10 + 1}) t(i)""")
|
|
16
|
+
con.execute(f"""
|
|
17
|
+
create or replace table shop.orders as
|
|
18
|
+
select i as id, 1 + (i % {n // 10}) as customer_id,
|
|
19
|
+
cast((i * 37 % 100000) / 100.0 as decimal(18,2)) as total,
|
|
20
|
+
(i * 0.137)::double as score,
|
|
21
|
+
timestamp '2026-01-01 00:00:00' + to_seconds(i * 997) + to_microseconds(i % 1000000) as created_at,
|
|
22
|
+
case when i % 13 = 0 then null else date '2026-01-01' + (i % 300)::int end as ship_date,
|
|
23
|
+
'{{"k":' || i || ',"tags":["a","b"]}}' as payload
|
|
24
|
+
from range(1, {n + 1}) t(i)""")
|
|
25
|
+
con.execute("""
|
|
26
|
+
create or replace table shop.empty_table (id integer, label varchar)""")
|
|
27
|
+
con.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEMO_QUERIES = """\
|
|
31
|
+
select city, count(*) as n, sum(o.total) as revenue
|
|
32
|
+
from shop.orders o join shop.customers c on c.id = o.customer_id
|
|
33
|
+
group by city order by revenue desc nulls last;
|
|
34
|
+
|
|
35
|
+
select date_trunc('month', created_at) as m, count(*) as n, avg(score) as avg_score
|
|
36
|
+
from shop.orders group by 1 order by 1;
|
|
37
|
+
|
|
38
|
+
select customer_id, max(total) as biggest
|
|
39
|
+
from shop.orders where ship_date is not null group by 1 order by biggest desc limit 20;
|
|
40
|
+
|
|
41
|
+
select * from shop.orders order by ship_date limit 50;
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_retail(path: str, tickets: int = 50_000) -> None:
|
|
46
|
+
"""Fictional grocery retailer ("Maison Marché"): invented data only. Includes nested lines
|
|
47
|
+
(ARRAY<STRUCT>, as BigQuery exports them), decimals, NULLs, accents and a JSON-as-text column."""
|
|
48
|
+
con = duckdb.connect(path)
|
|
49
|
+
con.execute("create schema if not exists distrib")
|
|
50
|
+
con.execute("""
|
|
51
|
+
create or replace table distrib.stores as
|
|
52
|
+
select i as store_id, 'Magasin ' || i as name,
|
|
53
|
+
(['hyper','super','proxi','drive'])[1 + i % 4] as format,
|
|
54
|
+
(['Île-de-France','Occitanie','Bretagne','Hauts-de-France','Auvergne-Rhône-Alpes'])[1 + i % 5] as region,
|
|
55
|
+
date '2008-01-01' + (i * 37 % 6000)::int as opened_on
|
|
56
|
+
from range(1, 301) t(i)""")
|
|
57
|
+
con.execute("""
|
|
58
|
+
create or replace table distrib.products as
|
|
59
|
+
select i as product_id, 'Produit ' || i as label,
|
|
60
|
+
(['frais','épicerie','boissons','hygiène','bazar'])[1 + i % 5] as category,
|
|
61
|
+
cast(0.5 + (i * 13 % 2500) / 100.0 as decimal(10,2)) as unit_price,
|
|
62
|
+
case when i % 9 = 0 then null else 'marque_' || (i % 40) end as brand
|
|
63
|
+
from range(1, 2001) t(i)""")
|
|
64
|
+
con.execute(f"""
|
|
65
|
+
create or replace table distrib.loyalty_customers as
|
|
66
|
+
select i as customer_id, case when i % 5 = 0 then null else 'ville_' || (i % 80) end as city,
|
|
67
|
+
timestamp '2022-01-01' + to_days((i % 1400)::int) as joined_at,
|
|
68
|
+
'{{"opt_in":' || (i % 2 = 0) || ',"segment":"s' || (i % 6) || '"}}' as prefs
|
|
69
|
+
from range(1, 8001) t(i)""")
|
|
70
|
+
con.execute(f"""
|
|
71
|
+
create or replace table distrib.tickets as
|
|
72
|
+
select i as ticket_id, 1 + (i * 7 % 300) as store_id,
|
|
73
|
+
case when i % 4 = 0 then null else 1 + (i * 11 % 8000) end as customer_id,
|
|
74
|
+
timestamp '2026-03-01 08:00:00' + to_seconds((i * 53) % 2500000) as sold_at,
|
|
75
|
+
cast(timestamp '2026-03-01 08:00:00' + to_seconds((i * 53) % 2500000) + to_microseconds(i % 997) as timestamp_ns) as scanned_at,
|
|
76
|
+
cast(((i * 97) % 20000) / 100.0 + 1 as decimal(12,2)) as total,
|
|
77
|
+
[struct_pack(product_id := 1 + (i * 3 % 2000), qty := 1 + i % 4),
|
|
78
|
+
struct_pack(product_id := 1 + (i * 5 % 2000), qty := 1 + i % 2)] as lines
|
|
79
|
+
from range(1, {tickets + 1}) t(i)""")
|
|
80
|
+
con.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
RETAIL_QUERIES = """\
|
|
84
|
+
select s.region, s.format, count(*) as tickets, sum(t.total) as revenue, avg(t.total) as basket
|
|
85
|
+
from distrib.tickets t join distrib.stores s using (store_id)
|
|
86
|
+
group by 1, 2 order by revenue desc;
|
|
87
|
+
|
|
88
|
+
select date_trunc('week', sold_at) as week, count(*) as tickets, sum(total) as revenue
|
|
89
|
+
from distrib.tickets group by 1 order by 1;
|
|
90
|
+
|
|
91
|
+
select c.city, count(*) as tickets, sum(t.total) as revenue
|
|
92
|
+
from distrib.tickets t join distrib.loyalty_customers c using (customer_id)
|
|
93
|
+
group by 1 order by revenue desc nulls last limit 30;
|
|
94
|
+
|
|
95
|
+
select p.category, sum(l.qty * p.unit_price) as revenue
|
|
96
|
+
from distrib.tickets t, unnest(t.lines) as u(l) join distrib.products p on p.product_id = l.product_id
|
|
97
|
+
group by 1 order by 2 desc;
|
|
98
|
+
"""
|
canot/drill.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import duckdb
|
|
8
|
+
import pyarrow
|
|
9
|
+
import pyiceberg
|
|
10
|
+
import sqlglot
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .copy import copy_table
|
|
14
|
+
from .evidence import EvidenceLog
|
|
15
|
+
from .scan import scan as run_scan
|
|
16
|
+
from .sources.base import Source
|
|
17
|
+
from .target import Target
|
|
18
|
+
from .tolerances import Tolerances
|
|
19
|
+
from .verify import check_table, replay_queries, split_sql
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_drill(src: Source, target: Target, out: Path, key_path: Path, queries_file: str | None,
|
|
23
|
+
tol: Tolerances, top_n: int = 20, only: list[str] | None = None,
|
|
24
|
+
credit_price: float | None = 3.90, log=print) -> dict:
|
|
25
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
ev = EvidenceLog(out)
|
|
27
|
+
t_start = time.monotonic()
|
|
28
|
+
env = {"canot": __version__, "duckdb": duckdb.__version__, "pyarrow": pyarrow.__version__,
|
|
29
|
+
"pyiceberg": pyiceberg.__version__, "sqlglot": sqlglot.__version__}
|
|
30
|
+
ev.add("start", {"source": src.describe(), "target": target.label, "env": env, "tolerances": tol.to_dict()})
|
|
31
|
+
|
|
32
|
+
log("→ inventaire")
|
|
33
|
+
scan_res = run_scan(src, credit_price)
|
|
34
|
+
ev.add("scan", {"totals": scan_res["totals"], "objects": scan_res["object_counts"]})
|
|
35
|
+
tables = [t for t in src.tables() if not only or t.fqn.lower() in {o.lower() for o in only}]
|
|
36
|
+
|
|
37
|
+
cat = target.catalog()
|
|
38
|
+
log(f"→ copie de {len(tables)} table(s) vers {target.label}")
|
|
39
|
+
copies = {}
|
|
40
|
+
conversions = []
|
|
41
|
+
for t in tables:
|
|
42
|
+
r = copy_table(src, t, cat)
|
|
43
|
+
copies[t.fqn] = r
|
|
44
|
+
conversions += r.conversions
|
|
45
|
+
mb = r.bytes / 1e6
|
|
46
|
+
log(f" {t.fqn}: {r.rows} lignes, {mb:.1f} Mo, {r.seconds:.1f}s" + (f" ERREUR {r.error}" if r.error else ""))
|
|
47
|
+
ev.add("copy", {"table": t.fqn, "rows": r.rows, "bytes": r.bytes, "seconds": round(r.seconds, 3), "error": r.error,
|
|
48
|
+
"fingerprint": r.fingerprint})
|
|
49
|
+
copy_seconds = sum(r.seconds for r in copies.values())
|
|
50
|
+
copy_bytes = sum(r.bytes for r in copies.values())
|
|
51
|
+
|
|
52
|
+
log("→ comparaison des tables")
|
|
53
|
+
checks = []
|
|
54
|
+
for t in tables:
|
|
55
|
+
r = copies[t.fqn]
|
|
56
|
+
if r.error:
|
|
57
|
+
continue
|
|
58
|
+
c = check_table(src, t, cat.load_table((t.schema, t.name)), r.fingerprint, tol)
|
|
59
|
+
checks.append(c)
|
|
60
|
+
log(f" {t.fqn}: {'OK' if c.ok else 'ÉCART'}")
|
|
61
|
+
ev.add("table_check", asdict(c) | {"ok": c.ok})
|
|
62
|
+
|
|
63
|
+
queries = split_sql(Path(queries_file).read_text()) if queries_file else src.history_queries(top_n)
|
|
64
|
+
qres = []
|
|
65
|
+
if queries:
|
|
66
|
+
log(f"→ rejeu de {len(queries)} requête(s) des deux côtés")
|
|
67
|
+
qres = replay_queries(src, queries[:top_n], cat, [t for t in tables if not copies[t.fqn].error], tol)
|
|
68
|
+
for q in qres:
|
|
69
|
+
log(f" [{q.status}] {q.sql[:70].replace(chr(10), ' ')}")
|
|
70
|
+
ev.add("query", asdict(q))
|
|
71
|
+
else:
|
|
72
|
+
log("→ aucune requête fournie (--queries) ni trouvée dans l'historique : rejeu sauté")
|
|
73
|
+
|
|
74
|
+
mbps = (copy_bytes / 1e6) / copy_seconds if copy_seconds > 0 else None
|
|
75
|
+
total_bytes = scan_res["totals"]["bytes"] or copy_bytes
|
|
76
|
+
rto = None
|
|
77
|
+
if mbps:
|
|
78
|
+
center = total_bytes / 1e6 / mbps
|
|
79
|
+
rto = {"seconds": [round(center * 0.7, 1), round(center * 1.5, 1)], "basis": "débit mesuré ±, hors validation humaine et reprise des applications"}
|
|
80
|
+
credits = round(copy_seconds / 3600 * src.credits_per_hour, 4) if src.dialect == "snowflake" else None
|
|
81
|
+
summary = {
|
|
82
|
+
"tables_total": len(tables),
|
|
83
|
+
"tables_ok": sum(1 for c in checks if c.ok),
|
|
84
|
+
"tables_error": [t for t, r in copies.items() if r.error],
|
|
85
|
+
"queries_total": len(qres),
|
|
86
|
+
"queries_match": sum(1 for q in qres if q.status == "match"),
|
|
87
|
+
"queries_mismatch": sum(1 for q in qres if q.status == "mismatch"),
|
|
88
|
+
"queries_untranslatable": sum(1 for q in qres if q.status in ("untranslatable", "error")),
|
|
89
|
+
"queries_order_differs": sum(1 for q in qres if q.order_differs),
|
|
90
|
+
"lossy_conversions": sum(1 for c in conversions if c.lossy),
|
|
91
|
+
"throughput_mb_s": round(mbps, 2) if mbps else None,
|
|
92
|
+
"rto": rto,
|
|
93
|
+
"warehouse_credits_used_est": credits,
|
|
94
|
+
"cost_usd_est": round(credits * credit_price, 2) if credits is not None and credit_price else None,
|
|
95
|
+
"duration_s": round(time.monotonic() - t_start, 1),
|
|
96
|
+
}
|
|
97
|
+
summary["parity"] = ("complète" if summary["tables_ok"] == summary["tables_total"] and not summary["tables_error"]
|
|
98
|
+
and not summary["queries_mismatch"] else "écarts")
|
|
99
|
+
ev.add("summary", summary)
|
|
100
|
+
seal = ev.seal(key_path)
|
|
101
|
+
return {"summary": summary, "scan": scan_res, "checks": [asdict(c) | {"ok": c.ok} for c in checks],
|
|
102
|
+
"queries": [asdict(q) for q in qres], "conversions": [asdict(c) for c in conversions],
|
|
103
|
+
"env": env, "seal": seal, "source": src.describe(), "target": target.label, "tolerances": tol.to_dict()}
|
canot/evidence.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Tamper-evident log: every entry holds the hash of the previous one; the last hash is signed
|
|
2
|
+
with an Ed25519 key created in the customer's environment."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidSignature
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _h(b: bytes) -> str:
|
|
17
|
+
return hashlib.sha256(b).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EvidenceLog:
|
|
21
|
+
def __init__(self, out: Path):
|
|
22
|
+
self.out = out
|
|
23
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
self.path = out / "evidence.jsonl"
|
|
25
|
+
self.entries: list[dict] = []
|
|
26
|
+
self.prev = "0" * 64
|
|
27
|
+
|
|
28
|
+
def add(self, event: str, data: dict) -> None:
|
|
29
|
+
data = json.loads(json.dumps(data, sort_keys=True, default=str)) # normalise (clés int -> str) avant de hacher
|
|
30
|
+
e = {"seq": len(self.entries), "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
31
|
+
"event": event, "data": data, "prev": self.prev}
|
|
32
|
+
e["hash"] = _h(json.dumps({k: e[k] for k in ("seq", "ts", "event", "data", "prev")}, sort_keys=True, default=str).encode())
|
|
33
|
+
self.prev = e["hash"]
|
|
34
|
+
self.entries.append(e)
|
|
35
|
+
|
|
36
|
+
def seal(self, key_path: Path) -> dict:
|
|
37
|
+
key = load_or_create_key(key_path)
|
|
38
|
+
sig = key.sign(self.prev.encode())
|
|
39
|
+
pub = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
40
|
+
self.path.write_text("\n".join(json.dumps(e, sort_keys=True, default=str) for e in self.entries) + "\n")
|
|
41
|
+
(self.out / "evidence.sig").write_text(sig.hex())
|
|
42
|
+
(self.out / "evidence.pub").write_bytes(pub)
|
|
43
|
+
return {"last_hash": self.prev, "signature": sig.hex(), "entries": len(self.entries)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_or_create_key(path: Path) -> Ed25519PrivateKey:
|
|
47
|
+
if path.exists():
|
|
48
|
+
return serialization.load_pem_private_key(path.read_bytes(), password=None) # type: ignore[return-value]
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
key = Ed25519PrivateKey.generate()
|
|
51
|
+
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
|
52
|
+
os.chmod(path, 0o600)
|
|
53
|
+
return key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def verify_dir(d: Path) -> tuple[bool, str]:
|
|
57
|
+
try:
|
|
58
|
+
lines = [json.loads(l) for l in (d / "evidence.jsonl").read_text().splitlines() if l.strip()]
|
|
59
|
+
prev = "0" * 64
|
|
60
|
+
for e in lines:
|
|
61
|
+
body = {k: e[k] for k in ("seq", "ts", "event", "data", "prev")}
|
|
62
|
+
if e["prev"] != prev or _h(json.dumps(body, sort_keys=True, default=str).encode()) != e["hash"]:
|
|
63
|
+
return False, f"chaîne rompue à l'entrée {e['seq']}"
|
|
64
|
+
prev = e["hash"]
|
|
65
|
+
pub = serialization.load_pem_public_key((d / "evidence.pub").read_bytes())
|
|
66
|
+
assert isinstance(pub, Ed25519PublicKey)
|
|
67
|
+
pub.verify(bytes.fromhex((d / "evidence.sig").read_text().strip()), prev.encode())
|
|
68
|
+
return True, f"{len(lines)} entrées, chaîne intacte, signature valide"
|
|
69
|
+
except InvalidSignature:
|
|
70
|
+
return False, "signature invalide"
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return False, f"{type(e).__name__}: {e}"
|
canot/model.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def q(ident: str, dialect: str = "duckdb") -> str:
|
|
9
|
+
"""Quote an SQL identifier for the given dialect (backticks for BigQuery/Databricks, double quotes otherwise)."""
|
|
10
|
+
return exp.to_identifier(ident, quoted=True).sql(dialect=dialect)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ColumnInfo:
|
|
15
|
+
name: str
|
|
16
|
+
type: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TableInfo:
|
|
21
|
+
schema: str
|
|
22
|
+
name: str
|
|
23
|
+
rows: int | None = None
|
|
24
|
+
bytes: int | None = None
|
|
25
|
+
columns: list[ColumnInfo] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def fqn(self) -> str:
|
|
29
|
+
return f"{self.schema}.{self.name}"
|
|
30
|
+
|
|
31
|
+
def sql_for(self, dialect: str = "duckdb") -> str:
|
|
32
|
+
return f"{q(self.schema, dialect)}.{q(self.name, dialect)}"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def sql(self) -> str:
|
|
36
|
+
return self.sql_for("duckdb")
|