Repository: TobikoData/sqlmesh
Pull request #4323 (2025-05-07 12:01:54)

Chore: detect invalid audit refs and raise early
This is a UX improvement. If a model references an undefined model today, we crash with a `KeyError` after loading, in the metadata hash computation logic:

```
  File "sqlmesh/core/model/definition.py", line 1112, in metadata_hash
    audit = self.audit_definitions[audit_name]
            ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
KeyError: 'not_nulll'
```

This PR gracefully handles invalid references and raises early to avoid this:

```
Error: Failed to load model definition at '.../playground/models/test.sql'.
Audit 'not_nulll' is undefined at '.../playground/models/test.sql'
```


Patch:
diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py
index 3dd73429..a49e074b 100644
--- a/sqlmesh/core/model/definition.py
+++ b/sqlmesh/core/model/definition.py
@@ -2295,7 +2295,7 @@ def create_python_model(
     else:
         depends_on_rendered = render_expression(
             expression=exp.Array(
-                expressions=[d.parse_one(dep, dialect=dialect) for dep in depends_on or []]
+                expressions=[exp.maybe_parse(dep, dialect=dialect) for dep in depends_on or []]
             ),
             module_path=module_path,
             macros=macros,
@@ -2478,6 +2478,14 @@ def _create_model(
 
     model.audit_definitions.update(audit_definitions)
 
+    from sqlmesh.core.audit.builtin import BUILT_IN_AUDITS
+
+    # Ensure that all audits referenced in the model are defined
+    available_audits = BUILT_IN_AUDITS.keys() | model.audit_definitions.keys()
+    for referenced_audit, *_ in model.audits:
+        if referenced_audit not in available_audits:
+            raise_config_error(f"Audit '{referenced_audit}' is undefined", location=path)
+
     # Any macro referenced in audits or signals needs to be treated as metadata-only
     statements.extend((audit.query, True) for audit in audit_definitions.values())
     for _, audit_args in model.audits:


Test patch:
diff --git a/docs/concepts/tests.md b/docs/concepts/tests.md
index 04f204f3..c1714ea9 100644
--- a/docs/concepts/tests.md
+++ b/docs/concepts/tests.md
@@ -605,7 +605,7 @@ An optional dictionary that maps columns to their types:
 ```yaml linenums="1"
     <upstream_model>:
       columns:
-        - <column_name>: <column_type>
+        <column_name>: <column_type>
         ...
 ```
 
diff --git a/tests/core/test_model.py b/tests/core/test_model.py
index 3686b354..ee8dc043 100644
--- a/tests/core/test_model.py
+++ b/tests/core/test_model.py
@@ -1342,7 +1342,18 @@ def test_audits():
     """
     )
 
-    model = load_sql_based_model(expressions, path=Path("./examples/sushi/models/test_model.sql"))
+    audit_definitions = {
+        audit_name: load_audit(
+            d.parse(f"AUDIT (name {audit_name}); SELECT 1 WHERE FALSE"), dialect="duckdb"
+        )
+        for audit_name in ("audit_a", "audit_b", "audit_c")
+    }
+
+    model = load_sql_based_model(
+        expressions,
+        path=Path("./examples/sushi/models/test_model.sql"),
+        audit_definitions=audit_definitions,
+    )
     assert model.audits == [
         ("audit_a", {}),
         ("audit_b", {"key": exp.Literal.string("value")}),
@@ -8845,6 +8856,54 @@ def entrypoint(context, *args, **kwargs):
     assert t.cast(pd.DataFrame, list(m.render(context=context))[0]).to_dict() == {"x": {0: 1}}
 
 
+def test_python_model_depends_on_blueprints(tmp_path: Path) -> None:
+    sql_model = tmp_path / "models" / "base_blueprints.sql"
+    sql_model.parent.mkdir(parents=True, exist_ok=True)
+    sql_model.write_text(
+        """
+        MODEL (
+          name test_schema1.@{model_name},
+          blueprints ((model_name := foo), (model_name := bar)),
+          kind FULL
+        );
+
+        SELECT 1 AS id
+        """
+    )
+
+    py_model = tmp_path / "models" / "depends_on_with_blueprint_vars.py"
+    py_model.parent.mkdir(parents=True, exist_ok=True)
+    py_model.write_text(
+        """
+import pandas as pd
+from sqlmesh import model
+
+@model(
+    "test_schema2.@model_name",
+    columns={
+        "id": "int",
+    },
+    blueprints=[
+        {"model_name": "foo"},
+        {"model_name": "bar"},
+    ],
+    depends_on=["test_schema1.@{model_name}"],
+)
+def entrypoint(context, *args, **kwargs):
+    table = context.resolve_table(f"test_schema1.{context.blueprint_var('model_name')}")
+    return context.fetchdf(f"SELECT * FROM {table}")"""
+    )
+
+    ctx = Context(
+        config=Config(model_defaults=ModelDefaultsConfig(dialect="duckdb")),
+        paths=tmp_path,
+    )
+    assert len(ctx.models) == 4
+
+    ctx.plan(no_prompts=True, auto_apply=True)
+    assert ctx.fetchdf("SELECT * FROM test_schema2.foo").to_dict() == {"id": {0: 1}}
+
+
 @time_machine.travel("2020-01-01 00:00:00 UTC")
 def test_dynamic_date_spine_model(assert_exp_eq):
     @macro()
@@ -9587,3 +9646,19 @@ SELECT
     plan = ctx.plan(no_prompts=True, auto_apply=True)
 
     assert not plan.context_diff.modified_snapshots
+
+
+def test_invalid_audit_reference():
+    sql = """
+    MODEL (
+      name test,
+      audits (not_nulll (columns := (id)))
+    );
+
+    SELECT
+      1 AS id
+    """
+    expressions = d.parse(sql)
+
+    with pytest.raises(ConfigError, match="Audit 'not_nulll' is undefined"):
+        load_sql_based_model(expressions)