Skip to content

Commit a6f1787

Browse files
committed
Chore: Improve plan error handling when --select-model is used and remote snapshot cant be rendered
1 parent d11fcdd commit a6f1787

3 files changed

Lines changed: 251 additions & 15 deletions

File tree

sqlmesh/core/context.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,12 +1450,18 @@ def plan_builder(
14501450

14511451
models_override: t.Optional[UniqueKeyDict[str, Model]] = None
14521452
if select_models:
1453-
models_override = model_selector.select_models(
1454-
select_models,
1455-
environment,
1456-
fallback_env_name=create_from or c.PROD,
1457-
ensure_finalized_snapshots=self.config.plan.use_finalized_state,
1458-
)
1453+
try:
1454+
models_override = model_selector.select_models(
1455+
select_models,
1456+
environment,
1457+
fallback_env_name=create_from or c.PROD,
1458+
ensure_finalized_snapshots=self.config.plan.use_finalized_state,
1459+
)
1460+
except SQLMeshError as e:
1461+
logger.exception(e) # ensure the full stack trace is logged
1462+
raise PlanError(
1463+
f"{e}\nCheck the SQLMesh log file for the full stack trace.\nIf the model has been fixed locally, please ensure that the --select-model expression includes it."
1464+
)
14591465
if not backfill_models:
14601466
# Only backfill selected models unless explicitly specified.
14611467
backfill_models = model_selector.expand_model_selections(select_models)

sqlmesh/core/selector.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from sqlmesh.utils import UniqueKeyDict
1717
from sqlmesh.utils.dag import DAG
1818
from sqlmesh.utils.git import GitClient
19+
from sqlmesh.utils.errors import SQLMeshError
1920

2021

2122
if t.TYPE_CHECKING:
@@ -111,7 +112,16 @@ def select_models(
111112
def get_model(fqn: str) -> t.Optional[Model]:
112113
if fqn not in all_selected_models and fqn in env_models:
113114
# Unselected modified or added model.
114-
return env_models[fqn]
115+
model_from_env = env_models[fqn]
116+
try:
117+
# this triggers a render_query() which can throw an exception
118+
model_from_env.depends_on
119+
return model_from_env
120+
except Exception as e:
121+
raise SQLMeshError(
122+
f"Model '{model_from_env.name}' sourced from state cannot be rendered "
123+
f"in the local environment due to:\n> {str(e)}"
124+
) from e
115125
if fqn in all_selected_models and fqn in self._models:
116126
# Selected modified or removed model.
117127
return self._models[fqn]

tests/cli/test_integration_cli.py

Lines changed: 228 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,8 @@ def _invoke(sqlmesh_args: t.List[str], **kwargs: t.Any) -> subprocess.CompletedP
4040
return _invoke
4141

4242

43-
def test_load_snapshots_that_reference_nonexistent_python_libraries(
44-
invoke_cli: InvokeCliType, tmp_path: Path
45-
) -> None:
43+
@pytest.fixture
44+
def duckdb_example_project(tmp_path: Path) -> Path:
4645
init_example_project(tmp_path, dialect="duckdb")
4746
config_path = tmp_path / "config.yaml"
4847

@@ -54,6 +53,36 @@ def test_load_snapshots_that_reference_nonexistent_python_libraries(
5453
}
5554
config_path.write_text(yaml.dump(config_dict))
5655

56+
return tmp_path
57+
58+
59+
@pytest.fixture
60+
def last_log_file_contents(tmp_path: Path) -> t.Callable[[], str]:
61+
def _fetch() -> str:
62+
log_file = sorted(list((tmp_path / "logs").iterdir()))[-1]
63+
return log_file.read_text()
64+
65+
return _fetch
66+
67+
68+
def test_load_snapshots_that_reference_nonexistent_python_libraries(
69+
invoke_cli: InvokeCliType,
70+
duckdb_example_project: Path,
71+
last_log_file_contents: t.Callable[[], str],
72+
) -> None:
73+
"""
74+
Scenario:
75+
- A model is created using a macro that is imported from an external package
76+
- That model is applied + snapshot committed to state
77+
- The external package is removed locally and the import macro import is changed to an inline definition
78+
79+
Outcome:
80+
- `sqlmesh plan` should not exit with an ImportError when it tries to render the query of the snapshot stored in state
81+
- Instead, it should log a warning and proceed with applying the new model version
82+
"""
83+
84+
project_path = duckdb_example_project
85+
5786
# simulate a 3rd party library that provides a macro
5887
site_packages = site.getsitepackages()[0]
5988
sqlmesh_test_macros_package_path = Path(site_packages) / "sqlmesh_test_macros"
@@ -67,11 +96,11 @@ def do_something(evaluator):
6796
""")
6897

6998
# reference the macro from site-packages
70-
(tmp_path / "macros" / "__init__.py").write_text("""
99+
(project_path / "macros" / "__init__.py").write_text("""
71100
from sqlmesh_test_macros.macros import do_something
72101
""")
73102

74-
(tmp_path / "models" / "example.sql").write_text("""
103+
(project_path / "models" / "example.sql").write_text("""
75104
MODEL (
76105
name example.test_model,
77106
kind FULL
@@ -99,7 +128,7 @@ def do_something(evaluator):
99128
shutil.rmtree(sqlmesh_test_macros_package_path)
100129

101130
# Move the macro inline so its no longer being loaded from a library but still exists with the same signature
102-
(tmp_path / "macros" / "__init__.py").write_text("""
131+
(project_path / "macros" / "__init__.py").write_text("""
103132
from sqlmesh import macro
104133
105134
@macro()
@@ -120,8 +149,7 @@ def do_something(evaluator):
120149
assert "Physical layer updated" in result.stdout
121150
assert "Virtual layer updated" in result.stdout
122151

123-
log_file = sorted(list((tmp_path / "logs").iterdir()))[-1]
124-
log_file_contents = log_file.read_text()
152+
log_file_contents = last_log_file_contents()
125153
assert "ModuleNotFoundError: No module named 'sqlmesh_test_macros'" in log_file_contents
126154
assert (
127155
"ERROR - Failed to cache optimized query for model 'example.test_model'"
@@ -131,3 +159,195 @@ def do_something(evaluator):
131159
'ERROR - Failed to cache snapshot SnapshotId<"db"."example"."test_model"'
132160
in log_file_contents
133161
)
162+
163+
164+
def test_model_selector_snapshot_references_nonexistent_python_libraries(
165+
invoke_cli: InvokeCliType,
166+
duckdb_example_project: Path,
167+
last_log_file_contents: t.Callable[[], str],
168+
) -> None:
169+
"""
170+
Scenario:
171+
- A model is created using a macro that is imported from an external package
172+
- That model is applied + snapshot committed to state
173+
- The external package is removed locally and the import macro import is changed to an inline definition
174+
- Thus, local version of the model can be rendered but the remote version in state cannot
175+
176+
Outcome:
177+
- `sqlmesh plan --select-model <this model>` should work as it picks up the local version
178+
- `sqlmesh plan --select-model <some other model> should exit with an error, because the plan needs a valid DAG and the remote version is no longer valid locally
179+
"""
180+
project_path = duckdb_example_project
181+
182+
# simulate a 3rd party library that provides a macro
183+
site_packages = site.getsitepackages()[0]
184+
sqlmesh_test_macros_package_path = Path(site_packages) / "sqlmesh_test_macros"
185+
sqlmesh_test_macros_package_path.mkdir()
186+
(sqlmesh_test_macros_package_path / "macros.py").write_text("""
187+
from sqlmesh import macro
188+
189+
@macro()
190+
def do_something(evaluator):
191+
return "'value from site-packages'"
192+
""")
193+
194+
# reference the macro from site-packages
195+
(project_path / "macros" / "__init__.py").write_text("""
196+
from sqlmesh_test_macros.macros import do_something
197+
""")
198+
199+
(project_path / "models" / "example.sql").write_text("""
200+
MODEL (
201+
name sqlmesh_example.test_model,
202+
kind FULL
203+
);
204+
205+
select @do_something() as a
206+
""")
207+
208+
result = invoke_cli(["plan", "--no-prompts", "--auto-apply", "--skip-tests"])
209+
210+
assert result.returncode == 0
211+
assert "Physical layer updated" in result.stdout
212+
assert "Virtual layer updated" in result.stdout
213+
214+
# clear cache to ensure we are forced to reload everything
215+
assert invoke_cli(["clean"]).returncode == 0
216+
217+
# deleting this removes the 'do_something()' macro used by the version of the snapshot stored in state
218+
# when loading the old snapshot from state in the local python env, this will create an ImportError
219+
shutil.rmtree(sqlmesh_test_macros_package_path)
220+
221+
# Move the macro inline so its no longer being loaded from a library but still exists with the same signature
222+
(project_path / "macros" / "__init__.py").write_text("""
223+
from sqlmesh import macro
224+
225+
@macro()
226+
def do_something(evaluator):
227+
return "'some value not from site-packages'"
228+
""")
229+
230+
# the invalid snapshot is in state but is not preventing a plan
231+
result = invoke_cli(
232+
[
233+
"plan",
234+
"--no-prompts",
235+
"--skip-tests",
236+
],
237+
input="n", # for the apply backfill (y/n) prompt
238+
)
239+
assert result.returncode == 0
240+
assert "Apply - Backfill Tables [y/n]:" in result.stdout
241+
assert "Physical layer updated" not in result.stdout
242+
243+
# the invalid snapshot in state should not prevent a plan if --select-model is used on it (since the local version can be rendered)
244+
result = invoke_cli(
245+
["plan", "--select-model", "sqlmesh_example.test_model", "--no-prompts", "--skip-tests"],
246+
input="n", # for the apply backfill (y/n) prompt
247+
)
248+
assert result.returncode == 0
249+
assert "ModuleNotFoundError" not in result.stdout
250+
assert "sqlmesh_example.test_model" in result.stdout
251+
assert "Apply - Backfill Tables" in result.stdout
252+
253+
# the invalid snapshot in state should prevent a plan if --select-model is used on another model
254+
# (since this says to SQLMesh "source everything from state except this selected model" and the plan DAG must be valid to run the plan)
255+
result = invoke_cli(
256+
[
257+
"plan",
258+
"--select-model",
259+
"sqlmesh_example.full_model",
260+
"--no-prompts",
261+
"--skip-tests",
262+
],
263+
input="n", # for the apply backfill (y/n) prompt
264+
)
265+
assert result.returncode == 1
266+
assert (
267+
"Model 'sqlmesh_example.test_model' sourced from state cannot be rendered in the local environment"
268+
in result.stdout
269+
)
270+
assert "No module named 'sqlmesh_test_macros'" in result.stdout
271+
assert (
272+
"If the model has been fixed locally, please ensure that the --select-model expression includes it"
273+
in result.stdout
274+
)
275+
276+
# verify the full stack trace was logged
277+
log_file_contents = last_log_file_contents()
278+
assert "ModuleNotFoundError: No module named 'sqlmesh_test_macros'" in log_file_contents
279+
assert (
280+
"The above exception was the direct cause of the following exception:" in log_file_contents
281+
)
282+
283+
284+
def test_model_selector_tags_picks_up_both_remote_and_local(
285+
invoke_cli: InvokeCliType, duckdb_example_project: Path
286+
) -> None:
287+
"""
288+
Scenario:
289+
- A model that has already been applied to prod (so exists in state) has a tag added locally
290+
- A new model is created locally that has the same tag
291+
292+
Outcome:
293+
- `sqlmesh plan --select-model tag:<tag>` should include both models
294+
"""
295+
project_path = duckdb_example_project
296+
297+
# default state of full_model
298+
(project_path / "models" / "full_model.sql").write_text("""
299+
MODEL (
300+
name sqlmesh_example.full_model,
301+
kind FULL,
302+
cron '@daily',
303+
grain item_id,
304+
audits (assert_positive_order_ids),
305+
);
306+
307+
SELECT
308+
item_id,
309+
COUNT(DISTINCT id) AS num_orders
310+
FROM sqlmesh_example.incremental_model
311+
GROUP BY item_id
312+
""")
313+
314+
# apply plan - starting point
315+
result = invoke_cli(["plan", "--no-prompts", "--auto-apply", "--skip-tests"])
316+
317+
assert result.returncode == 0
318+
assert "Physical layer updated" in result.stdout
319+
assert "Virtual layer updated" in result.stdout
320+
321+
# add a new model locally with tag:a
322+
(project_path / "models" / "new_model.sql").write_text("""
323+
MODEL (
324+
name sqlmesh_example.new_model,
325+
kind full,
326+
tags (a)
327+
);
328+
329+
SELECT 1;
330+
""")
331+
332+
# update full_model with tag:a
333+
(project_path / "models" / "full_model.sql").write_text("""
334+
MODEL (
335+
name sqlmesh_example.full_model,
336+
kind FULL,
337+
tags (a)
338+
);
339+
340+
SELECT
341+
item_id,
342+
COUNT(DISTINCT id) AS num_orders
343+
FROM sqlmesh_example.incremental_model
344+
GROUP BY item_id
345+
""")
346+
347+
result = invoke_cli(
348+
["plan", "--select-model", "tag:a", "--no-prompts", "--skip-tests"],
349+
input="n", # for the apply backfill (y/n) prompt
350+
)
351+
assert result.returncode == 0
352+
assert "sqlmesh_example.full_model" in result.stdout # metadata update: tags
353+
assert "sqlmesh_example.new_model" in result.stdout # added

0 commit comments

Comments
 (0)