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 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")