Skip to content

Commit 1748e7d

Browse files
Fix: Add custom exception for macro evaluation errors (#4270)
1 parent 738da48 commit 1748e7d

4 files changed

Lines changed: 65 additions & 69 deletions

File tree

sqlmesh/core/macros.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@
4141
from sqlmesh.utils.date import DatetimeRanges, to_datetime, to_date
4242
from sqlmesh.utils.errors import MacroEvalError, SQLMeshError
4343
from sqlmesh.utils.jinja import JinjaMacroRegistry, has_jinja
44-
from sqlmesh.utils.metaprogramming import Executable, SqlValue, prepare_env, print_exception
44+
from sqlmesh.utils.metaprogramming import (
45+
Executable,
46+
SqlValue,
47+
format_evaluated_code_exception,
48+
prepare_env,
49+
)
4550

4651
if t.TYPE_CHECKING:
4752
from sqlglot.dialects.dialect import DialectType
@@ -220,15 +225,17 @@ def send(
220225
func = self.macros.get(normalize_macro_name(name))
221226

222227
if not callable(func):
223-
raise SQLMeshError(f"Macro '{name}' does not exist.")
228+
raise MacroEvalError(f"Macro '{name}' does not exist.")
224229

225230
try:
226231
return call_macro(
227232
func, self.dialect, self._path, provided_args=(self, *args), provided_kwargs=kwargs
228233
) # type: ignore
229234
except Exception as e:
230-
print_exception(e, self.python_env)
231-
raise MacroEvalError("Error trying to eval macro.") from e
235+
raise MacroEvalError(
236+
f"An error occurred during evaluation of '{name}'\n\n"
237+
+ format_evaluated_code_exception(e, self.python_env)
238+
)
232239

233240
def transform(
234241
self, expression: exp.Expression
@@ -371,10 +378,10 @@ def eval_expression(self, node: t.Any) -> t.Any:
371378
code = self.generator.generate(node)
372379
return eval(code, self.env, self.locals)
373380
except Exception as e:
374-
print_exception(e, self.python_env)
375381
raise MacroEvalError(
376-
f"Error trying to eval macro.\n\nGenerated code: {code}\n\nOriginal sql: {node}"
377-
) from e
382+
f"Error trying to eval macro.\n\nGenerated code: {code}\n\nOriginal sql: {node}\n\n"
383+
+ format_evaluated_code_exception(e, self.python_env)
384+
)
378385

379386
def parse_one(
380387
self, sql: str | exp.Expression, into: t.Optional[exp.IntoType] = None, **opts: t.Any

sqlmesh/core/renderer.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,9 @@ def _resolve_table(table: str | exp.Table) -> str:
228228
try:
229229
macro_evaluator.evaluate(definition)
230230
except Exception as ex:
231-
raise_config_error(f"Failed to evaluate macro '{definition}'. {ex}", self._path)
231+
raise_config_error(
232+
f"Failed to evaluate macro '{definition}'.\n\n{ex}\n", self._path
233+
)
232234

233235
resolved_expressions: t.List[t.Optional[exp.Expression]] = []
234236

@@ -237,7 +239,7 @@ def _resolve_table(table: str | exp.Table) -> str:
237239
transformed_expressions = ensure_list(macro_evaluator.transform(expression))
238240
except Exception as ex:
239241
raise_config_error(
240-
f"Failed to resolve macros for\n{expression.sql(dialect=self._dialect, pretty=True)}\n{ex}",
242+
f"Failed to resolve macros for\n\n{expression.sql(dialect=self._dialect, pretty=True)}\n\n{ex}\n",
241243
self._path,
242244
)
243245

sqlmesh/utils/metaprogramming.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -554,12 +554,16 @@ def format_evaluated_code_exception(
554554
tb: t.List[str] = []
555555
indent = ""
556556

557+
skip_patterns = re.compile(
558+
r"Traceback \(most recent call last\):|"
559+
r'File ".*?core/model/definition\.py|'
560+
r'File ".*?core/snapshot/definition\.py|'
561+
r'File ".*?core/macros\.py|'
562+
r'File ".*?inspect\.py'
563+
)
564+
557565
for error_line in format_exception(exception):
558-
traceback_match = error_line.startswith("Traceback (most recent call last):")
559-
model_def_match = re.search('File ".*?core/model/definition.py', error_line)
560-
snapshot_def_match = re.search('File ".*?core/snapshot/definition.py', error_line)
561-
core_macros_match = re.search('File ".*?core/macros.py', error_line)
562-
if traceback_match or model_def_match or snapshot_def_match or core_macros_match:
566+
if skip_patterns.search(error_line):
563567
continue
564568

565569
error_match = re.search("^.*?Error: ", error_line)

tests/core/test_macros.py

Lines changed: 38 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -672,36 +672,26 @@ def test_positional_follows_kwargs(macro_evaluator):
672672

673673

674674
def test_macro_parameter_resolution(macro_evaluator):
675-
with pytest.raises(MacroEvalError) as e:
675+
with pytest.raises(MacroEvalError, match=".*missing a required argument: 'pos_only'"):
676676
macro_evaluator.evaluate(parse_one("@test_arg_resolution()"))
677-
assert str(e.value.__cause__) == "missing a required argument: 'pos_only'"
678677

679-
with pytest.raises(MacroEvalError) as e:
678+
with pytest.raises(MacroEvalError, match=".*missing a required argument: 'pos_only'"):
680679
macro_evaluator.evaluate(parse_one("@test_arg_resolution(a1 := 1)"))
681-
assert str(e.value.__cause__) == "missing a required argument: 'pos_only'"
682680

683-
with pytest.raises(MacroEvalError) as e:
681+
with pytest.raises(MacroEvalError, match=".*missing a required argument: 'a1'"):
684682
macro_evaluator.evaluate(parse_one("@test_arg_resolution(1)"))
685-
assert str(e.value.__cause__) == "missing a required argument: 'a1'"
686683

687-
with pytest.raises(MacroEvalError) as e:
684+
with pytest.raises(MacroEvalError, match=".*missing a required argument: 'a1'"):
688685
macro_evaluator.evaluate(parse_one("@test_arg_resolution(1, a2 := 2)"))
689-
assert str(e.value.__cause__) == "missing a required argument: 'a1'"
690686

691-
with pytest.raises(MacroEvalError) as e:
687+
with pytest.raises(
688+
MacroEvalError,
689+
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'",
690+
):
692691
macro_evaluator.evaluate(parse_one("@test_arg_resolution(pos_only := 1)"))
693692

694-
# The CI was failing for Python 3.12 with the latter message, but other versions fail
695-
# with the former one. This ensures we capture both.
696-
assert str(e.value.__cause__) in (
697-
"'pos_only' parameter is positional only, but was passed as a keyword",
698-
"missing a required positional-only argument: 'pos_only'",
699-
"missing a required argument: 'a1'",
700-
)
701-
702-
with pytest.raises(MacroEvalError) as e:
693+
with pytest.raises(MacroEvalError, match=".*too many positional arguments"):
703694
macro_evaluator.evaluate(parse_one("@test_arg_resolution(1, 2, 3)"))
704-
assert str(e.value.__cause__) == "too many positional arguments"
705695

706696

707697
def test_macro_metadata_flag():
@@ -808,28 +798,25 @@ def test_deduplicate(assert_exp_eq, dialect, sql, expected_sql):
808798

809799
def test_deduplicate_error_handling(macro_evaluator):
810800
# Test error handling: non-list partition_by
811-
with pytest.raises(SQLMeshError) as e:
801+
with pytest.raises(
802+
SQLMeshError,
803+
match="partition_by must be a list of columns: \\[<column>, cast\\(<column> as <type>\\)\\]",
804+
):
812805
macro_evaluator.evaluate(parse_one("@deduplicate(my_table, user_id, ['timestamp DESC'])"))
813-
assert (
814-
str(e.value.__cause__)
815-
== "partition_by must be a list of columns: [<column>, cast(<column> as <type>)]"
816-
)
817806

818807
# Test error handling: non-list order_by
819-
with pytest.raises(SQLMeshError) as e:
808+
with pytest.raises(
809+
SQLMeshError,
810+
match="order_by must be a list of strings, optional - nulls ordering: \\['<column> <asc|desc> nulls <first|last>'\\]",
811+
):
820812
macro_evaluator.evaluate(parse_one("@deduplicate(my_table, [user_id], 'timestamp DESC')"))
821-
assert (
822-
str(e.value.__cause__)
823-
== "order_by must be a list of strings, optional - nulls ordering: ['<column> <asc|desc> nulls <first|last>']"
824-
)
825813

826814
# Test error handling: empty order_by
827-
with pytest.raises(SQLMeshError) as e:
815+
with pytest.raises(
816+
SQLMeshError,
817+
match="order_by must be a list of strings, optional - nulls ordering: \\['<column> <asc|desc> nulls <first|last>'\\]",
818+
):
828819
macro_evaluator.evaluate(parse_one("@deduplicate(my_table, [user_id], [])"))
829-
assert (
830-
str(e.value.__cause__)
831-
== "order_by must be a list of strings, optional - nulls ordering: ['<column> <asc|desc> nulls <first|last>']"
832-
)
833820

834821

835822
@pytest.mark.parametrize(
@@ -991,34 +978,32 @@ def test_date_spine(assert_exp_eq, dialect, date_part):
991978

992979
def test_date_spine_error_handling(macro_evaluator):
993980
# Test error handling: invalid datepart
994-
with pytest.raises(SQLMeshError) as e:
981+
with pytest.raises(
982+
MacroEvalError,
983+
match=".*Invalid datepart 'invalid'. Expected: 'day', 'week', 'month', 'quarter', or 'year'",
984+
):
995985
macro_evaluator.evaluate(parse_one("@date_spine('invalid', '2022-01-01', '2024-12-31')"))
996-
assert (
997-
str(e.value.__cause__)
998-
== "Invalid datepart 'invalid'. Expected: 'day', 'week', 'month', 'quarter', or 'year'"
999-
)
1000986

1001987
# Test error handling: invalid start_date format
1002-
with pytest.raises(SQLMeshError) as e:
988+
with pytest.raises(
989+
MacroEvalError,
990+
match=".*Invalid date format - start_date and end_date must be in format: YYYY-MM-DD",
991+
):
1003992
macro_evaluator.evaluate(parse_one("@date_spine('day', '2022/01/01', '2024-12-31')"))
1004-
assert str(e.value.__cause__).startswith(
1005-
"Invalid date format - start_date and end_date must be in format: YYYY-MM-DD"
1006-
)
1007993

1008994
# Test error handling: invalid end_date format
1009-
with pytest.raises(SQLMeshError) as e:
995+
with pytest.raises(
996+
MacroEvalError,
997+
match=".*Invalid date format - start_date and end_date must be in format: YYYY-MM-DD",
998+
):
1010999
macro_evaluator.evaluate(parse_one("@date_spine('day', '2022-01-01', '2024/12/31')"))
1011-
assert str(e.value.__cause__).startswith(
1012-
"Invalid date format - start_date and end_date must be in format: YYYY-MM-DD"
1013-
)
10141000

10151001
# Test error handling: start_date after end_date
1016-
with pytest.raises(SQLMeshError) as e:
1002+
with pytest.raises(
1003+
MacroEvalError,
1004+
match=".*Invalid date range - start_date '2024-12-31' is after end_date '2022-01-01'.",
1005+
):
10171006
macro_evaluator.evaluate(parse_one("@date_spine('day', '2024-12-31', '2022-01-01')"))
1018-
assert (
1019-
str(e.value.__cause__)
1020-
== "Invalid date range - start_date '2024-12-31' is after end_date '2022-01-01'."
1021-
)
10221007

10231008

10241009
def test_macro_union(assert_exp_eq, macro_evaluator: MacroEvaluator):
@@ -1044,11 +1029,9 @@ def test_resolve_template_literal():
10441029
# Creating
10451030
# This macro can work during creating / evaluating but only if @this_model is present in the context
10461031
evaluator = MacroEvaluator(runtime_stage=RuntimeStage.CREATING)
1047-
with pytest.raises(SQLMeshError) as e:
1032+
with pytest.raises(MacroEvalError, match=".*this_model must be present"):
10481033
evaluator.transform(parsed_sql)
10491034

1050-
assert "this_model must be present" in str(e.value.__cause__)
1051-
10521035
evaluator.locals.update(
10531036
{"this_model": exp.to_table("test_catalog.sqlmesh__test.test__test_model__2517971505")}
10541037
)

0 commit comments

Comments
 (0)