Repository: TobikoData/sqlmesh
Pull request #4270 (2025-04-28 21:57:04)

Fix: Add custom exception for macro evaluation errors
Handle MacroEval errors similar to pythonmodel and signal evaluation errors that we want to catch.


Patch:
diff --git a/sqlmesh/core/macros.py b/sqlmesh/core/macros.py
index 5bbcbf21..b9bf3f54 100644
--- a/sqlmesh/core/macros.py
+++ b/sqlmesh/core/macros.py
@@ -41,7 +41,12 @@ from sqlmesh.utils import (
 from sqlmesh.utils.date import DatetimeRanges, to_datetime, to_date
 from sqlmesh.utils.errors import MacroEvalError, SQLMeshError
 from sqlmesh.utils.jinja import JinjaMacroRegistry, has_jinja
-from sqlmesh.utils.metaprogramming import Executable, SqlValue, prepare_env, print_exception
+from sqlmesh.utils.metaprogramming import (
+    Executable,
+    SqlValue,
+    format_evaluated_code_exception,
+    prepare_env,
+)
 
 if t.TYPE_CHECKING:
     from sqlglot.dialects.dialect import DialectType
@@ -220,15 +225,17 @@ class MacroEvaluator:
         func = self.macros.get(normalize_macro_name(name))
 
         if not callable(func):
-            raise SQLMeshError(f"Macro '{name}' does not exist.")
+            raise MacroEvalError(f"Macro '{name}' does not exist.")
 
         try:
             return call_macro(
                 func, self.dialect, self._path, provided_args=(self, *args), provided_kwargs=kwargs
             )  # type: ignore
         except Exception as e:
-            print_exception(e, self.python_env)
-            raise MacroEvalError("Error trying to eval macro.") from e
+            raise MacroEvalError(
+                f"An error occurred during evaluation of '{name}'\n\n"
+                + format_evaluated_code_exception(e, self.python_env)
+            )
 
     def transform(
         self, expression: exp.Expression
@@ -371,10 +378,10 @@ class MacroEvaluator:
             code = self.generator.generate(node)
             return eval(code, self.env, self.locals)
         except Exception as e:
-            print_exception(e, self.python_env)
             raise MacroEvalError(
-                f"Error trying to eval macro.\n\nGenerated code: {code}\n\nOriginal sql: {node}"
-            ) from e
+                f"Error trying to eval macro.\n\nGenerated code: {code}\n\nOriginal sql: {node}\n\n"
+                + format_evaluated_code_exception(e, self.python_env)
+            )
 
     def parse_one(
         self, sql: str | exp.Expression, into: t.Optional[exp.IntoType] = None, **opts: t.Any
diff --git a/sqlmesh/core/renderer.py b/sqlmesh/core/renderer.py
index fee9dcb5..cc389dd8 100644
--- a/sqlmesh/core/renderer.py
+++ b/sqlmesh/core/renderer.py
@@ -228,7 +228,9 @@ class BaseExpressionRenderer:
             try:
                 macro_evaluator.evaluate(definition)
             except Exception as ex:
-                raise_config_error(f"Failed to evaluate macro '{definition}'. {ex}", self._path)
+                raise_config_error(
+                    f"Failed to evaluate macro '{definition}'.\n\n{ex}\n", self._path
+                )
 
         resolved_expressions: t.List[t.Optional[exp.Expression]] = []
 
@@ -237,7 +239,7 @@ class BaseExpressionRenderer:
                 transformed_expressions = ensure_list(macro_evaluator.transform(expression))
             except Exception as ex:
                 raise_config_error(
-                    f"Failed to resolve macros for\n{expression.sql(dialect=self._dialect, pretty=True)}\n{ex}",
+                    f"Failed to resolve macros for\n\n{expression.sql(dialect=self._dialect, pretty=True)}\n\n{ex}\n",
                     self._path,
                 )
 
diff --git a/sqlmesh/utils/metaprogramming.py b/sqlmesh/utils/metaprogramming.py
index b3838992..c426ff19 100644
--- a/sqlmesh/utils/metaprogramming.py
+++ b/sqlmesh/utils/metaprogramming.py
@@ -554,12 +554,16 @@ def format_evaluated_code_exception(
     tb: t.List[str] = []
     indent = ""
 
+    skip_patterns = re.compile(
+        r"Traceback \(most recent call last\):|"
+        r'File ".*?core/model/definition\.py|'
+        r'File ".*?core/snapshot/definition\.py|'
+        r'File ".*?core/macros\.py|'
+        r'File ".*?inspect\.py'
+    )
+
     for error_line in format_exception(exception):
-        traceback_match = error_line.startswith("Traceback (most recent call last):")
-        model_def_match = re.search('File ".*?core/model/definition.py', error_line)
-        snapshot_def_match = re.search('File ".*?core/snapshot/definition.py', error_line)
-        core_macros_match = re.search('File ".*?core/macros.py', error_line)
-        if traceback_match or model_def_match or snapshot_def_match or core_macros_match:
+        if skip_patterns.search(error_line):
             continue
 
         error_match = re.search("^.*?Error: ", error_line)


Test patch:
diff --git a/tests/core/test_macros.py b/tests/core/test_macros.py
index 23fbce51..5b79d5ff 100644
--- a/tests/core/test_macros.py
+++ b/tests/core/test_macros.py
@@ -672,36 +672,26 @@ def test_positional_follows_kwargs(macro_evaluator):
 
 
 def test_macro_parameter_resolution(macro_evaluator):
-    with pytest.raises(MacroEvalError) as e:
+    with pytest.raises(MacroEvalError, match=".*missing a required argument: 'pos_only'"):
         macro_evaluator.evaluate(parse_one("@test_arg_resolution()"))
-    assert str(e.value.__cause__) == "missing a required argument: 'pos_only'"
 
-    with pytest.raises(MacroEvalError) as e:
+    with pytest.raises(MacroEvalError, match=".*missing a required argument: 'pos_only'"):
         macro_evaluator.evaluate(parse_one("@test_arg_resolution(a1 := 1)"))
-    assert str(e.value.__cause__) == "missing a required argument: 'pos_only'"
 
-    with pytest.raises(MacroEvalError) as e:
+    with pytest.raises(MacroEvalError, match=".*missing a required argument: 'a1'"):
         macro_evaluator.evaluate(parse_one("@test_arg_resolution(1)"))
-    assert str(e.value.__cause__) == "missing a required argument: 'a1'"
 
-    with pytest.raises(MacroEvalError) as e:
+    with pytest.raises(MacroEvalError, match=".*missing a required argument: 'a1'"):
         macro_evaluator.evaluate(parse_one("@test_arg_resolution(1, a2 := 2)"))
-    assert str(e.value.__cause__) == "missing a required argument: 'a1'"
 
-    with pytest.raises(MacroEvalError) as e:
+    with pytest.raises(
+        MacroEvalError,
+        match=".*'pos_only' parameter is positional only, but was passed as a keyword|.*missing a required positional-only argument: 'pos_only'|.*missing a required argument: 'a1'",
+    ):
         macro_evaluator.evaluate(parse_one("@test_arg_resolution(pos_only := 1)"))
 
-    # The CI was failing for Python 3.12 with the latter message, but other versions fail
-    # with the former one. This ensures we capture both.
-    assert str(e.value.__cause__) in (
-        "'pos_only' parameter is positional only, but was passed as a keyword",
-        "missing a required positional-only argument: 'pos_only'",
-        "missing a required argument: 'a1'",
-    )
-
-    with pytest.raises(MacroEvalError) as e:
+    with pytest.raises(MacroEvalError, match=".*too many positional arguments"):
         macro_evaluator.evaluate(parse_one("@test_arg_resolution(1, 2, 3)"))
-    assert str(e.value.__cause__) == "too many positional arguments"
 
 
 def test_macro_metadata_flag():
@@ -808,28 +798,25 @@ def test_deduplicate(assert_exp_eq, dialect, sql, expected_sql):
 
 def test_deduplicate_error_handling(macro_evaluator):
     # Test error handling: non-list partition_by
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        SQLMeshError,
+        match="partition_by must be a list of columns: \\[<column>, cast\\(<column> as <type>\\)\\]",
+    ):
         macro_evaluator.evaluate(parse_one("@deduplicate(my_table, user_id, ['timestamp DESC'])"))
-    assert (
-        str(e.value.__cause__)
-        == "partition_by must be a list of columns: [<column>, cast(<column> as <type>)]"
-    )
 
     # Test error handling: non-list order_by
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        SQLMeshError,
+        match="order_by must be a list of strings, optional - nulls ordering: \\['<column> <asc|desc> nulls <first|last>'\\]",
+    ):
         macro_evaluator.evaluate(parse_one("@deduplicate(my_table, [user_id], 'timestamp DESC')"))
-    assert (
-        str(e.value.__cause__)
-        == "order_by must be a list of strings, optional - nulls ordering: ['<column> <asc|desc> nulls <first|last>']"
-    )
 
     # Test error handling: empty order_by
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        SQLMeshError,
+        match="order_by must be a list of strings, optional - nulls ordering: \\['<column> <asc|desc> nulls <first|last>'\\]",
+    ):
         macro_evaluator.evaluate(parse_one("@deduplicate(my_table, [user_id], [])"))
-    assert (
-        str(e.value.__cause__)
-        == "order_by must be a list of strings, optional - nulls ordering: ['<column> <asc|desc> nulls <first|last>']"
-    )
 
 
 @pytest.mark.parametrize(
@@ -991,34 +978,32 @@ def test_date_spine(assert_exp_eq, dialect, date_part):
 
 def test_date_spine_error_handling(macro_evaluator):
     # Test error handling: invalid datepart
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        MacroEvalError,
+        match=".*Invalid datepart 'invalid'. Expected: 'day', 'week', 'month', 'quarter', or 'year'",
+    ):
         macro_evaluator.evaluate(parse_one("@date_spine('invalid', '2022-01-01', '2024-12-31')"))
-    assert (
-        str(e.value.__cause__)
-        == "Invalid datepart 'invalid'. Expected: 'day', 'week', 'month', 'quarter', or 'year'"
-    )
 
     # Test error handling: invalid start_date format
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        MacroEvalError,
+        match=".*Invalid date format - start_date and end_date must be in format: YYYY-MM-DD",
+    ):
         macro_evaluator.evaluate(parse_one("@date_spine('day', '2022/01/01', '2024-12-31')"))
-    assert str(e.value.__cause__).startswith(
-        "Invalid date format - start_date and end_date must be in format: YYYY-MM-DD"
-    )
 
     # Test error handling: invalid end_date format
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        MacroEvalError,
+        match=".*Invalid date format - start_date and end_date must be in format: YYYY-MM-DD",
+    ):
         macro_evaluator.evaluate(parse_one("@date_spine('day', '2022-01-01', '2024/12/31')"))
-    assert str(e.value.__cause__).startswith(
-        "Invalid date format - start_date and end_date must be in format: YYYY-MM-DD"
-    )
 
     # Test error handling: start_date after end_date
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(
+        MacroEvalError,
+        match=".*Invalid date range - start_date '2024-12-31' is after end_date '2022-01-01'.",
+    ):
         macro_evaluator.evaluate(parse_one("@date_spine('day', '2024-12-31', '2022-01-01')"))
-    assert (
-        str(e.value.__cause__)
-        == "Invalid date range - start_date '2024-12-31' is after end_date '2022-01-01'."
-    )
 
 
 def test_macro_union(assert_exp_eq, macro_evaluator: MacroEvaluator):
@@ -1044,11 +1029,9 @@ def test_resolve_template_literal():
     # Creating
     # This macro can work during creating / evaluating but only if @this_model is present in the context
     evaluator = MacroEvaluator(runtime_stage=RuntimeStage.CREATING)
-    with pytest.raises(SQLMeshError) as e:
+    with pytest.raises(MacroEvalError, match=".*this_model must be present"):
         evaluator.transform(parsed_sql)
 
-    assert "this_model must be present" in str(e.value.__cause__)
-
     evaluator.locals.update(
         {"this_model": exp.to_table("test_catalog.sqlmesh__test.test__test_model__2517971505")}
     )