sql2iceberg 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.
@@ -0,0 +1,186 @@
1
+ """Validate a migration: compare row counts, per-column non-null counts and numeric sums across platforms.
2
+
3
+ Each platform is a ``Target``: a name, a function that runs SQL and returns rows, and a map from the logical
4
+ table name to that platform's qualified name. One of the targets is the reference (usually the source system).
5
+ Nothing here connects anywhere by itself; ``snowflake_runner``, ``athena_runner`` and ``bigquery_runner`` are
6
+ thin adapters over clients you already have.
7
+
8
+ What it proves: same row count, same count of non-null values per column, same sum of each numeric column
9
+ (within a tolerance). What it does not prove: row-by-row equality. For that use ``sql2iceberg verify`` on a case
10
+ folder, or compare a sample you export yourself.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+
19
+ from .tracing import span
20
+
21
+ #: how each platform spells a floating-point cast
22
+ CASTS = {"snowflake": "FLOAT", "spark": "DOUBLE", "athena": "DOUBLE", "bigquery": "FLOAT64", "sqlite": "REAL"}
23
+
24
+
25
+ @dataclass
26
+ class Target:
27
+ name: str
28
+ run: Callable[[str], list[tuple]]
29
+ tables: dict[str, str] # logical name -> qualified name on this platform
30
+ cast: str = "DOUBLE" # floating-point type name, see CASTS
31
+
32
+
33
+ @dataclass
34
+ class ColumnSpec:
35
+ name: str
36
+ numeric: bool = False
37
+
38
+
39
+ @dataclass
40
+ class CheckResult:
41
+ table: str
42
+ check: str # row_count | non_null:<col> | sum:<col>
43
+ values: dict[str, float | int | None] # platform -> value
44
+ ok: bool
45
+ note: str = ""
46
+
47
+
48
+ @dataclass
49
+ class ValidationReport:
50
+ reference: str
51
+ results: list[CheckResult] = field(default_factory=list)
52
+ errors: list[str] = field(default_factory=list)
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ return not self.errors and all(r.ok for r in self.results)
57
+
58
+ def to_dict(self) -> dict:
59
+ return {"reference": self.reference, "ok": self.ok, "errors": self.errors,
60
+ "results": [r.__dict__ for r in self.results]}
61
+
62
+ def to_markdown(self) -> str:
63
+ names = list(self.results[0].values) if self.results else []
64
+ head = ["table", "check", *names, "result"]
65
+ out = [f"# Validation (reference: {self.reference}): {'PASS' if self.ok else 'FAIL'}\n",
66
+ "| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
67
+ for r in self.results:
68
+ vals = ["" if r.values.get(n) is None else str(r.values[n]) for n in names]
69
+ out.append("| " + " | ".join([r.table, r.check, *vals, "ok" if r.ok else f"MISMATCH {r.note}"]) + " |")
70
+ out += [f"\nERROR: {e}" for e in self.errors]
71
+ return "\n".join(out) + "\n"
72
+
73
+
74
+ def _close(a, b, rel: float, abs_: float) -> bool:
75
+ if a is None or b is None:
76
+ return a is None and b is None
77
+ return math.isclose(float(a), float(b), rel_tol=rel, abs_tol=abs_)
78
+
79
+
80
+ def validate(columns: dict[str, list[ColumnSpec]], reference: Target, others: list[Target], *,
81
+ rel_tol: float = 1e-9, abs_tol: float = 1e-6) -> ValidationReport:
82
+ """Compare every table in ``columns`` (logical name -> columns to check) across the targets."""
83
+ report = ValidationReport(reference.name)
84
+ targets = [reference, *others]
85
+ for table, cols in columns.items():
86
+ checks = [("row_count", "COUNT(*)", False)]
87
+ for c in cols:
88
+ checks.append((f"non_null:{c.name}", f"COUNT({c.name})", False))
89
+ if c.numeric:
90
+ checks.append((f"sum:{c.name}", f"SUM(CAST({c.name} AS {{cast}}))", True))
91
+ values: dict[str, dict[str, float | None]] = {t.name: {} for t in targets}
92
+ for t in targets:
93
+ qualified = t.tables.get(table)
94
+ if not qualified:
95
+ report.errors.append(f"{t.name}: no table mapped for {table}")
96
+ continue
97
+ # one query per table and platform: every check is a column of the same SELECT
98
+ # zero-padded aliases: clients that return columns sorted by name still give the right order
99
+ exprs = ", ".join(f"{expr.format(cast=t.cast)} AS c{i:03d}" for i, (_, expr, _) in enumerate(checks))
100
+ with span("validate.query", platform=t.name, table=table, checks=len(checks)):
101
+ try:
102
+ rows = t.run(f"SELECT {exprs} FROM {qualified}")
103
+ row = rows[0] if rows else ()
104
+ if len(row) != len(checks):
105
+ raise ValueError(f"expected {len(checks)} columns, got {len(row)}")
106
+ for (label, _, _), v in zip(checks, row, strict=True):
107
+ values[t.name][label] = float(v) if v is not None else None
108
+ except Exception as exc: # noqa: BLE001 report per target, keep checking the others
109
+ report.errors.append(f"{t.name} {table}: {type(exc).__name__}: {str(exc)[:200]}")
110
+ for label, _, is_sum in checks:
111
+ vals = {t.name: values[t.name].get(label) for t in targets}
112
+ ref = vals[reference.name]
113
+ bad = [t.name for t in others if t.name in vals and not _close(
114
+ ref, vals[t.name], rel_tol if is_sum else 0, abs_tol if is_sum else 0)]
115
+ report.results.append(CheckResult(table, label, vals, not bad, f"differs on {', '.join(bad)}" if bad else ""))
116
+ return report
117
+
118
+
119
+ # ---- adapters ---------------------------------------------------------------------------------------------
120
+
121
+ def snowflake_runner(connector) -> Callable[[str], list[tuple]]:
122
+ return connector.query
123
+
124
+
125
+ def athena_runner(session, workgroup: str, output: str, database: str | None = None) -> Callable[[str], list[tuple]]:
126
+ import time
127
+
128
+ athena = session.client("athena")
129
+
130
+ def run(sql: str) -> list[tuple]:
131
+ kw = {"QueryExecutionContext": {"Database": database}} if database else {}
132
+ qid = athena.start_query_execution(QueryString=sql, WorkGroup=workgroup,
133
+ ResultConfiguration={"OutputLocation": output}, **kw)["QueryExecutionId"]
134
+ while True:
135
+ st = athena.get_query_execution(QueryExecutionId=qid)["QueryExecution"]["Status"]
136
+ if st["State"] in ("SUCCEEDED", "FAILED", "CANCELLED"):
137
+ break
138
+ time.sleep(1)
139
+ if st["State"] != "SUCCEEDED":
140
+ raise RuntimeError(f"Athena {st['State']}: {st.get('StateChangeReason', '')}")
141
+ rows = athena.get_query_results(QueryExecutionId=qid)["ResultSet"]["Rows"][1:]
142
+ return [tuple(c.get("VarCharValue") for c in r["Data"]) for r in rows]
143
+
144
+ return run
145
+
146
+
147
+ def bigquery_runner(client) -> Callable[[str], list[tuple]]:
148
+ return lambda sql: [tuple(r) for r in client.query(sql).result()]
149
+
150
+
151
+ # ---- config-file entry point ------------------------------------------------------------------------------
152
+
153
+ def _target_from_config(cfg: dict) -> Target:
154
+ kind = cfg["kind"]
155
+ if kind == "snowflake":
156
+ from .connectors import get_connector
157
+
158
+ conn = get_connector("snowflake")
159
+ return Target(cfg.get("name", "snowflake"), snowflake_runner(conn), cfg["tables"], CASTS["snowflake"])
160
+ if kind == "athena":
161
+ import boto3
162
+
163
+ session = boto3.Session(profile_name=cfg.get("profile"), region_name=cfg.get("region"))
164
+ run = athena_runner(session, cfg.get("workgroup", "primary"), cfg["output"], cfg.get("database"))
165
+ return Target(cfg.get("name", "athena"), run, cfg["tables"], CASTS["athena"])
166
+ if kind == "bigquery":
167
+ from google.cloud import bigquery
168
+
169
+ client = bigquery.Client(project=cfg["project"], location=cfg.get("location", "US"))
170
+ return Target(cfg.get("name", "bigquery"), bigquery_runner(client), cfg["tables"], CASTS["bigquery"])
171
+ raise ValueError(f"unknown target kind {kind!r}: use snowflake, athena or bigquery")
172
+
173
+
174
+ def report_from_config(cfg: dict) -> ValidationReport:
175
+ """Build targets from a plain dict (the CLI's JSON file) and run :func:`validate`.
176
+
177
+ {"tables": {"orders": ["order_id", {"name": "amount", "numeric": true}]},
178
+ "reference": {"kind": "snowflake", "name": "source", "tables": {"orders": "DB.SCH.ORDERS"}},
179
+ "targets": [{"kind": "athena", ...}, {"kind": "bigquery", ...}]}
180
+ """
181
+ columns = {t: [ColumnSpec(c) if isinstance(c, str) else ColumnSpec(c["name"], c.get("numeric", False))
182
+ for c in cols] for t, cols in cfg["tables"].items()}
183
+ with span("validate", tables=len(columns)):
184
+ return validate(columns, _target_from_config(cfg["reference"]),
185
+ [_target_from_config(t) for t in cfg["targets"]],
186
+ rel_tol=cfg.get("rel_tol", 1e-9), abs_tol=cfg.get("abs_tol", 1e-6))
sql2iceberg/verify.py ADDED
@@ -0,0 +1,138 @@
1
+ """Offline verification of one converted statement.
2
+
3
+ A case folder holds case.json, seed CSV files and an expected-results CSV. The expected CSV is what
4
+ the original Snowflake query returned on the same seed data (export it from your own Snowflake
5
+ account). The converted Spark SQL is run on the seed data and compared with it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from .convert import UNPARSED, UNSUPPORTED, convert_statement
17
+
18
+ PASS, FAIL, BLOCKED = "pass", "fail", "blocked"
19
+
20
+
21
+ @dataclass
22
+ class Verification:
23
+ case: str
24
+ outcome: str # pass | fail | blocked
25
+ conversion_status: str
26
+ spark_sql: str | None = None
27
+ differences: list[str] = field(default_factory=list)
28
+ findings: list[dict] = field(default_factory=list)
29
+
30
+ def to_dict(self) -> dict:
31
+ return self.__dict__.copy()
32
+
33
+
34
+ def make_spark(iceberg: bool = False, warehouse: str | None = None):
35
+ from pyspark.sql import SparkSession
36
+
37
+ # A stray SPARK_HOME points PySpark at a different Spark than the pip package (py4j "does not exist in the JVM").
38
+ os.environ.pop("SPARK_HOME", None)
39
+ builder = (
40
+ SparkSession.builder.master("local[2]")
41
+ .appName("sql2iceberg-verify")
42
+ .config("spark.ui.enabled", "false")
43
+ .config("spark.sql.shuffle.partitions", "2")
44
+ )
45
+ if iceberg:
46
+ pkg = os.environ.get(
47
+ "ICEBERG_PKG", "org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.9.2"
48
+ )
49
+ # ICEBERG_JAR: a local runtime jar, for machines where Ivy cannot reach Maven Central (e.g. TLS proxy).
50
+ jar = os.environ.get("ICEBERG_JAR")
51
+ builder = (
52
+ builder.config("spark.jars", jar) if jar else builder.config("spark.jars.packages", pkg)
53
+ )
54
+ builder = (
55
+ builder.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
56
+ .config("spark.sql.catalog.lake", "org.apache.iceberg.spark.SparkCatalog")
57
+ .config("spark.sql.catalog.lake.type", "hadoop")
58
+ .config("spark.sql.catalog.lake.warehouse", warehouse or "/tmp/sql2iceberg-warehouse")
59
+ .config("spark.sql.defaultCatalog", "lake")
60
+ )
61
+ spark = builder.getOrCreate()
62
+ spark.sparkContext.setLogLevel("ERROR")
63
+ if iceberg:
64
+ spark.sql("CREATE NAMESPACE IF NOT EXISTS lake.db")
65
+ spark.sql("USE lake.db")
66
+ return spark
67
+
68
+
69
+ def _same(a, b, tol: float) -> bool:
70
+ if a is None or b is None:
71
+ return a is None and b is None
72
+ if isinstance(a, float) or isinstance(b, float):
73
+ try:
74
+ return math.isclose(float(a), float(b), rel_tol=tol, abs_tol=tol)
75
+ except (TypeError, ValueError):
76
+ return False
77
+ return type(a) is type(b) and a == b
78
+
79
+
80
+ def _rows(df) -> list[tuple]:
81
+ return [tuple(r) for r in df.collect()]
82
+
83
+
84
+ def _sort_key(row: tuple):
85
+ return tuple((v is None, str(type(v)), str(v)) for v in row)
86
+
87
+
88
+ def verify_case(case_dir: str | Path, spark=None, iceberg: bool = False) -> Verification:
89
+ case_dir = Path(case_dir)
90
+ spec = json.loads((case_dir / "case.json").read_text(encoding="utf-8"))
91
+ name = spec.get("name", case_dir.name)
92
+ result = convert_statement(spec["sql"])
93
+ ver = Verification(
94
+ case=name,
95
+ outcome=BLOCKED,
96
+ conversion_status=result.status,
97
+ spark_sql=result.spark_sql,
98
+ findings=[f.__dict__ for f in result.findings],
99
+ )
100
+ if result.status in (UNSUPPORTED, UNPARSED):
101
+ ver.differences.append("not executed: conversion is " + result.status)
102
+ return ver
103
+
104
+ own_session = spark is None
105
+ spark = spark or make_spark(iceberg)
106
+ try:
107
+ for table, t in spec["tables"].items():
108
+ df = spark.read.csv(str(case_dir / t["csv"]), header=True, schema=t["schema"])
109
+ if iceberg:
110
+ spark.sql(f"DROP TABLE IF EXISTS {table}")
111
+ df.writeTo(table).using("iceberg").create()
112
+ else:
113
+ df.createOrReplaceTempView(table) # no files left behind between runs
114
+ try:
115
+ got = _rows(spark.sql(result.spark_sql))
116
+ except Exception as exc: # noqa: BLE001 - report any Spark failure as a failed verification
117
+ ver.outcome = FAIL
118
+ ver.differences.append(f"Spark failed to run the converted SQL: {str(exc).splitlines()[0][:200]}")
119
+ return ver
120
+ expected = _rows(
121
+ spark.read.csv(str(case_dir / spec["expected"]), header=True, schema=spec["expected_schema"])
122
+ )
123
+ finally:
124
+ if own_session:
125
+ spark.stop()
126
+
127
+ if not spec.get("ordered", False):
128
+ got, expected = sorted(got, key=_sort_key), sorted(expected, key=_sort_key)
129
+ tol = float(spec.get("float_tolerance", 1e-9))
130
+ if len(got) != len(expected):
131
+ ver.differences.append(f"row count: Spark {len(got)} vs expected {len(expected)}")
132
+ for i, (g, e) in enumerate(zip(got, expected)):
133
+ if len(g) != len(e) or not all(_same(x, y, tol) for x, y in zip(g, e)):
134
+ ver.differences.append(f"row {i}: Spark {g!r} vs expected {e!r}")
135
+ if len(ver.differences) >= 6:
136
+ break
137
+ ver.outcome = PASS if not ver.differences else FAIL
138
+ return ver
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.5
2
+ Name: sql2iceberg
3
+ Version: 0.1.0
4
+ Summary: Convert SQL from Snowflake and other databases to PySpark, persist it as Apache Iceberg tables, expose them to Snowflake, AWS and GCP, and validate the result.
5
+ Project-URL: Homepage, https://github.com/jey1987-cmd/sql2iceberg
6
+ Project-URL: Documentation, https://github.com/jey1987-cmd/sql2iceberg/tree/main/docs
7
+ Project-URL: Issues, https://github.com/jey1987-cmd/sql2iceberg/issues
8
+ Project-URL: Changelog, https://github.com/jey1987-cmd/sql2iceberg/blob/main/CHANGELOG.md
9
+ Author: Jeyaraman Ramalingam
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: bigquery,data-engineering,glue,iceberg,migration,pyspark,snowflake,sql,sqlglot
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Database
24
+ Classifier: Topic :: Software Development :: Code Generators
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: sqlglot>=30
27
+ Provides-Extra: all
28
+ Requires-Dist: boto3>=1.34; extra == 'all'
29
+ Requires-Dist: google-cloud-bigquery>=3.20; extra == 'all'
30
+ Requires-Dist: pyspark>=3.5; extra == 'all'
31
+ Requires-Dist: snowflake-connector-python>=3.12; extra == 'all'
32
+ Provides-Extra: aws
33
+ Requires-Dist: boto3>=1.34; extra == 'aws'
34
+ Provides-Extra: dev
35
+ Requires-Dist: build; extra == 'dev'
36
+ Requires-Dist: pytest>=8; extra == 'dev'
37
+ Requires-Dist: ruff; extra == 'dev'
38
+ Requires-Dist: twine; extra == 'dev'
39
+ Provides-Extra: gcp
40
+ Requires-Dist: google-cloud-bigquery>=3.20; extra == 'gcp'
41
+ Provides-Extra: mssql
42
+ Requires-Dist: pyodbc>=5; extra == 'mssql'
43
+ Provides-Extra: mysql
44
+ Requires-Dist: pymysql>=1.1; extra == 'mysql'
45
+ Provides-Extra: oracle
46
+ Requires-Dist: oracledb>=2; extra == 'oracle'
47
+ Provides-Extra: postgres
48
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
49
+ Provides-Extra: pytest
50
+ Requires-Dist: chispa>=0.10; extra == 'pytest'
51
+ Requires-Dist: pyspark>=3.5; extra == 'pytest'
52
+ Requires-Dist: pytest>=8; extra == 'pytest'
53
+ Provides-Extra: snowflake
54
+ Requires-Dist: snowflake-connector-python>=3.12; extra == 'snowflake'
55
+ Provides-Extra: spark
56
+ Requires-Dist: pyspark>=3.5; extra == 'spark'
57
+ Description-Content-Type: text/markdown
58
+
59
+ # sql2iceberg
60
+
61
+ Take the SQL in your code base, convert it to **PySpark**, persist the result as **Apache Iceberg** tables, expose those
62
+ tables to **Snowflake, AWS (Glue/Athena) and GCP (BigQuery)**, and **validate** the migration.
63
+
64
+ **Status: alpha (0.1.0).** One source platform (Snowflake) and three target platforms were exercised against real
65
+ accounts; everything else is written but untested and labelled so. See [what was tested](docs/environments.md#what-was-tested)
66
+ and [what is not supported yet](#limitations).
67
+
68
+ ```
69
+ your code ──detect──▶ SQL ──convert──▶ Spark SQL ──generate──▶ PySpark job ──run──▶ Iceberg on S3 / GCS
70
+ │
71
+ validate ◀──── counts, nulls, sums ────────────────────────┤
72
+ ├─▶ Snowflake (external Iceberg table)
73
+ ├─▶ AWS Glue / Athena
74
+ └─▶ BigQuery (BigLake)
75
+ ```
76
+
77
+ ## Core capabilities
78
+
79
+ | # | Capability | Command / API | Status |
80
+ |---|---|---|---|
81
+ | 1 | **Detect** the SQL platform and find SQL in `.sql`, `.py`, `.ipynb` | `detect`, `detect_platform()` | Snowflake tested; other dialects parse via SQLGlot |
82
+ | 2 | **Connect** to the source (you set up authentication first) | `connectors.get_connector()` | Snowflake tested live; Postgres, MySQL, SQL Server, Oracle **experimental** (mock-tested only) |
83
+ | 3 | **Convert** to Spark SQL and flag what cannot be trusted | `convert`, `convert_statement()` | tested |
84
+ | 4 | **Generate a PySpark job**: JDBC read, write Iceberg, run the curation SQL | `pyspark`, `pyspark_pipeline()` | tested end to end (Snowflake to Iceberg on S3 and on GCS) |
85
+ | 5 | **Expose** Iceberg tables to other platforms | `publish`, `publish.*Publisher` | Snowflake, AWS Glue/Athena, BigQuery all tested live |
86
+ | 6 | **Validate** counts, non-null counts and column sums across platforms | `validate`, `validate.validate()` | tested live across four platforms |
87
+ | 7 | **Report**: converted PySpark, plan of changes, validation results (Markdown and JSON) | all commands | tested |
88
+ | 8 | **Trace** every step with timings, secrets redacted | `--trace trace.jsonl`, `tracing.span()` | tested |
89
+ | 9 | **Diagnose** your environment; open a pre-filled GitHub issue | `doctor`, `issue` | tested |
90
+
91
+ Safety defaults: `publish` prints a plan and changes nothing unless you pass `--apply`; credentials are only read from
92
+ environment variables or your cloud profile; generated scripts never contain secrets.
93
+
94
+ ## Install
95
+
96
+ ```bash
97
+ pip install sql2iceberg # core: detect + convert (needs only sqlglot)
98
+ pip install "sql2iceberg[spark]" # run the generated PySpark jobs locally
99
+ pip install "sql2iceberg[snowflake,aws,gcp]" # connectors and publishers you need
100
+ pip install "sql2iceberg[all]" # spark + snowflake + aws + gcp
101
+ pip install "sql2iceberg[pytest]" # pytest plugin and fixtures
102
+ ```
103
+
104
+ Extras: `spark`, `snowflake`, `postgres`, `mysql`, `mssql`, `oracle`, `aws`, `gcp`, `pytest`, `all`, `dev`.
105
+
106
+ ## Environment dependencies (summary)
107
+
108
+ | Needed for | Requirement |
109
+ |---|---|
110
+ | everything | Python 3.10 or newer |
111
+ | running generated Spark jobs | **Java 17** (Java 21 should work), `pyspark` 3.5 or 4.x. Do not set `SPARK_HOME` to another Spark. |
112
+ | Iceberg tables | Iceberg Spark runtime jar matching your Spark (we used `iceberg-spark-runtime-4.1_2.13-1.12.0`) |
113
+ | Iceberg on S3 / GCS | `iceberg-aws-bundle` / `iceberg-gcp-bundle` jars |
114
+ | reading the source over JDBC | the JDBC driver jar (we used `snowflake-jdbc-3.19.0`) |
115
+ | Snowflake | account, role that can create external volumes, catalog integrations and Iceberg tables; a warehouse |
116
+ | AWS | an AWS profile or environment credentials with S3, Glue and (to count) Athena permissions |
117
+ | GCP | `gcloud auth login`, a project **with billing enabled**, a BigQuery connection to the bucket |
118
+ | behind a TLS-inspecting proxy | a CA bundle for Python and a Java truststore: see [troubleshooting](docs/troubleshooting.md) |
119
+
120
+ Full details, versions we tested and the list of every package and jar: [docs/environments.md](docs/environments.md) and
121
+ [docs/packages.md](docs/packages.md). Run `sql2iceberg doctor` to check your machine.
122
+
123
+ ## Quick start
124
+
125
+ ```bash
126
+ # 1. which platform is this code base for, and what SQL is in it?
127
+ sql2iceberg detect path/to/code
128
+ # 2. convert, and see what needs hand work
129
+ sql2iceberg convert path/to/code --out out
130
+ # 3. generate the PySpark job (reads the source over JDBC, writes Iceberg, runs the curation SQL)
131
+ sql2iceberg pyspark path/to/code --out out --platform snowflake \
132
+ --catalog-type jdbc --catalog-uri jdbc:sqlite:catalog.db \
133
+ --warehouse s3://my-bucket/warehouse --namespace live --iceberg-version 2
134
+ # 4. run it (set SNOWFLAKE_USER and SNOWFLAKE_PRIVATE_KEY_PATH or SNOWFLAKE_PASSWORD first)
135
+ spark-submit out/pipeline_pyspark.py
136
+ # 5. expose the tables: read the plan first, then --apply
137
+ sql2iceberg publish publish.json
138
+ sql2iceberg publish publish.json --apply
139
+ # 6. validate across platforms
140
+ sql2iceberg validate validate.json --out out
141
+ ```
142
+
143
+ Add `--trace trace.jsonl` to any command to record a timed trace of the run.
144
+ Worked examples from real runs (configs, plans, results): [docs/examples.md](docs/examples.md).
145
+
146
+ ## Python API
147
+
148
+ ```python
149
+ from sql2iceberg import detect_path, convert_statement
150
+ from sql2iceberg.pyspark_gen import MigrationConfig, pyspark_pipeline
151
+ from sql2iceberg.publish import SnowflakePublisher, SnowflakeTarget, tables_from_jdbc_catalog
152
+ from sql2iceberg.validate import validate, Target, ColumnSpec
153
+
154
+ stmts = detect_path("pipelines/", dialect="snowflake")
155
+ script, report = pyspark_pipeline(stmts, MigrationConfig(platform="snowflake", warehouse="s3://b/wh",
156
+ catalog_type="jdbc", catalog_uri="jdbc:sqlite:catalog.db"))
157
+ ```
158
+
159
+ ## Documentation
160
+
161
+ - [Environments and what was tested](docs/environments.md)
162
+ - [Every package, jar and service used in this build](docs/packages.md)
163
+ - [Examples from the live tests](docs/examples.md)
164
+ - [Troubleshooting frequent problems](docs/troubleshooting.md)
165
+ - [Known conversion differences between Snowflake and Spark](docs/known-issues.md)
166
+ - [Roadmap](docs/roadmap.md)
167
+
168
+ ## Getting help and reporting problems
169
+
170
+ - Run `sql2iceberg doctor`, then `sql2iceberg issue --title "short summary" --open` to open a GitHub issue pre-filled with
171
+ your environment (names and versions only, never values or credentials).
172
+ - Attach a trace (`--trace trace.jsonl`) after reading it. Secrets are redacted but check anyway.
173
+ - Issues: <https://github.com/jey1987-cmd/sql2iceberg/issues>. Please read [troubleshooting](docs/troubleshooting.md) first.
174
+ - Security problems: do not open a public issue; see [CONTRIBUTING.md](CONTRIBUTING.md#security).
175
+
176
+ ## Limitations
177
+
178
+ - Only **Snowflake as a source** and **Snowflake, AWS Glue/Athena, BigQuery as targets** were run against real systems.
179
+ Postgres, MySQL, SQL Server and Oracle connectors, REST/Hive catalogs, Spark 3.5, Azure and Databricks are untested.
180
+ - Tested with Iceberg **format-version 2**. Whether Snowflake and BigQuery read v3 tables is unverified here.
181
+ - BigQuery cannot read an S3 warehouse. The GCP path writes a second Iceberg copy to GCS.
182
+ - Snowflake object-store tables and BigQuery tables point at one metadata file (a snapshot): refresh after new writes.
183
+ - Validation compares counts, non-null counts and sums. It does not compare every row.
184
+ - Tested on Windows 10 with Python 3.13. Linux and macOS should work; CI covers Linux but live tests were not run there.
185
+ - Conversion uses [SQLGlot](https://github.com/tobymao/sqlglot); the converter flags constructs that behave differently in
186
+ Spark (see [known issues](docs/known-issues.md)) but a clean result still needs your own verification.
187
+
188
+ ## License
189
+
190
+ MIT. Not affiliated with Snowflake, the Apache Software Foundation, Amazon or Google. "Iceberg" is a trademark of the ASF.
@@ -0,0 +1,30 @@
1
+ sql2iceberg/__init__.py,sha256=stAfaZcxB9EhVvz6fuc8jLRwVBQZ-WR0_4vs_tQ_ncg,395
2
+ sql2iceberg/cli.py,sha256=ruTmNvpwqsTilasCwba0ltQDtkMua3VWb1arJiA4u-E,12343
3
+ sql2iceberg/compare.py,sha256=nXz3yLBNjhouICGVq8URBuZt1gMoVH5dQCw7DrjWni8,3361
4
+ sql2iceberg/convert.py,sha256=HPuQjWu2DIyWq07oDqDE-OmKzt1ufh5ESLXCKedasxg,7587
5
+ sql2iceberg/detect.py,sha256=R4UDXKngHU1O5d-Xw9UK1zdLHR3wuGVtkfCTnE47tZc,3666
6
+ sql2iceberg/diagnostics.py,sha256=G3KSUEHAc5iCdt9F-aepXzfHnUG7nIO6YvCj_2HnUz0,5925
7
+ sql2iceberg/generate.py,sha256=3jc-FB8-xM3QDGv9tEufOvQNubBysNy40kZ7PkX7WDk,5021
8
+ sql2iceberg/lineage.py,sha256=LDeLaxG0QtG6P8CZxjsoe_8J7IhDnDekPYFGo6nq_5U,2643
9
+ sql2iceberg/platforms.py,sha256=4S81gJ4naS0D-A3LZ2T_N6gPMj3RCjGkBCM8Eqt7pCI,2831
10
+ sql2iceberg/pyspark_gen.py,sha256=SX3rRI2ANdypsCmSokrOw1OoVgvhA1qQTzeY8U6oU4s,11465
11
+ sql2iceberg/pytest_plugin.py,sha256=7KbrrSLJ72fvEVFmXahWJOPx7pF7SX3CUTOAJoYKXhA,2767
12
+ sql2iceberg/report.py,sha256=uQ9jxg-0JZkwUAGhe9e_Qb25DYbRKcrucrhYlDpPKDw,1884
13
+ sql2iceberg/tracing.py,sha256=bxm7jnXrc249JAYYZYF1lbrHJmFl7Hb8vFUq9oPwu2o,4467
14
+ sql2iceberg/validate.py,sha256=dRAasPsB84DFITSyDNdvHVKqLpoiwshmXa3xKWpJwQ8,8723
15
+ sql2iceberg/verify.py,sha256=iToMmKL9lPajfJwwGHJn3K5rIr8J3d_-aB_RmFCpcu8,5312
16
+ sql2iceberg/connectors/__init__.py,sha256=aw3Syhw-gEJT5JIJY9K9A2xwLPmD6cWv50m5hHGtptE,727
17
+ sql2iceberg/connectors/base.py,sha256=biiQV8FLvX8Ll_CSE9Rqe5_DODNKbqVynG7Voi5hQbc,4165
18
+ sql2iceberg/connectors/others.py,sha256=RMO82sToQBKEm-Rve5LyA93h0a2jY_Z40D-gQoWGLBg,4853
19
+ sql2iceberg/connectors/snowflake.py,sha256=8sGeKDdwdIp5EzT57J33fS2W64KueQT5iKWHOM0V8rA,1960
20
+ sql2iceberg/publish/__init__.py,sha256=SCSz_iZpfwCRD-HQ-rizo-iNAqSpKumyU3KZWfnqmDY,758
21
+ sql2iceberg/publish/aws.py,sha256=3pWAvLXJat0LBBt8mknj3Dca1YS-7D_1l0cpf1iZjyo,5338
22
+ sql2iceberg/publish/base.py,sha256=zS72f8jNx7pjFuWgkyQnA8Ntf3VsdS0E67gQediE91g,4744
23
+ sql2iceberg/publish/gcp.py,sha256=4KE6ZKK6kWf4TllQvTKEX8j7sdd-WFpsZUWTtebsN0U,3544
24
+ sql2iceberg/publish/metadata.py,sha256=SGCLEOxC3gmzgX2AB00ZR2xIobwHrrEf6qgvN5BI8go,2718
25
+ sql2iceberg/publish/snowflake.py,sha256=uqTdurlDj0uB18Al0zG26xbMifmYG4XE-O5mKOuyJco,7622
26
+ sql2iceberg-0.1.0.dist-info/METADATA,sha256=0qEpHUAZs2Rzz4GMETIJmwC2mPT4G24XE1L_evg2lCI,10776
27
+ sql2iceberg-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
28
+ sql2iceberg-0.1.0.dist-info/entry_points.txt,sha256=3oOnpyo7QgckSJtXQOeCA3m-OK1T4Fwmy4oXNroly7w,105
29
+ sql2iceberg-0.1.0.dist-info/licenses/LICENSE,sha256=rqA3WWMGK7lfkrqpbLaJYvH5FUfpnXbkiPakVC8AUOA,1077
30
+ sql2iceberg-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ sql2iceberg = sql2iceberg.cli:main
3
+
4
+ [pytest11]
5
+ sql2iceberg = sql2iceberg.pytest_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeyaraman Ramalingam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.