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,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"| {'&nbsp;&nbsp;' * 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)