Skip to content

Commit 82e0487

Browse files
committed
feat: add load_state flag to Context
Add `load_state: bool = True` to `GenericContext.__init__` and AND the new flag into the two `if any(self._projects):` guards in `Context.load()`. With `load_state=False`, no remote-snapshot merge runs, so `state_reader` is never read and the state-sync backend is never instantiated. The default preserves today's behavior for every existing caller. CLI wiring lands in the next commit; this one just introduces the mechanism and proves it. Tests patch `EngineAdapterStateSync.get_versions` to raise and assert it is never called when `load_state=False`. They set a non-empty `project` on the test config so `any(self._projects)` is truthy and the gate actually exercises `self._load_state` (with the default empty project, the outer `any(...)` short-circuits and the test is vacuous). Verified non-vacuous by flipping `load_state=True` and confirming both tests fail with the patched RuntimeError. The plan originally proposed an unreachable Postgres state connection; `psycopg2` is not installed in the dev env so `PostgresConnectionConfig` validates at construction time and the config can't be built. Implementation uses the plan's documented patch-based fallback instead. Plan updated to reflect the change. Coding-Agent: pi Model: anthropic/claude-sonnet-4-6 Signed-off-by: Joe Hartshorn <8881940+j-hartshorn@users.noreply.github.com>
1 parent 15bf65b commit 82e0487

4 files changed

Lines changed: 58 additions & 7 deletions

File tree

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ The PR is done when all of these are true:
2929
- The `Linter` is built per-project in `Context.load()` at `context.py:670-674` before the state-merge block. Linters don't depend on the state-merge having run.
3030
- `format` and `lint` Click handlers are at `sqlmesh/cli/main.py:343-380` and `sqlmesh/cli/main.py:1168-1185` respectively. Neither needs argument changes.
3131

32-
**Assumptions** (flagged for the implementer to confirm during work):
32+
**Decisions resolved during implementation:**
3333

34-
- A Postgres connection config pointing at an unreachable host (e.g. `localhost:1`) is sufficient to make state access fail loudly without requiring `psycopg2` to actually attempt a real connection — the failure should happen at `get_versions` cursor-fetch time, not at config validation time. If config validation rejects the host, fall back to patching `EngineAdapterStateSync.get_versions` to raise.
35-
- No existing test currently exercises the "format/lint succeed despite broken state" path. Verified by `rg 'format.*state\|lint.*state' tests/` returning no relevant matches, but the implementer should confirm before adding the regression.
34+
- The plan originally suggested configuring an unreachable Postgres state connection. This doesn't work in the dev environment because `psycopg2` is not installed; `PostgresConnectionConfig` runs `_get_engine_import_validator("psycopg2", ...)` at config-validation time (`sqlmesh/core/config/connection.py:1424`) and raises before our code path is reached. Implementation uses the plan's documented fallback: patch `sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions` with `side_effect=RuntimeError(...)` and assert it is never called.
35+
- The state-merge blocks are guarded by `if any(self._projects):`, where `self._projects = {config.project for config in self.configs.values()}`. `Config.project` defaults to `""` (`sqlmesh/core/config/root.py:142`), and `any({""})` is `False` — so a `Config()` literal short-circuits the guard before the new `self._load_state` term is evaluated, making the test vacuous. The Context-level tests therefore set a non-empty `project` (`Config(project="local_only")` in the format test; an inline rewrite of sushi's `config.py` to add `project="sushi"` in the linter test) to ensure the guard is actually exercised.
36+
- No existing test exercised the "format/lint succeed despite broken state" path (verified by `rg 'format.*state\|lint.*state' tests/`).
3637

3738
## Existing patterns & conventions to follow
3839

@@ -85,10 +86,10 @@ Two tasks, two commits, one PR.
8586
**Test cases** (red first, then implementation):
8687

8788
`tests/core/test_format.py` (fast):
88-
- *`test_format_without_state_load`*: Construct a `Context` with `paths=tmp_path`, a `Config` whose state connection points at `localhost:1` (Postgres), and `load_state=False`. Place one `.sql` model file under `models/` containing SQL that is already in canonical formatted form (so `--check` succeeds for content reasons — the test is about state access, not about formatting decisions). Call `context.format(check=True)` and assert it returns `True` without raising. Assert the file's contents on disk are unchanged.
89+
- *`test_format_without_state_load`*: Patch `EngineAdapterStateSync.get_versions` to raise `RuntimeError`. Place one `.sql` model under `models/`. Construct `Context(paths=tmp_path, config=Config(project="local_only"), load_state=False)` (non-empty `project` so `any(self._projects)` is truthy and the gate is exercised). Call `context.format(check=True)`. Assert the patched mock has `assert_not_called()`. Verified non-vacuous by flipping to `load_state=True` and confirming the test fails with the patched `RuntimeError`.
8990

9091
`tests/core/linter/test_builtin.py` (fast):
91-
- *`test_lint_without_state_load`*: Using `copy_to_temp_path("examples/sushi")`, rewrite the sushi `config.py` to enable the linter (existing pattern at `test_builtin.py:21-40`) AND set a Postgres state connection at `localhost:1`. Construct `Context(paths=[sushi_path], load_state=False)`. Call `context.lint_models(raise_on_error=False)` and assert it returns a list (i.e., the linter ran end-to-end) without raising.
92+
- *`test_lint_without_state_load`*: Using `copy_to_temp_path("examples/sushi")`, rewrite the sushi `config.py` to add `project="sushi"` to the `Config(...)` call (so `any(self._projects)` is truthy). Patch `EngineAdapterStateSync.get_versions` to raise. Construct `Context(paths=[sushi_path], load_state=False)`. Call `context.lint_models(raise_on_error=False)`. Assert the patched mock has `assert_not_called()`. Verified non-vacuous the same way as the format test.
9293

9394
**Implementation outline:**
9495

sqlmesh/core/context.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ class GenericContext(BaseContext, t.Generic[C]):
363363
connection as it appears in configuration will be used.
364364
concurrent_tasks: The maximum number of tasks that can use the connection concurrently.
365365
load: Whether or not to automatically load all models and macros (default True).
366+
load_state: Whether to merge remote state into the local project during load (default True). Only meaningful when load=True.
366367
console: The rich instance used for printing out CLI command results.
367368
users: A list of users to make known to SQLMesh.
368369
"""
@@ -383,6 +384,7 @@ def __init__(
383384
concurrent_tasks: t.Optional[int] = None,
384385
loader: t.Optional[t.Type[Loader]] = None,
385386
load: bool = True,
387+
load_state: bool = True,
386388
users: t.Optional[t.List[User]] = None,
387389
config_loader_kwargs: t.Optional[t.Dict[str, t.Any]] = None,
388390
selector: t.Optional[t.Type[Selector]] = None,
@@ -413,6 +415,7 @@ def __init__(
413415
self._engine_adapter: t.Optional[EngineAdapter] = None
414416
self._linters: t.Dict[str, Linter] = {}
415417
self._loaded: bool = False
418+
self._load_state: bool = load_state
416419
self._selector_cls = selector or NativeSelector
417420

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

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

685688
uncached = set()
686689

687-
if any(self._projects):
690+
if self._load_state and any(self._projects):
688691
prod = self.state_reader.get_environment(c.PROD)
689692

690693
if prod:

tests/core/linter/test_builtin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,32 @@ def test_no_missing_unit_tests(tmp_path, copy_to_temp_path):
232232
assert len(model_violations) == 0, (
233233
f"Model {model_name} should not have a violation since it has a test"
234234
)
235+
236+
237+
def test_lint_without_state_load(tmp_path, copy_to_temp_path, mocker) -> None:
238+
"""`lint_models` with `load_state=False` runs end-to-end without touching state sync."""
239+
sushi_paths = copy_to_temp_path("examples/sushi")
240+
sushi_path = sushi_paths[0]
241+
242+
with open(sushi_path / "config.py", "r") as f:
243+
read_file = f.read()
244+
245+
# Set a non-empty project name so `any(self._projects)` is truthy and the
246+
# state-merge guard in `Context.load()` actually exercises `self._load_state`.
247+
read_file = read_file.replace(
248+
"config = Config(\n gateways=",
249+
'config = Config(\n project="sushi",\n gateways=',
250+
)
251+
assert 'project="sushi"' in read_file
252+
253+
with open(sushi_path / "config.py", "w") as f:
254+
f.writelines(read_file)
255+
256+
mock = mocker.patch(
257+
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
258+
side_effect=RuntimeError("state should not be accessed"),
259+
)
260+
261+
context = Context(paths=[sushi_path], load_state=False)
262+
context.lint_models(raise_on_error=False)
263+
mock.assert_not_called()

tests/core/test_format.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,21 @@ def test_ignore_formating_files(tmp_path: pathlib.Path):
144144
model3.read_text(encoding="utf-8")
145145
== "MODEL (\n name this.model3,\n dialect 'duckdb',\n formatting TRUE\n);\n\nSELECT\n 1 AS col"
146146
)
147+
148+
149+
def test_format_without_state_load(tmp_path: pathlib.Path, mocker: MockerFixture):
150+
"""`format` with `load_state=False` runs end-to-end without touching state sync."""
151+
mock = mocker.patch(
152+
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
153+
side_effect=RuntimeError("state should not be accessed"),
154+
)
155+
156+
create_temp_file(
157+
tmp_path,
158+
pathlib.Path("models/example.sql"),
159+
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col",
160+
)
161+
162+
context = Context(paths=tmp_path, config=Config(project="local_only"), load_state=False)
163+
context.format(check=True)
164+
mock.assert_not_called()

0 commit comments

Comments
 (0)