Skip to content

Commit addfc19

Browse files
committed
cli: run format and lint without state
Add `LOCAL_ONLY_COMMANDS = ("format", "lint")` and pass `load_state=False` to the `Context(...)` constructor for those subcommands. Mirrors the existing `SKIP_LOAD_COMMANDS` shape; one new tuple, one new local, one new kwarg in the Context call. With Task 1's gate in place, `format` and `lint` now run end to end with no state-sync connection, no warehouse connection, and no credentials. A CI job with zero secrets can run them against a real project. Three new slow CLI tests share a setup helper that scaffolds an empty project, sets a non-empty `project` name (so `any(self._projects)` gates on `self._load_state` rather than short-circuiting on the empty string default), patches `EngineAdapterStateSync.get_versions` to raise, and writes one model file. The format and lint tests assert `exit_code == 0` and that the patch was never called. The guard-rail test spies on `Context.__init__` and asserts every call passed `load_state=True` for `plan` — stronger than asserting state was touched later, since `plan` would touch state regardless of `load_state` via `context.plan(...)`. Plan updated to reflect the spy-based assertion and the patch-throughout test mechanism. Coding-Agent: pi Model: google/gemini-3.5-flash Signed-off-by: Joe Hartshorn <8881940+j-hartshorn@users.noreply.github.com>
1 parent 82e0487 commit addfc19

3 files changed

Lines changed: 59 additions & 7 deletions

File tree

docs/2026-05-21_local-only-format/plan.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,15 @@ In `sqlmesh/core/context.py`:
121121

122122
`tests/cli/test_cli.py` (slow, via module-level `pytestmark`):
123123

124-
All three tests share a setup (factor into a local helper or repeat inline):
124+
All three tests share a setup factored into `_setup_local_only_project(tmp_path, mocker)`:
125125
1. `create_example_project(tmp_path, template=ProjectTemplate.EMPTY)` to scaffold a real project.
126-
2. Overwrite the generated `config.yaml` so the `gateways.local` block includes a `state_connection` of type `postgres` pointing at `host: localhost, port: 1`. The DuckDB warehouse connection stays as it is. This makes the project *configuration* declare an unreachable state, exercising the full config-loading path — not just the runtime patch.
127-
3. Place one already-formatted `.sql` model under `models/` so `format --check` has no formatting reason to fail.
128-
4. `mocker.patch("sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions", side_effect=RuntimeError("state should not be accessed"))` belt-and-suspenders: if the unreachable host somehow gets bypassed, the patch still catches it.
126+
2. Prepend `project: cli_test\n\n` to the generated `config.yaml` so `Config.project` is non-empty and `any(self._projects)` is truthy. Without this, the existing outer guard short-circuits regardless of `self._load_state` and the tests are vacuous.
127+
3. Write one `.sql` model under `models/` so the CLI doesn't short-circuit with "no models found".
128+
4. `mocker.patch("sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions", side_effect=RuntimeError("state should not be accessed"))`. (No `state_connection: postgres` block in `config.yaml` — same `psycopg2` problem as Task 1; YAML validation through Pydantic fires the import validator. The patch is the only mechanism.)
129129

130-
- *`test_format_runs_without_state`*: Run setup. Invoke `runner.invoke(cli, ["--paths", str(tmp_path), "format", "--check"])`. Assert `result.exit_code == 0`. Assert the patched `get_versions` mock has `assert_not_called()`.
131-
- *`test_lint_runs_without_state`*: Run setup. Invoke `runner.invoke(cli, ["--paths", str(tmp_path), "lint"])`. Assert `result.exit_code == 0` and the patched mock was not called.
132-
- *`test_plan_still_loads_state`* (required guard-rail): Run setup, but patch `get_versions` to return a stub `Versions` object rather than raise, so `plan` can proceed past the version check. Invoke `runner.invoke(cli, ["--paths", str(tmp_path), "plan"], input="n\n")` to cancel at the prompt. Assert the patched method *was* called at least once. Confirms `LOCAL_ONLY_COMMANDS` didn't accidentally include `plan`.
130+
- *`test_format_runs_without_state`*: Run setup. Invoke `runner.invoke(cli, ["--paths", str(tmp_path), "format"])` (no `--check` — lets format write in place, exit_code 0 whenever the call returned). Assert `result.exit_code == 0` and `mock.assert_not_called()`.
131+
- *`test_lint_runs_without_state`*: Run setup. Invoke `runner.invoke(cli, ["--paths", str(tmp_path), "lint"])`. Assert `result.exit_code == 0` and `mock.assert_not_called()`.
132+
- *`test_plan_still_loads_state`* (required guard-rail): Run setup. *Additionally* spy on `Context.__init__` via `mocker.spy(Context, "__init__")`. Invoke `runner.invoke(cli, ["--paths", str(tmp_path), "plan"], input="n\n")`. Assert that the spy was called and that every call's `load_state` kwarg was `True` (defaults to `True` when omitted). This is stronger than asserting `mock.called` on `get_versions` — a regression where someone added `"plan"` to `LOCAL_ONLY_COMMANDS` would still hit state later via `context.plan(...)`, so `get_versions.called` alone wouldn't catch it. The spy proves the Context constructor itself was called with `load_state=True` for `plan`. Verified by temporarily appending `"plan"` to `LOCAL_ONLY_COMMANDS`; the spy-based assertion fails.
133133

134134
**Implementation outline:**
135135

sqlmesh/cli/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"table_name",
4242
)
4343
SKIP_CONTEXT_COMMANDS = ("init", "ui")
44+
LOCAL_ONLY_COMMANDS = ("format", "lint")
4445

4546

4647
def _sqlmesh_version() -> str:
@@ -115,6 +116,7 @@ def cli(
115116
configure_console(ignore_warnings=ignore_warnings)
116117

117118
load = True
119+
load_state = True
118120

119121
if len(paths) == 1:
120122
path = os.path.abspath(paths[0])
@@ -123,6 +125,8 @@ def cli(
123125
return
124126
if ctx.invoked_subcommand in SKIP_LOAD_COMMANDS:
125127
load = False
128+
if ctx.invoked_subcommand in LOCAL_ONLY_COMMANDS:
129+
load_state = False
126130

127131
configs = load_configs(config, Context.CONFIG_TYPE, paths, dotenv_path=dotenv)
128132
log_limit = list(configs.values())[0].log_limit
@@ -135,6 +139,7 @@ def cli(
135139
config=configs,
136140
gateway=gateway,
137141
load=load,
142+
load_state=load_state,
138143
)
139144
except Exception:
140145
if debug:

tests/cli/test_cli.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2237,3 +2237,50 @@ def test_format_leading_comma_default(runner: CliRunner, tmp_path: Path):
22372237
assert result.exit_code == 0
22382238
finally:
22392239
del os.environ["SQLMESH__FORMAT__LEADING_COMMA"]
2240+
2241+
2242+
def _setup_local_only_project(tmp_path, mocker):
2243+
"""Scaffold a project with a non-empty `project` name and patch state to raise."""
2244+
create_example_project(tmp_path, template=ProjectTemplate.EMPTY)
2245+
config_path = tmp_path / "config.yaml"
2246+
existing = config_path.read_text(encoding="utf-8")
2247+
config_path.write_text("project: cli_test\n\n" + existing, encoding="utf-8")
2248+
2249+
(tmp_path / "models" / "example.sql").write_text(
2250+
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col\n",
2251+
encoding="utf-8",
2252+
)
2253+
2254+
return mocker.patch(
2255+
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
2256+
side_effect=RuntimeError("state should not be accessed"),
2257+
)
2258+
2259+
2260+
def test_format_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
2261+
mock = _setup_local_only_project(tmp_path, mocker)
2262+
result = runner.invoke(cli, ["--paths", str(tmp_path), "format"])
2263+
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
2264+
mock.assert_not_called()
2265+
2266+
2267+
def test_lint_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
2268+
mock = _setup_local_only_project(tmp_path, mocker)
2269+
result = runner.invoke(cli, ["--paths", str(tmp_path), "lint"])
2270+
assert result.exit_code == 0, f"Lint failed: {result.output}\nException: {result.exception}"
2271+
mock.assert_not_called()
2272+
2273+
2274+
def test_plan_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
2275+
"""Guard-rail: confirm `plan` is not in LOCAL_ONLY_COMMANDS by checking
2276+
the Context constructor received load_state=True for it."""
2277+
_setup_local_only_project(tmp_path, mocker)
2278+
init_spy = mocker.spy(Context, "__init__")
2279+
2280+
runner.invoke(cli, ["--paths", str(tmp_path), "plan"], input="n\n")
2281+
2282+
assert init_spy.called, "Context was never constructed"
2283+
load_state_values = [call.kwargs.get("load_state", True) for call in init_spy.call_args_list]
2284+
assert all(load_state_values), (
2285+
f"Context was constructed with load_state=False for `plan`: {load_state_values}"
2286+
)

0 commit comments

Comments
 (0)