Skip to content

Commit 699c93f

Browse files
authored
Fix!: Move linting from load to plan creation (#4032)
1 parent 213e010 commit 699c93f

9 files changed

Lines changed: 77 additions & 36 deletions

File tree

docs/guides/linter.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Linting is a powerful tool for improving code quality and consistency. It enables you to automatically validate model definition, ensuring they adhere to your team's best practices.
66

7-
When a SQLMesh command is executed and the project is loaded, each model's code is checked for compliance with a set of rules you choose.
7+
When a SQLMesh plan is created, each model's code is checked for compliance with a set of rules you choose.
88

99
SQLMesh provides built-in rules, and you can define custom rules. This improves code quality and helps detect issues early in the development cycle when they are simpler to debug.
1010

sqlmesh/cli/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,11 @@ def diff(ctx: click.Context, environment: t.Optional[str] = None) -> None:
339339
is_flag=True,
340340
help="Skip tests prior to generating the plan if they are defined.",
341341
)
342+
@click.option(
343+
"--skip-linter",
344+
is_flag=True,
345+
help="Skip linting prior to generating the plan if the linter is enabled.",
346+
)
342347
@click.option(
343348
"--restate-model",
344349
"-r",

sqlmesh/core/console.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2194,6 +2194,16 @@ def log_failed_models(self, errors: t.List[NodeExecutionFailedError]) -> None:
21942194

21952195
self._print("```\n")
21962196

2197+
def show_linter_violations(
2198+
self, violations: t.List[RuleViolation], model: Model, is_error: bool = False
2199+
) -> None:
2200+
severity = "**errors**" if is_error else "warnings"
2201+
violations_msg = "\n".join(f" - {violation}" for violation in violations)
2202+
msg = f"\nLinter {severity} for `{model._path}`:\n{violations_msg}\n"
2203+
2204+
self._print(msg)
2205+
self._errors.append(msg)
2206+
21972207
def log_error(self, message: str) -> None:
21982208
super().log_error(f"```\n\\[ERROR] {message}```\n\n")
21992209

sqlmesh/core/context.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -501,8 +501,6 @@ def upsert_model(self, model: t.Union[str, Model], **kwargs: t.Any) -> Model:
501501

502502
model.validate_definition()
503503

504-
self.lint_models(model)
505-
506504
return model
507505

508506
def scheduler(self, environment: t.Optional[str] = None) -> Scheduler:
@@ -640,8 +638,6 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
640638
# The model definition can be validated correctly only after the schema is set.
641639
model.validate_definition()
642640

643-
self.lint_models(*models)
644-
645641
duplicates = set(self._models) & set(self._standalone_audits)
646642
if duplicates:
647643
raise ConfigError(
@@ -1139,6 +1135,7 @@ def plan(
11391135
no_diff: t.Optional[bool] = None,
11401136
run: bool = False,
11411137
diff_rendered: bool = False,
1138+
skip_linter: bool = False,
11421139
) -> Plan:
11431140
"""Interactively creates a plan.
11441141
@@ -1183,6 +1180,7 @@ def plan(
11831180
no_diff: Hide text differences for changed models.
11841181
run: Whether to run latest intervals as part of the plan application.
11851182
diff_rendered: Whether the diff should compare raw vs rendered models
1183+
skip_linter: Linter runs by default so this will skip it if enabled
11861184
11871185
Returns:
11881186
The populated Plan object.
@@ -1209,6 +1207,7 @@ def plan(
12091207
enable_preview=enable_preview,
12101208
run=run,
12111209
diff_rendered=diff_rendered,
1210+
skip_linter=skip_linter,
12121211
)
12131212

12141213
if no_auto_categorization:
@@ -1250,6 +1249,7 @@ def plan_builder(
12501249
enable_preview: t.Optional[bool] = None,
12511250
run: bool = False,
12521251
diff_rendered: bool = False,
1252+
skip_linter: bool = False,
12531253
) -> PlanBuilder:
12541254
"""Creates a plan builder.
12551255
@@ -1305,6 +1305,9 @@ def plan_builder(
13051305
if run and is_dev:
13061306
raise ConfigError("The '--run' flag is only supported for the production environment.")
13071307

1308+
if not skip_linter:
1309+
self.lint_models(*self.models.values())
1310+
13081311
self._run_plan_tests(skip_tests=skip_tests)
13091312

13101313
environment_ttl = (

sqlmesh/magics.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,11 @@ def test(self, context: Context, line: str, test_def_raw: t.Optional[str] = None
343343
action="store_true",
344344
help="Skip the unit tests defined for the model.",
345345
)
346+
@argument(
347+
"--skip-linter",
348+
action="store_true",
349+
help="Skip the linter for the model.",
350+
)
346351
@argument(
347352
"--restate-model",
348353
"-r",

tests/cli/test_cli.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,29 @@ def test_plan_skip_tests(runner, tmp_path):
198198
assert_backfill_success(result)
199199

200200

201+
def test_plan_skip_linter(runner, tmp_path):
202+
create_example_project(tmp_path)
203+
204+
with open(tmp_path / "config.yaml", "a", encoding="utf-8") as f:
205+
f.write(
206+
"""linter:
207+
enabled: True
208+
rules: "ALL"
209+
"""
210+
)
211+
212+
# Successful test run message should not appear with `--skip-tests`
213+
# Input: `y` to apply and backfill
214+
result = runner.invoke(
215+
cli, ["--log-file-dir", tmp_path, "--paths", tmp_path, "plan", "--skip-linter"], input="y\n"
216+
)
217+
218+
assert result.exit_code == 0
219+
assert "Linter warnings" not in result.output
220+
assert_new_env(result)
221+
assert_backfill_success(result)
222+
223+
201224
def test_plan_restate_model(runner, tmp_path):
202225
create_example_project(tmp_path)
203226
init_prod_and_backfill(runner, tmp_path)

tests/core/test_context.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,10 +1698,12 @@ def assert_cached_violations_exist(cache: OptimizedQueryCache, model: Model):
16981698
for query in ("SELECT * FROM tbl", "SELECT t.* FROM tbl"):
16991699
with pytest.raises(LinterError, match=config_err):
17001700
ctx.upsert_model(load_sql_based_model(d.parse(f"MODEL (name test); {query}")))
1701+
ctx.plan(environment="dev", auto_apply=True, no_prompts=True)
17011702

1702-
error_model = load_sql_based_model(d.parse("MODEL (name test); SELECT col"))
1703+
error_model = load_sql_based_model(d.parse("MODEL (name test); SELECT * FROM (SELECT 1)"))
17031704
with pytest.raises(LinterError, match=config_err):
17041705
ctx.upsert_model(error_model)
1706+
ctx.plan_builder("dev")
17051707

17061708
# Case: Ensure error violations are cached if the model did not pass linting
17071709
cache = OptimizedQueryCache(tmp_path / c.CACHE)
@@ -1731,8 +1733,10 @@ def assert_cached_violations_exist(cache: OptimizedQueryCache, model: Model):
17311733
assert_cached_violations_exist(cache, model2)
17321734

17331735
ctx.upsert_model(error_model)
1736+
ctx.plan(environment="dev", auto_apply=True, no_prompts=True)
1737+
17341738
assert (
1735-
"""Column '"col"' could not be resolved for model '"test"'"""
1739+
"""noselectstar: Query should not contain SELECT * on its outer most projections"""
17361740
in mock_logger.call_args[0][0]
17371741
)
17381742

@@ -1779,10 +1783,11 @@ def assert_cached_violations_exist(cache: OptimizedQueryCache, model: Model):
17791783
"MODEL(name test2, audits (at_least_one(column := col)), ignored_rules ['noselectstar']); SELECT * FROM (SELECT 1 AS col);",
17801784
)
17811785

1782-
ctx.load()
1786+
ctx.plan(environment="dev", auto_apply=True, no_prompts=True)
17831787

17841788
# Case: Ensure we can load & use the user-defined rules
17851789
sushi_context.config.linter = LinterConfig(enabled=True, rules=["aLl"])
1790+
sushi_context.load()
17861791
sushi_context.upsert_model(
17871792
load_sql_based_model(
17881793
d.parse("MODEL (name sushi.test); SELECT col FROM (SELECT * FROM tbl)"),
@@ -1791,7 +1796,7 @@ def assert_cached_violations_exist(cache: OptimizedQueryCache, model: Model):
17911796
)
17921797

17931798
with pytest.raises(LinterError, match=config_err):
1794-
sushi_context.load()
1799+
sushi_context.plan_builder(environment="dev")
17951800

17961801
# Case: Ensure the Linter also picks up Python model violations
17971802
@model(name="memory.sushi.model3", is_sql=True, kind="full", dialect="snowflake")
@@ -1813,21 +1818,7 @@ def model4_entrypoint(context, **kwargs):
18131818
for python_model in (model3, model4):
18141819
with pytest.raises(LinterError, match=config_err):
18151820
sushi_context.upsert_model(python_model)
1816-
1817-
@model(
1818-
name="memory.sushi.model5",
1819-
columns={"col": "int"},
1820-
owner="test",
1821-
audits=[("at_least_one", {"column": "col"})],
1822-
)
1823-
def model5_entrypoint(context, **kwargs):
1824-
yield pd.DataFrame({"col": []})
1825-
1826-
model5 = model.get_registry()["memory.sushi.model5"].model(
1827-
module_path=Path("."), path=Path(".")
1828-
)
1829-
1830-
sushi_context.upsert_model(model5)
1821+
sushi_context.plan(environment="dev", auto_apply=True, no_prompts=True)
18311822

18321823

18331824
def test_plan_selector_expression_no_match(sushi_context: Context) -> None:

tests/core/test_integration.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4389,12 +4389,10 @@ def test_auto_categorization(sushi_context: Context):
43894389

43904390
@use_terminal_console
43914391
def test_multi(mocker):
4392-
context = Context(
4393-
paths=["examples/multi/repo_1", "examples/multi/repo_2"], gateway="memory", load=False
4394-
)
4392+
context = Context(paths=["examples/multi/repo_1", "examples/multi/repo_2"], gateway="memory")
43954393

43964394
with patch.object(get_console(), "log_warning") as mock_logger:
4397-
context.load()
4395+
context.plan_builder(environment="dev")
43984396
warnings = mock_logger.call_args[0][0]
43994397
repo1_path, repo2_path = context.configs.keys()
44004398
assert f"Linter warnings for {repo1_path}" in warnings

tests/core/test_model.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ def test_model_qualification(tmp_path: Path):
301301
config=Config(linter=LinterConfig(enabled=True, warn_rules=["ALL"])), paths=tmp_path
302302
)
303303
ctx.upsert_model(load_sql_based_model(expressions))
304+
ctx.plan_builder("dev")
304305

305306
assert (
306307
"""Column '"a"' could not be resolved for model '"db"."table"', the column may not exist or is ambiguous."""
@@ -327,6 +328,7 @@ def test_model_missing_audits(tmp_path: Path):
327328
paths=tmp_path,
328329
)
329330
ctx.upsert_model(load_sql_based_model(expressions))
331+
ctx.plan_builder("plan")
330332

331333
assert (
332334
"""Model `audits` must be configured to test data quality."""
@@ -2841,6 +2843,7 @@ def test_update_schema(tmp_path: Path):
28412843
)
28422844
with patch.object(get_console(), "log_warning") as mock_logger:
28432845
ctx.upsert_model(model)
2846+
ctx.plan_builder("dev")
28442847
assert (
28452848
missing_schema_warning_msg('"db"."table"', ('"table_b"',))
28462849
in mock_logger.call_args[0][0]
@@ -2895,13 +2898,14 @@ def test_missing_schema_warnings(tmp_path: Path):
28952898
model = load_sql_based_model(d.parse("MODEL (name test); SELECT * FROM a CROSS JOIN b"))
28962899
model.update_schema(partial_schema)
28972900
ctx.upsert_model(model)
2898-
2901+
ctx.plan_builder("dev")
28992902
assert missing_schema_warning_msg('"test"', ('"b"',)) in mock_logger.call_args[0][0]
29002903

29012904
# star, no schema
29022905
with patch.object(console, "log_warning") as mock_logger:
29032906
model = load_sql_based_model(d.parse("MODEL (name test); SELECT * FROM b JOIN a"))
29042907
ctx.upsert_model(model)
2908+
ctx.plan_builder("dev")
29052909
assert missing_schema_warning_msg('"test"', ('"a"', '"b"')) in mock_logger.call_args[0][0]
29062910

29072911
# no star, full schema
@@ -8062,16 +8066,18 @@ def model_with_virtual_statements(context, **kwargs):
80628066

80638067
def test_compile_time_checks(tmp_path: Path):
80648068
ctx = Context(
8065-
config=Config(model_defaults=ModelDefaultsConfig(dialect="duckdb")), paths=tmp_path
8069+
config=Config(
8070+
model_defaults=ModelDefaultsConfig(dialect="duckdb"),
8071+
linter=LinterConfig(
8072+
enabled=True, rules=["ambiguousorinvalidcolumn", "invalidselectstarexpansion"]
8073+
),
8074+
),
8075+
paths=tmp_path,
80668076
)
80678077

80688078
cfg_err = "Linter detected errors in the code. Please fix them before proceeding."
80698079

80708080
# Strict SELECT * expansion
8071-
linter_cfg = LinterConfig(
8072-
enabled=True, rules=["ambiguousorinvalidcolumn", "invalidselectstarexpansion"]
8073-
)
8074-
ctx.config.linter = linter_cfg
80758081
strict_query = d.parse(
80768082
"""
80778083
MODEL (
@@ -8082,10 +8088,9 @@ def test_compile_time_checks(tmp_path: Path):
80828088
"""
80838089
)
80848090

8085-
ctx.load()
8086-
80878091
with pytest.raises(LinterError, match=cfg_err):
80888092
ctx.upsert_model(load_sql_based_model(strict_query))
8093+
ctx.plan_builder("dev")
80898094

80908095
# Strict column resolution
80918096
strict_query = d.parse(
@@ -8100,6 +8105,7 @@ def test_compile_time_checks(tmp_path: Path):
81008105

81018106
with pytest.raises(LinterError, match=cfg_err):
81028107
ctx.upsert_model(load_sql_based_model(strict_query))
8108+
ctx.plan_builder("dev")
81038109

81048110

81058111
def test_partition_interval_unit():

0 commit comments

Comments
 (0)