Edit on GitHub

Re-normalize python_env payloads using ast.unparse after dropping astor.

SQLMesh previously used the third-party astor library to serialise Python function source code (normalize_source). That library has been replaced with the stdlib ast.unparse, which produces subtly different text for the same AST (e.g. lambda : xlambda: x, condensed multi-line signatures, etc.).

Because python_env payloads are included in each snapshot's data_hash, any model that contains Python code (Python models, SQL models with Python macros/signals) would otherwise appear as Directly Modified after the upgrade, potentially triggering a full backfill.

This migration re-normalises every stored Executable payload of kind == "definition" via ast.unparse(ast.parse(payload)). The subsequent _migrate_rows pass then recomputes fingerprints from the updated payloads so that they match what the current code produces when loading models from disk. The migrated snapshots are flagged migrated = True, so no unexpected backfills are scheduled.

  1"""Re-normalize python_env payloads using ast.unparse after dropping astor.
  2
  3SQLMesh previously used the third-party `astor` library to serialise Python
  4function source code (`normalize_source`). That library has been replaced with
  5the stdlib `ast.unparse`, which produces subtly different text for the same
  6AST (e.g. `lambda : x` → `lambda: x`, condensed multi-line signatures, etc.).
  7
  8Because `python_env` payloads are included in each snapshot's `data_hash`,
  9any model that contains Python code (Python models, SQL models with Python
 10macros/signals) would otherwise appear as *Directly Modified* after the upgrade,
 11potentially triggering a full backfill.
 12
 13This migration re-normalises every stored `Executable` payload of
 14`kind == "definition"` via `ast.unparse(ast.parse(payload))`. The
 15subsequent `_migrate_rows` pass then recomputes fingerprints from the updated
 16payloads so that they match what the current code produces when loading models
 17from disk. The migrated snapshots are flagged `migrated = True`, so no
 18unexpected backfills are scheduled.
 19"""
 20
 21import ast
 22import json
 23
 24from sqlglot import exp
 25
 26from sqlmesh.utils.migration import index_text_type, blob_text_type
 27
 28
 29def migrate_schemas(engine_adapter, schema, **kwargs):  # type: ignore
 30    pass
 31
 32
 33def migrate_rows(engine_adapter, schema, **kwargs):  # type: ignore
 34    import pandas as pd
 35
 36    snapshots_table = "_snapshots"
 37    if schema:
 38        snapshots_table = f"{schema}.{snapshots_table}"
 39
 40    index_type = index_text_type(engine_adapter.dialect)
 41    blob_type = blob_text_type(engine_adapter.dialect)
 42
 43    new_snapshots = []
 44    migration_needed = False
 45
 46    for (
 47        name,
 48        identifier,
 49        version,
 50        snapshot,
 51        kind_name,
 52        updated_ts,
 53        unpaused_ts,
 54        ttl_ms,
 55        unrestorable,
 56        forward_only,
 57        dev_version,
 58        fingerprint,
 59    ) in engine_adapter.fetchall(
 60        exp.select(
 61            "name",
 62            "identifier",
 63            "version",
 64            "snapshot",
 65            "kind_name",
 66            "updated_ts",
 67            "unpaused_ts",
 68            "ttl_ms",
 69            "unrestorable",
 70            "forward_only",
 71            "dev_version",
 72            "fingerprint",
 73        ).from_(snapshots_table),
 74        quote_identifiers=True,
 75    ):
 76        parsed_snapshot = json.loads(snapshot)
 77        python_env = parsed_snapshot["node"].get("python_env") or {}
 78        for executable in python_env.values():
 79            if executable.get("kind") != "definition":
 80                continue
 81            new_payload = ast.unparse(ast.parse(executable["payload"])).strip()
 82            if new_payload != executable["payload"]:
 83                executable["payload"] = new_payload
 84                migration_needed = True
 85
 86        new_snapshots.append(
 87            {
 88                "name": name,
 89                "identifier": identifier,
 90                "version": version,
 91                "snapshot": json.dumps(parsed_snapshot),
 92                "kind_name": kind_name,
 93                "updated_ts": updated_ts,
 94                "unpaused_ts": unpaused_ts,
 95                "ttl_ms": ttl_ms,
 96                "unrestorable": unrestorable,
 97                "forward_only": forward_only,
 98                "dev_version": dev_version,
 99                "fingerprint": fingerprint,
100            }
101        )
102
103    if migration_needed and new_snapshots:
104        engine_adapter.delete_from(snapshots_table, "TRUE")
105
106        engine_adapter.insert_append(
107            snapshots_table,
108            pd.DataFrame(new_snapshots),
109            target_columns_to_types={
110                "name": exp.DataType.build(index_type),
111                "identifier": exp.DataType.build(index_type),
112                "version": exp.DataType.build(index_type),
113                "snapshot": exp.DataType.build(blob_type),
114                "kind_name": exp.DataType.build(index_type),
115                "updated_ts": exp.DataType.build("bigint"),
116                "unpaused_ts": exp.DataType.build("bigint"),
117                "ttl_ms": exp.DataType.build("bigint"),
118                "unrestorable": exp.DataType.build("boolean"),
119                "forward_only": exp.DataType.build("boolean"),
120                "dev_version": exp.DataType.build(index_type),
121                "fingerprint": exp.DataType.build(blob_type),
122            },
123        )
def migrate_schemas(engine_adapter, schema, **kwargs):
30def migrate_schemas(engine_adapter, schema, **kwargs):  # type: ignore
31    pass
def migrate_rows(engine_adapter, schema, **kwargs):
 34def migrate_rows(engine_adapter, schema, **kwargs):  # type: ignore
 35    import pandas as pd
 36
 37    snapshots_table = "_snapshots"
 38    if schema:
 39        snapshots_table = f"{schema}.{snapshots_table}"
 40
 41    index_type = index_text_type(engine_adapter.dialect)
 42    blob_type = blob_text_type(engine_adapter.dialect)
 43
 44    new_snapshots = []
 45    migration_needed = False
 46
 47    for (
 48        name,
 49        identifier,
 50        version,
 51        snapshot,
 52        kind_name,
 53        updated_ts,
 54        unpaused_ts,
 55        ttl_ms,
 56        unrestorable,
 57        forward_only,
 58        dev_version,
 59        fingerprint,
 60    ) in engine_adapter.fetchall(
 61        exp.select(
 62            "name",
 63            "identifier",
 64            "version",
 65            "snapshot",
 66            "kind_name",
 67            "updated_ts",
 68            "unpaused_ts",
 69            "ttl_ms",
 70            "unrestorable",
 71            "forward_only",
 72            "dev_version",
 73            "fingerprint",
 74        ).from_(snapshots_table),
 75        quote_identifiers=True,
 76    ):
 77        parsed_snapshot = json.loads(snapshot)
 78        python_env = parsed_snapshot["node"].get("python_env") or {}
 79        for executable in python_env.values():
 80            if executable.get("kind") != "definition":
 81                continue
 82            new_payload = ast.unparse(ast.parse(executable["payload"])).strip()
 83            if new_payload != executable["payload"]:
 84                executable["payload"] = new_payload
 85                migration_needed = True
 86
 87        new_snapshots.append(
 88            {
 89                "name": name,
 90                "identifier": identifier,
 91                "version": version,
 92                "snapshot": json.dumps(parsed_snapshot),
 93                "kind_name": kind_name,
 94                "updated_ts": updated_ts,
 95                "unpaused_ts": unpaused_ts,
 96                "ttl_ms": ttl_ms,
 97                "unrestorable": unrestorable,
 98                "forward_only": forward_only,
 99                "dev_version": dev_version,
100                "fingerprint": fingerprint,
101            }
102        )
103
104    if migration_needed and new_snapshots:
105        engine_adapter.delete_from(snapshots_table, "TRUE")
106
107        engine_adapter.insert_append(
108            snapshots_table,
109            pd.DataFrame(new_snapshots),
110            target_columns_to_types={
111                "name": exp.DataType.build(index_type),
112                "identifier": exp.DataType.build(index_type),
113                "version": exp.DataType.build(index_type),
114                "snapshot": exp.DataType.build(blob_type),
115                "kind_name": exp.DataType.build(index_type),
116                "updated_ts": exp.DataType.build("bigint"),
117                "unpaused_ts": exp.DataType.build("bigint"),
118                "ttl_ms": exp.DataType.build("bigint"),
119                "unrestorable": exp.DataType.build("boolean"),
120                "forward_only": exp.DataType.build("boolean"),
121                "dev_version": exp.DataType.build(index_type),
122                "fingerprint": exp.DataType.build(blob_type),
123            },
124        )