Skip to content
4 changes: 4 additions & 0 deletions sqlmesh/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"table_name",
)
SKIP_CONTEXT_COMMANDS = ("init", "ui")
LOCAL_ONLY_COMMANDS = ("format", "lint")


def _sqlmesh_version() -> str:
Expand Down Expand Up @@ -115,6 +116,8 @@ def cli(
configure_console(ignore_warnings=ignore_warnings)

load = True
# Outside the single-path block: applies regardless of --paths count.
load_state = ctx.invoked_subcommand not in LOCAL_ONLY_COMMANDS

if len(paths) == 1:
path = os.path.abspath(paths[0])
Expand All @@ -135,6 +138,7 @@ def cli(
config=configs,
gateway=gateway,
load=load,
load_state=load_state,
)
except Exception:
if debug:
Expand Down
7 changes: 5 additions & 2 deletions sqlmesh/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ class GenericContext(BaseContext, t.Generic[C]):
connection as it appears in configuration will be used.
concurrent_tasks: The maximum number of tasks that can use the connection concurrently.
load: Whether or not to automatically load all models and macros (default True).
load_state: Whether to merge remote state into the local project during load (default True).
console: The rich instance used for printing out CLI command results.
users: A list of users to make known to SQLMesh.
"""
Expand All @@ -386,6 +387,7 @@ def __init__(
users: t.Optional[t.List[User]] = None,
config_loader_kwargs: t.Optional[t.Dict[str, t.Any]] = None,
selector: t.Optional[t.Type[Selector]] = None,
load_state: bool = True,
):
self.configs = (
config
Expand Down Expand Up @@ -413,6 +415,7 @@ def __init__(
self._engine_adapter: t.Optional[EngineAdapter] = None
self._linters: t.Dict[str, Linter] = {}
self._loaded: bool = False
self._load_state: bool = load_state
self._selector_cls = selector or NativeSelector

self.path, self.config = t.cast(t.Tuple[Path, C], next(iter(self.configs.items())))
Expand Down Expand Up @@ -674,7 +677,7 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
)

# Load environment statements from state for projects not in current load
if any(self._projects):
if self._load_state and any(self._projects):
prod = self.state_reader.get_environment(c.PROD)
if prod:
existing_statements = self.state_reader.get_environment_statements(c.PROD)
Expand All @@ -684,7 +687,7 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:

uncached = set()

if any(self._projects):
if self._load_state and any(self._projects):
prod = self.state_reader.get_environment(c.PROD)

if prod:
Expand Down
97 changes: 97 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2237,3 +2237,100 @@ def test_format_leading_comma_default(runner: CliRunner, tmp_path: Path):
assert result.exit_code == 0
finally:
del os.environ["SQLMESH__FORMAT__LEADING_COMMA"]


def _setup_local_only_project(tmp_path, mocker):
create_example_project(tmp_path, template=ProjectTemplate.EMPTY)
config_path = tmp_path / "config.yaml"
existing = config_path.read_text(encoding="utf-8")
config_path.write_text("project: cli_test\n\n" + existing, encoding="utf-8")

(tmp_path / "models" / "example.sql").write_text(
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col\n",
encoding="utf-8",
)

return mocker.patch(
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
side_effect=RuntimeError("state should not be accessed"),
)


def test_format_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
mock = _setup_local_only_project(tmp_path, mocker)
result = runner.invoke(cli, ["--paths", str(tmp_path), "format"])
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
mock.assert_not_called()


def test_lint_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
mock = _setup_local_only_project(tmp_path, mocker)
result = runner.invoke(cli, ["--paths", str(tmp_path), "lint"])
assert result.exit_code == 0, f"Lint failed: {result.output}\nException: {result.exception}"
mock.assert_not_called()


def test_plan_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
"""Guard that `plan` explicitly passes `load_state=True` and still reaches state sync."""
mock = _setup_local_only_project(tmp_path, mocker)
init_spy = mocker.spy(Context, "__init__")

runner.invoke(cli, ["--paths", str(tmp_path), "plan"], input="n\n")

assert init_spy.called, "Context was never constructed"
for call in init_spy.call_args_list:
assert "load_state" in call.kwargs, (
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
)
assert call.kwargs["load_state"] is True, (
f"Context was constructed with load_state={call.kwargs['load_state']} for `plan`"
)
assert mock.called, "state-sync was never accessed during `plan`"


def test_format_does_not_open_state_connection(
runner: CliRunner, tmp_path: Path, mocker, monkeypatch
):
"""Format must not open a configured remote Postgres state connection when CI secrets are unset."""
pytest.importorskip("psycopg2")

for var in ("PG_HOST", "PG_USER", "PG_PASSWORD", "PG_DATABASE"):
monkeypatch.delenv(var, raising=False)

create_example_project(tmp_path, template=ProjectTemplate.EMPTY)
(tmp_path / "config.yaml").write_text(
"""project: cli_test

gateways:
prod:
state_connection:
type: postgres
host: "{{ env_var('PG_HOST', 'postgres.internal.example.com') }}"
port: 5432
user: "{{ env_var('PG_USER') }}"
password: "{{ env_var('PG_PASSWORD') }}"
database: "{{ env_var('PG_DATABASE', 'sqlmesh_state') }}"
connection:
type: duckdb
database: "warehouse.db"

default_gateway: prod

model_defaults:
dialect: duckdb
""",
encoding="utf-8",
)
(tmp_path / "models" / "example.sql").write_text(
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col\n",
encoding="utf-8",
)

mock = mocker.patch(
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
side_effect=RuntimeError("state should not be accessed"),
)

result = runner.invoke(cli, ["--paths", str(tmp_path), "format"])
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
mock.assert_not_called()
48 changes: 48 additions & 0 deletions tests/core/linter/test_builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,51 @@ def test_no_missing_unit_tests(tmp_path, copy_to_temp_path):
assert len(model_violations) == 0, (
f"Model {model_name} should not have a violation since it has a test"
)


def test_lint_without_state_load(tmp_path, copy_to_temp_path, mocker) -> None:
sushi_paths = copy_to_temp_path("examples/sushi")
sushi_path = sushi_paths[0]

with open(sushi_path / "config.py", "r") as f:
read_file = f.read()

# Set a project name so state-merge code reaches the `self._load_state` guard.
project_anchor = "config = Config(\n gateways="
assert project_anchor in read_file, (
"sushi config.py shape drifted; update project_anchor in test"
)
read_file = read_file.replace(
project_anchor,
'config = Config(\n project="sushi",\n gateways=',
)

# Enable one built-in rule so `lint_models` doesn't take the empty-rule-set path.
before = """ linter=LinterConfig(
enabled=False,
rules=[
"ambiguousorinvalidcolumn",
"invalidselectstarexpansion",
"noselectstar",
"nomissingaudits",
"nomissingowner",
"nomissingexternalmodels",
],
),"""
after = 'linter=LinterConfig(enabled=True, rules=["nomissingexternalmodels"]),'
assert before in read_file, (
"sushi config.py LinterConfig block shape drifted; update `before` in test"
)
read_file = read_file.replace(before, after)

with open(sushi_path / "config.py", "w") as f:
f.write(read_file)

mock = mocker.patch(
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
side_effect=RuntimeError("state should not be accessed"),
)

context = Context(paths=[sushi_path], load_state=False)
context.lint_models(raise_on_error=False)
mock.assert_not_called()
17 changes: 17 additions & 0 deletions tests/core/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,20 @@ def test_ignore_formating_files(tmp_path: pathlib.Path):
model3.read_text(encoding="utf-8")
== "MODEL (\n name this.model3,\n dialect 'duckdb',\n formatting TRUE\n);\n\nSELECT\n 1 AS col"
)


def test_format_without_state_load(tmp_path: pathlib.Path, mocker: MockerFixture):
mock = mocker.patch(
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
side_effect=RuntimeError("state should not be accessed"),
)

create_temp_file(
tmp_path,
pathlib.Path("models/example.sql"),
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col",
)

context = Context(paths=tmp_path, config=Config(project="local_only"), load_state=False)
context.format(check=True)
mock.assert_not_called()