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.
- sql2iceberg/__init__.py +15 -0
- sql2iceberg/cli.py +258 -0
- sql2iceberg/compare.py +68 -0
- sql2iceberg/connectors/__init__.py +22 -0
- sql2iceberg/connectors/base.py +126 -0
- sql2iceberg/connectors/others.py +133 -0
- sql2iceberg/connectors/snowflake.py +47 -0
- sql2iceberg/convert.py +170 -0
- sql2iceberg/detect.py +95 -0
- sql2iceberg/diagnostics.py +124 -0
- sql2iceberg/generate.py +112 -0
- sql2iceberg/lineage.py +58 -0
- sql2iceberg/platforms.py +63 -0
- sql2iceberg/publish/__init__.py +18 -0
- sql2iceberg/publish/aws.py +108 -0
- sql2iceberg/publish/base.py +120 -0
- sql2iceberg/publish/gcp.py +72 -0
- sql2iceberg/publish/metadata.py +63 -0
- sql2iceberg/publish/snowflake.py +143 -0
- sql2iceberg/pyspark_gen.py +251 -0
- sql2iceberg/pytest_plugin.py +74 -0
- sql2iceberg/report.py +49 -0
- sql2iceberg/tracing.py +134 -0
- sql2iceberg/validate.py +186 -0
- sql2iceberg/verify.py +138 -0
- sql2iceberg-0.1.0.dist-info/METADATA +190 -0
- sql2iceberg-0.1.0.dist-info/RECORD +30 -0
- sql2iceberg-0.1.0.dist-info/WHEEL +4 -0
- sql2iceberg-0.1.0.dist-info/entry_points.txt +5 -0
- sql2iceberg-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Generate a runnable PySpark script: ingest source tables over JDBC into Iceberg, then run the curation SQL.
|
|
2
|
+
|
|
3
|
+
The script reads credentials from environment variables when it runs. Nothing secret is written into it.
|
|
4
|
+
Iceberg table names are ``<catalog>.<namespace>.<table>``; source schema/database qualifiers are dropped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import sqlglot
|
|
13
|
+
from sqlglot import exp
|
|
14
|
+
from sqlglot.errors import ParseError
|
|
15
|
+
|
|
16
|
+
from .connectors import REGISTRY
|
|
17
|
+
from .convert import UNPARSED, UNSUPPORTED, convert_statement
|
|
18
|
+
from .lineage import map_statements
|
|
19
|
+
from .tracing import span
|
|
20
|
+
|
|
21
|
+
DEFAULT_ICEBERG_PACKAGE = "org.apache.iceberg:iceberg-spark-runtime-4.1_2.13:1.12.0"
|
|
22
|
+
CATALOG_TYPES = ("hadoop", "glue", "rest", "hive", "jdbc")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MigrationConfig:
|
|
27
|
+
platform: str = "snowflake"
|
|
28
|
+
catalog: str = "lake"
|
|
29
|
+
namespace: str = "db"
|
|
30
|
+
catalog_type: str = "hadoop" # hadoop | glue | rest | hive | jdbc (use jdbc, not hadoop, on S3)
|
|
31
|
+
region: str = "us-east-2" # S3 region when the warehouse is s3://
|
|
32
|
+
gcs_project: str = "" # GCP project when the warehouse is gs://
|
|
33
|
+
gcs_token_from_env: bool = False # gs:// only: read GCS_OAUTH_TOKEN (+ _EXPIRES_AT_MS) at run time, else ADC
|
|
34
|
+
warehouse: str = "./warehouse" # path or s3://bucket/prefix
|
|
35
|
+
catalog_uri: str = "" # rest / hive / jdbc (e.g. jdbc:sqlite:catalog.db)
|
|
36
|
+
format_version: int = 2
|
|
37
|
+
iceberg_package: str = DEFAULT_ICEBERG_PACKAGE
|
|
38
|
+
extra_packages: list[str] = field(default_factory=list)
|
|
39
|
+
jdbc_driver: str = "" # default: the connector's driver class
|
|
40
|
+
jdbc_package: str | None = None # default: the connector's Maven coordinate; "" for none
|
|
41
|
+
jars: list[str] = field(default_factory=list) # local jars (offline machines); use "" iceberg_package then
|
|
42
|
+
jdbc_url: str = "" # default: the connector's own URL, resolved at generation time
|
|
43
|
+
source_schema: str = "" # prefix for unqualified source tables, e.g. "PUBLIC"
|
|
44
|
+
# per source table: {"partition_column": "id", "lower": 1, "upper": 1000000, "num_partitions": 8}
|
|
45
|
+
table_options: dict[str, dict] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if self.catalog_type not in CATALOG_TYPES:
|
|
49
|
+
raise ValueError(f"catalog_type must be one of {CATALOG_TYPES}")
|
|
50
|
+
if self.platform not in REGISTRY:
|
|
51
|
+
raise ValueError(f"unknown platform {self.platform!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def target_name(name: str) -> str:
|
|
55
|
+
"""Last part of a table name, lower-cased: the Iceberg table it maps to."""
|
|
56
|
+
return name.split(".")[-1].strip('"`[]').lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _catalog_conf(cfg: MigrationConfig) -> dict[str, str]:
|
|
60
|
+
p = f"spark.sql.catalog.{cfg.catalog}"
|
|
61
|
+
conf = {p: "org.apache.iceberg.spark.SparkCatalog", f"{p}.warehouse": cfg.warehouse}
|
|
62
|
+
if cfg.warehouse.startswith("s3"):
|
|
63
|
+
conf[f"{p}.io-impl"] = "org.apache.iceberg.aws.s3.S3FileIO"
|
|
64
|
+
conf[f"{p}.client.region"] = cfg.region
|
|
65
|
+
if cfg.warehouse.startswith("gs"):
|
|
66
|
+
conf[f"{p}.io-impl"] = "org.apache.iceberg.gcp.gcs.GCSFileIO"
|
|
67
|
+
if cfg.gcs_project:
|
|
68
|
+
conf[f"{p}.gcs.project-id"] = cfg.gcs_project
|
|
69
|
+
if cfg.gcs_token_from_env:
|
|
70
|
+
conf[f"{p}.gcs.oauth2.token"] = "$ENV:GCS_OAUTH_TOKEN"
|
|
71
|
+
conf[f"{p}.gcs.oauth2.token-expires-at"] = "$ENV:GCS_OAUTH_TOKEN_EXPIRES_AT_MS"
|
|
72
|
+
if cfg.catalog_type == "jdbc":
|
|
73
|
+
conf[f"{p}.catalog-impl"] = "org.apache.iceberg.jdbc.JdbcCatalog"
|
|
74
|
+
conf[f"{p}.uri"] = cfg.catalog_uri
|
|
75
|
+
elif cfg.catalog_type == "glue":
|
|
76
|
+
conf[f"{p}.catalog-impl"] = "org.apache.iceberg.aws.glue.GlueCatalog"
|
|
77
|
+
conf[f"{p}.io-impl"] = "org.apache.iceberg.aws.s3.S3FileIO"
|
|
78
|
+
else:
|
|
79
|
+
conf[f"{p}.type"] = cfg.catalog_type
|
|
80
|
+
if cfg.catalog_uri:
|
|
81
|
+
conf[f"{p}.uri"] = cfg.catalog_uri
|
|
82
|
+
return conf
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _conf_line(key: str, value: str) -> str:
|
|
86
|
+
if value.startswith("$ENV:"): # tokens are read when the script runs, never written into it
|
|
87
|
+
return f" .config({key!r}, os.environ[{value[5:]!r}])"
|
|
88
|
+
return f" .config({key!r}, {value!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def rewrite_for_iceberg(spark_sql: str, cfg: MigrationConfig) -> str | None:
|
|
92
|
+
"""Point every table at the Iceberg catalog; make CTAS create Iceberg tables.
|
|
93
|
+
|
|
94
|
+
Returns None for a plain CREATE TABLE (no query): ingestion or the generated DDL creates those.
|
|
95
|
+
"""
|
|
96
|
+
tree = sqlglot.parse_one(spark_sql, read="spark")
|
|
97
|
+
if isinstance(tree, exp.Create) and not tree.expression and (tree.args.get("kind") or "").upper() == "TABLE":
|
|
98
|
+
return None
|
|
99
|
+
ctes = {c.alias for c in tree.find_all(exp.CTE)}
|
|
100
|
+
|
|
101
|
+
def fix(node):
|
|
102
|
+
if isinstance(node, exp.Table) and isinstance(node.this, exp.Identifier) and node.name not in ctes:
|
|
103
|
+
node = node.copy()
|
|
104
|
+
node.set("this", exp.to_identifier(target_name(node.name)))
|
|
105
|
+
node.set("db", exp.to_identifier(cfg.namespace))
|
|
106
|
+
node.set("catalog", exp.to_identifier(cfg.catalog))
|
|
107
|
+
return node
|
|
108
|
+
|
|
109
|
+
text = tree.transform(fix).sql(dialect="spark", comments=False)
|
|
110
|
+
props = f"USING iceberg TBLPROPERTIES ('format-version'='{cfg.format_version}')"
|
|
111
|
+
return re.sub(r"^(CREATE (?:OR REPLACE )?TABLE (?:IF NOT EXISTS )?\S+)\s+AS\b",
|
|
112
|
+
lambda m: f"{m.group(1)} {props} AS", text, count=1, flags=re.IGNORECASE)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _source_names(statements, cfg: MigrationConfig) -> dict[str, str]:
|
|
116
|
+
"""target table -> table name to read over JDBC, for tables the code reads but never creates."""
|
|
117
|
+
out: dict[str, str] = {}
|
|
118
|
+
for name in map_statements(statements)["source_tables"]:
|
|
119
|
+
src = name if "." in name or not cfg.source_schema else f"{cfg.source_schema}.{name}"
|
|
120
|
+
out[target_name(name)] = src
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_HEADER = '''"""Generated by sql2iceberg. Review every statement before running it on real data.
|
|
125
|
+
|
|
126
|
+
Set before running: {envs}
|
|
127
|
+
Run with: spark-submit {submit}pipeline_pyspark.py
|
|
128
|
+
"""
|
|
129
|
+
import os
|
|
130
|
+
|
|
131
|
+
from pyspark.sql import SparkSession
|
|
132
|
+
|
|
133
|
+
CATALOG = {catalog!r}
|
|
134
|
+
NAMESPACE = {namespace!r}
|
|
135
|
+
FORMAT_VERSION = {format_version!r}
|
|
136
|
+
JDBC_URL = {jdbc_url!r}
|
|
137
|
+
JDBC_DRIVER = {jdbc_driver!r}
|
|
138
|
+
# Iceberg table -> (source table to read, partitioned-read options)
|
|
139
|
+
SOURCES = {sources}
|
|
140
|
+
|
|
141
|
+
spark = (
|
|
142
|
+
SparkSession.builder.appName("sql2iceberg-migration")
|
|
143
|
+
{jars_conf}{pkgs_conf} .config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
|
|
144
|
+
{conf}
|
|
145
|
+
.getOrCreate()
|
|
146
|
+
)
|
|
147
|
+
spark.sql(f"CREATE NAMESPACE IF NOT EXISTS {{CATALOG}}.{{NAMESPACE}}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ingest(target, source, opts):
|
|
151
|
+
"""Copy one source table into Iceberg (create or replace)."""
|
|
152
|
+
reader = (
|
|
153
|
+
spark.read.format("jdbc")
|
|
154
|
+
.option("url", JDBC_URL)
|
|
155
|
+
.option("driver", JDBC_DRIVER)
|
|
156
|
+
.option("dbtable", source)
|
|
157
|
+
.option("user", os.environ[{user_env!r}])
|
|
158
|
+
)
|
|
159
|
+
key_file = os.environ.get({key_env!r}) if {key_env!r} else None
|
|
160
|
+
if key_file: # key-pair login
|
|
161
|
+
reader = reader.option({key_option!r}, key_file)
|
|
162
|
+
else:
|
|
163
|
+
reader = reader.option("password", os.environ[{pw_env!r}])
|
|
164
|
+
if opts.get("partition_column"):
|
|
165
|
+
reader = (
|
|
166
|
+
reader.option("partitionColumn", opts["partition_column"])
|
|
167
|
+
.option("lowerBound", opts["lower"])
|
|
168
|
+
.option("upperBound", opts["upper"])
|
|
169
|
+
.option("numPartitions", opts.get("num_partitions", 4))
|
|
170
|
+
)
|
|
171
|
+
(
|
|
172
|
+
reader.load()
|
|
173
|
+
.writeTo(f"{{CATALOG}}.{{NAMESPACE}}.{{target}}")
|
|
174
|
+
.using("iceberg")
|
|
175
|
+
.tableProperty("format-version", str(FORMAT_VERSION))
|
|
176
|
+
.createOrReplace()
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
for _target, (_source, _opts) in SOURCES.items():
|
|
181
|
+
ingest(_target, _source, _opts)
|
|
182
|
+
|
|
183
|
+
# ---- curation ----
|
|
184
|
+
'''
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def pyspark_pipeline(statements, cfg: MigrationConfig, dialect: str | None = None) -> tuple[str, list[dict]]:
|
|
188
|
+
with span("pyspark_gen", platform=cfg.platform, catalog_type=cfg.catalog_type, statements=len(statements)):
|
|
189
|
+
return _pyspark_pipeline(statements, cfg, dialect)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _pyspark_pipeline(statements, cfg: MigrationConfig, dialect: str | None = None) -> tuple[str, list[dict]]:
|
|
193
|
+
"""Return (script text, per-statement report). Unsupported or dynamic statements are commented out."""
|
|
194
|
+
conn = REGISTRY[cfg.platform]
|
|
195
|
+
dialect = dialect or conn.sqlglot_dialect
|
|
196
|
+
sources = _source_names(statements, cfg) if statements else {}
|
|
197
|
+
table_src = {t: (s, cfg.table_options.get(s) or cfg.table_options.get(t) or {}) for t, s in sources.items()}
|
|
198
|
+
jdbc_pkg = conn.jdbc_package if cfg.jdbc_package is None else cfg.jdbc_package
|
|
199
|
+
packages = ",".join(p for p in (cfg.iceberg_package, jdbc_pkg, *cfg.extra_packages) if p)
|
|
200
|
+
jdbc_url = cfg.jdbc_url
|
|
201
|
+
if not jdbc_url:
|
|
202
|
+
try:
|
|
203
|
+
jdbc_url = conn().jdbc_url()
|
|
204
|
+
except KeyError as exc:
|
|
205
|
+
raise ValueError(f"set MigrationConfig.jdbc_url or the environment variable {exc.args[0]}") from None
|
|
206
|
+
envs = ", ".join(e for e in (conn.jdbc_user_env, conn.jdbc_password_env) if e)
|
|
207
|
+
if conn.jdbc_key_env:
|
|
208
|
+
envs += f" (or {conn.jdbc_key_env} instead of the password)"
|
|
209
|
+
pkg_arg = f"--packages {packages} " if packages else ""
|
|
210
|
+
joined_jars = ",".join(cfg.jars)
|
|
211
|
+
jars_conf = f" .config('spark.jars', {joined_jars!r})\n" if cfg.jars else ""
|
|
212
|
+
pkgs_conf = f" .config('spark.jars.packages', {packages!r})\n" if packages else ""
|
|
213
|
+
lines = [_HEADER.format(
|
|
214
|
+
envs=envs, submit=pkg_arg, catalog=cfg.catalog, namespace=cfg.namespace,
|
|
215
|
+
format_version=cfg.format_version, jdbc_url=jdbc_url, jdbc_driver=cfg.jdbc_driver or conn.jdbc_driver,
|
|
216
|
+
jars_conf=jars_conf,
|
|
217
|
+
sources="{\n" + "".join(f" {t!r}: ({s!r}, {o!r}),\n" for t, (s, o) in table_src.items()) + "}",
|
|
218
|
+
pkgs_conf=pkgs_conf, user_env=conn.jdbc_user_env, pw_env=conn.jdbc_password_env,
|
|
219
|
+
key_env=conn.jdbc_key_env, key_option=conn.jdbc_key_option,
|
|
220
|
+
conf="\n".join(_conf_line(k, v) for k, v in _catalog_conf(cfg).items()),
|
|
221
|
+
)]
|
|
222
|
+
report: list[dict] = []
|
|
223
|
+
for s in statements:
|
|
224
|
+
entry = {"id": s.id, "status": "skipped", "note": ""}
|
|
225
|
+
report.append(entry)
|
|
226
|
+
if s.dynamic or s.parse_error:
|
|
227
|
+
entry["note"] = "built from variables" if s.dynamic else "could not parse"
|
|
228
|
+
lines.append(f"# {s.id}: skipped ({entry['note']})")
|
|
229
|
+
continue
|
|
230
|
+
r = convert_statement(s.sql, dialect=dialect)
|
|
231
|
+
entry["status"] = r.status
|
|
232
|
+
lines.append(f"# {s.id} [{r.status}]")
|
|
233
|
+
lines.extend(f"# {f.code}: {f.message}" for f in r.findings)
|
|
234
|
+
if r.status in (UNSUPPORTED, UNPARSED) or r.spark_sql is None:
|
|
235
|
+
lines.append("# NOT GENERATED: rewrite by hand")
|
|
236
|
+
lines.append("# " + (r.spark_sql or s.sql).replace("\n", "\n# "))
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
sql = rewrite_for_iceberg(r.spark_sql, cfg)
|
|
240
|
+
except ParseError as exc:
|
|
241
|
+
entry["status"], entry["note"] = UNPARSED, str(exc)[:120]
|
|
242
|
+
lines.append(f"# NOT GENERATED: converted SQL did not parse as Spark ({entry['note']})")
|
|
243
|
+
continue
|
|
244
|
+
if sql is None:
|
|
245
|
+
entry["note"] = "plain CREATE TABLE: table is created by ingestion"
|
|
246
|
+
lines.append("# plain CREATE TABLE skipped: ingestion or generated DDL creates it")
|
|
247
|
+
else:
|
|
248
|
+
lines.append(f'spark.sql("""{sql}""")')
|
|
249
|
+
lines.append("")
|
|
250
|
+
lines.append("spark.stop()")
|
|
251
|
+
return "\n".join(lines) + "\n", report
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""pytest plugin: fixtures and markers for validating converted SQL.
|
|
2
|
+
|
|
3
|
+
Installed through the `pytest11` entry point. Spark fixtures need `pip install 'sql2iceberg[pytest]'`.
|
|
4
|
+
Set SQL2ICE_ICEBERG=1 (and optionally ICEBERG_PKG) to add the Iceberg runtime and a Hadoop catalog.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from .convert import convert_statement
|
|
14
|
+
|
|
15
|
+
DEFAULT_ICEBERG_PKG = "org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.9.2"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pytest_addoption(parser):
|
|
19
|
+
group = parser.getgroup("sql2iceberg")
|
|
20
|
+
group.addoption("--live-snowflake", action="store_true", default=False,
|
|
21
|
+
help="run tests marked live_snowflake against a real Snowflake account (spends credits)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pytest_collection_modifyitems(config, items):
|
|
25
|
+
if config.getoption("--live-snowflake"):
|
|
26
|
+
return
|
|
27
|
+
skip = pytest.mark.skip(reason="needs --live-snowflake (spends Snowflake credits)")
|
|
28
|
+
for item in items:
|
|
29
|
+
if "live_snowflake" in item.keywords:
|
|
30
|
+
item.add_marker(skip)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(scope="session")
|
|
34
|
+
def sf2i_has_iceberg() -> bool:
|
|
35
|
+
return os.environ.get("SQL2ICE_ICEBERG") == "1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="session")
|
|
39
|
+
def sf2i_spark(tmp_path_factory, sf2i_has_iceberg):
|
|
40
|
+
pyspark_sql = pytest.importorskip("pyspark.sql")
|
|
41
|
+
warehouse = str(tmp_path_factory.mktemp("warehouse"))
|
|
42
|
+
builder = (
|
|
43
|
+
pyspark_sql.SparkSession.builder.master("local[2]")
|
|
44
|
+
.appName("sql2iceberg-tests")
|
|
45
|
+
.config("spark.ui.enabled", "false")
|
|
46
|
+
.config("spark.sql.shuffle.partitions", "2")
|
|
47
|
+
.config("spark.sql.warehouse.dir", warehouse + "/spark")
|
|
48
|
+
)
|
|
49
|
+
if sf2i_has_iceberg:
|
|
50
|
+
pkg = os.environ.get("ICEBERG_PKG", DEFAULT_ICEBERG_PKG)
|
|
51
|
+
jar = os.environ.get("ICEBERG_JAR") # local jar, for machines that cannot reach Maven Central
|
|
52
|
+
builder = (
|
|
53
|
+
builder.config("spark.jars", jar) if jar else builder.config("spark.jars.packages", pkg)
|
|
54
|
+
)
|
|
55
|
+
builder = (
|
|
56
|
+
builder.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
|
|
57
|
+
.config("spark.sql.catalog.lake", "org.apache.iceberg.spark.SparkCatalog")
|
|
58
|
+
.config("spark.sql.catalog.lake.type", "hadoop")
|
|
59
|
+
.config("spark.sql.catalog.lake.warehouse", warehouse)
|
|
60
|
+
.config("spark.sql.defaultCatalog", "lake")
|
|
61
|
+
)
|
|
62
|
+
session = builder.getOrCreate()
|
|
63
|
+
session.sparkContext.setLogLevel("ERROR")
|
|
64
|
+
if sf2i_has_iceberg:
|
|
65
|
+
session.sql("CREATE NAMESPACE IF NOT EXISTS lake.db")
|
|
66
|
+
session.sql("USE lake.db")
|
|
67
|
+
yield session
|
|
68
|
+
session.stop()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture()
|
|
72
|
+
def sf2i_convert():
|
|
73
|
+
"""Return a function that converts one Snowflake statement and returns a Result."""
|
|
74
|
+
return convert_statement
|
sql2iceberg/report.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Build the conversion report (JSON and readable Markdown)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from .convert import Result
|
|
9
|
+
from .detect import Statement
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build(statements: list[Statement], results: dict[str, Result]) -> dict:
|
|
13
|
+
items = []
|
|
14
|
+
for s in statements:
|
|
15
|
+
item = {"id": s.id, "file": s.file, "line": s.line}
|
|
16
|
+
if s.dynamic:
|
|
17
|
+
item.update(status="dynamic", note="built from variables; needs parameters")
|
|
18
|
+
elif s.parse_error:
|
|
19
|
+
item.update(status="unparsed", note=s.parse_error)
|
|
20
|
+
else:
|
|
21
|
+
item.update(results[s.id].to_dict())
|
|
22
|
+
items.append(item)
|
|
23
|
+
counts = Counter(i["status"] for i in items)
|
|
24
|
+
return {"summary": dict(counts), "total": len(items), "statements": items}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_json(report: dict) -> str:
|
|
28
|
+
return json.dumps(report, indent=2)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_markdown(report: dict) -> str:
|
|
32
|
+
lines = ["# sql2iceberg conversion report", "", f"Statements found: {report['total']}", ""]
|
|
33
|
+
for status, n in sorted(report["summary"].items()):
|
|
34
|
+
lines.append(f"- {status}: {n}")
|
|
35
|
+
lines.append("")
|
|
36
|
+
for item in report["statements"]:
|
|
37
|
+
lines.append(f"## {item['id']} ({item['status']})")
|
|
38
|
+
if item.get("source_sql"):
|
|
39
|
+
lines += ["", "```sql", item["source_sql"], "```"]
|
|
40
|
+
if item.get("spark_sql"):
|
|
41
|
+
lines += ["", "Spark SQL:", "```sql", item["spark_sql"], "```"]
|
|
42
|
+
for f in item.get("findings", []):
|
|
43
|
+
lines.append(f"- **{f['code']}** ({f['severity']}): {f['message']}")
|
|
44
|
+
if item.get("note"):
|
|
45
|
+
lines.append(f"- {item['note']}")
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append("Statuses mean: converted = no known problems found (still verify); "
|
|
48
|
+
"review = semantics may differ from Snowflake; unsupported = known not to run in Spark.")
|
|
49
|
+
return "\n".join(lines)
|
sql2iceberg/tracing.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""A small tracer: timed, nested spans for every step of a run, written as JSON lines.
|
|
2
|
+
|
|
3
|
+
Enable it with ``--trace trace.jsonl`` on the CLI, ``SQL2ICEBERG_TRACE=trace.jsonl`` in the environment, or in code:
|
|
4
|
+
|
|
5
|
+
from sql2iceberg.tracing import Tracer, set_tracer
|
|
6
|
+
set_tracer(Tracer("trace.jsonl"))
|
|
7
|
+
|
|
8
|
+
With no tracer configured every call is a no-op. Attribute values whose key looks secret (password, token,
|
|
9
|
+
key, secret, credential) and anything that looks like a bearer/access token are replaced with ``***`` before
|
|
10
|
+
they are stored, so a trace file is safe to attach to a GitHub issue after a quick read.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_SECRET_KEY = re.compile(r"pass(word)?|token|secret|credential|private|(^|_)key($|_)", re.IGNORECASE)
|
|
26
|
+
_SECRET_VALUE = re.compile(r"(ya29\.[\w.-]+|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[\w-]{20,}\.[\w-]+\.[\w-]+)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def redact(key: str, value):
|
|
30
|
+
if _SECRET_KEY.search(key):
|
|
31
|
+
return "***"
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return _SECRET_VALUE.sub("***", value)[:500]
|
|
34
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
35
|
+
return value
|
|
36
|
+
return _SECRET_VALUE.sub("***", str(value))[:500]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Span:
|
|
41
|
+
id: str
|
|
42
|
+
parent: str | None
|
|
43
|
+
name: str
|
|
44
|
+
start: float
|
|
45
|
+
attrs: dict = field(default_factory=dict)
|
|
46
|
+
duration_ms: float = 0.0
|
|
47
|
+
status: str = "ok"
|
|
48
|
+
error: str = ""
|
|
49
|
+
|
|
50
|
+
def set(self, **attrs) -> None:
|
|
51
|
+
self.attrs.update({k: redact(k, v) for k, v in attrs.items()})
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
d = {"id": self.id, "parent": self.parent, "name": self.name, "start": round(self.start, 3),
|
|
55
|
+
"duration_ms": round(self.duration_ms, 1), "status": self.status, "attrs": self.attrs}
|
|
56
|
+
if self.error:
|
|
57
|
+
d["error"] = self.error
|
|
58
|
+
return d
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Tracer:
|
|
62
|
+
def __init__(self, path: str | Path | None = None, run_id: str | None = None) -> None:
|
|
63
|
+
self.path = Path(path) if path else None
|
|
64
|
+
self.run_id = run_id or uuid.uuid4().hex[:12]
|
|
65
|
+
self.spans: list[Span] = []
|
|
66
|
+
self._local = threading.local()
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
if self.path:
|
|
69
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
def _stack(self) -> list[Span]:
|
|
72
|
+
if not hasattr(self._local, "stack"):
|
|
73
|
+
self._local.stack = []
|
|
74
|
+
return self._local.stack
|
|
75
|
+
|
|
76
|
+
@contextlib.contextmanager
|
|
77
|
+
def span(self, name: str, **attrs):
|
|
78
|
+
stack = self._stack()
|
|
79
|
+
sp = Span(uuid.uuid4().hex[:8], stack[-1].id if stack else None, name, time.time())
|
|
80
|
+
sp.set(**attrs)
|
|
81
|
+
stack.append(sp)
|
|
82
|
+
t0 = time.perf_counter()
|
|
83
|
+
try:
|
|
84
|
+
yield sp
|
|
85
|
+
except BaseException as exc:
|
|
86
|
+
sp.status, sp.error = "error", f"{type(exc).__name__}: {exc}"[:500]
|
|
87
|
+
raise
|
|
88
|
+
finally:
|
|
89
|
+
sp.duration_ms = (time.perf_counter() - t0) * 1000
|
|
90
|
+
stack.pop()
|
|
91
|
+
with self._lock:
|
|
92
|
+
self.spans.append(sp)
|
|
93
|
+
if self.path:
|
|
94
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
95
|
+
f.write(json.dumps({"run": self.run_id, **sp.to_dict()}) + "\n")
|
|
96
|
+
|
|
97
|
+
def summary(self) -> str:
|
|
98
|
+
rows = ["| step | status | ms |", "|---|---|---|"]
|
|
99
|
+
for sp in sorted(self.spans, key=lambda s: s.start):
|
|
100
|
+
depth = 0
|
|
101
|
+
p = sp.parent
|
|
102
|
+
ids = {s.id: s for s in self.spans}
|
|
103
|
+
while p and p in ids:
|
|
104
|
+
depth += 1
|
|
105
|
+
p = ids[p].parent
|
|
106
|
+
rows.append(f"| {' ' * depth}{sp.name} | {sp.status} | {sp.duration_ms:.0f} |")
|
|
107
|
+
return f"Trace {self.run_id}\n\n" + "\n".join(rows) + "\n"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Noop:
|
|
111
|
+
@contextlib.contextmanager
|
|
112
|
+
def span(self, name: str, **attrs):
|
|
113
|
+
yield Span("", None, name, time.time())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_current: Tracer | _Noop | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_tracer(tracer: Tracer | None) -> None:
|
|
120
|
+
global _current
|
|
121
|
+
_current = tracer
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_tracer() -> Tracer | _Noop:
|
|
125
|
+
global _current
|
|
126
|
+
if _current is None:
|
|
127
|
+
path = os.environ.get("SQL2ICEBERG_TRACE")
|
|
128
|
+
_current = Tracer(path) if path else _Noop()
|
|
129
|
+
return _current
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def span(name: str, **attrs):
|
|
133
|
+
"""``with span("step", table="orders") as sp: ... sp.set(rows=3)``"""
|
|
134
|
+
return get_tracer().span(name, **attrs)
|