Skip to content

Commit 7f4b502

Browse files
authored
Merge branch 'main' into fix/invalidate-nonexistent-environment
2 parents 3e3727f + b44fdf6 commit 7f4b502

6 files changed

Lines changed: 203 additions & 4 deletions

File tree

sqlmesh/cli/main.py

Lines changed: 4 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",)
4445

4546

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

117118
load = True
119+
# Local-only gating must hold for any number of --paths, so it stays outside the block below.
120+
load_state = ctx.invoked_subcommand not in LOCAL_ONLY_COMMANDS
118121

119122
if len(paths) == 1:
120123
path = os.path.abspath(paths[0])
@@ -135,6 +138,7 @@ def cli(
135138
config=configs,
136139
gateway=gateway,
137140
load=load,
141+
load_state=load_state,
138142
)
139143
except Exception:
140144
if debug:

sqlmesh/core/context.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,9 @@ 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).
367+
Only intended for local-only operations like format; plan/apply in multi-repo projects
368+
require it to see models owned by other projects.
366369
console: The rich instance used for printing out CLI command results.
367370
users: A list of users to make known to SQLMesh.
368371
"""
@@ -386,6 +389,7 @@ def __init__(
386389
users: t.Optional[t.List[User]] = None,
387390
config_loader_kwargs: t.Optional[t.Dict[str, t.Any]] = None,
388391
selector: t.Optional[t.Type[Selector]] = None,
392+
load_state: bool = True,
389393
):
390394
self.configs = (
391395
config
@@ -413,6 +417,7 @@ def __init__(
413417
self._engine_adapter: t.Optional[EngineAdapter] = None
414418
self._linters: t.Dict[str, Linter] = {}
415419
self._loaded: bool = False
420+
self._load_state: bool = load_state
416421
self._selector_cls = selector or NativeSelector
417422

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

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

685690
uncached = set()
686691

687-
if any(self._projects):
692+
if self._load_state and any(self._projects):
688693
prod = self.state_reader.get_environment(c.PROD)
689694

690695
if prod:

sqlmesh/lsp/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,8 +1074,8 @@ def _context_get_or_load(self, document_uri: t.Optional[URI] = None) -> LSPConte
10741074
loaded_sqlmesh_message(self.server)
10751075
else:
10761076
self._ensure_context_for_document(document_uri)
1077-
if isinstance(state, ContextLoaded):
1078-
return state.lsp_context
1077+
if isinstance(self.context_state, ContextLoaded):
1078+
return self.context_state.lsp_context
10791079
raise RuntimeError("Context failed to load")
10801080

10811081
def _ensure_context_for_document(

tests/cli/test_cli.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2237,3 +2237,146 @@ 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 _create_local_only_project(path: Path, project: str) -> None:
2243+
path.mkdir(parents=True, exist_ok=True)
2244+
create_example_project(path, template=ProjectTemplate.EMPTY)
2245+
config_path = path / "config.yaml"
2246+
existing = config_path.read_text(encoding="utf-8")
2247+
config_path.write_text(f"project: {project}\n\n" + existing, encoding="utf-8")
2248+
2249+
(path / "models" / "example.sql").write_text(
2250+
f"MODEL(name {project}.example, dialect 'duckdb'); SELECT 1 AS col\n",
2251+
encoding="utf-8",
2252+
)
2253+
2254+
2255+
def _patch_state_access(mocker):
2256+
return mocker.patch(
2257+
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
2258+
side_effect=RuntimeError("state should not be accessed"),
2259+
)
2260+
2261+
2262+
def _setup_local_only_project(tmp_path, mocker):
2263+
_create_local_only_project(tmp_path, "cli_test")
2264+
return _patch_state_access(mocker)
2265+
2266+
2267+
def test_format_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), "format"])
2270+
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
2271+
mock.assert_not_called()
2272+
2273+
2274+
def test_format_runs_without_state_multi_repo_partial(runner: CliRunner, copy_to_temp_path, mocker):
2275+
"""Format one repo of a multi-repo project whose upstream models live only in prod state."""
2276+
repo_2 = copy_to_temp_path("examples/multi")[0] / "repo_2"
2277+
mock = _patch_state_access(mocker)
2278+
2279+
result = runner.invoke(cli, ["--gateway", "memory", "--paths", str(repo_2), "format"])
2280+
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
2281+
mock.assert_not_called()
2282+
2283+
2284+
def test_lint_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
2285+
"""Guard that `lint` explicitly passes `load_state=True` and still reaches state sync."""
2286+
mock = _setup_local_only_project(tmp_path, mocker)
2287+
init_spy = mocker.spy(Context, "__init__")
2288+
2289+
runner.invoke(cli, ["--paths", str(tmp_path), "lint"])
2290+
2291+
assert init_spy.called, "Context was never constructed"
2292+
for call in init_spy.call_args_list:
2293+
assert "load_state" in call.kwargs, (
2294+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2295+
)
2296+
assert call.kwargs["load_state"] is True, (
2297+
f"Context was constructed with load_state={call.kwargs['load_state']} for `lint`"
2298+
)
2299+
assert mock.called, "state-sync was never accessed during `lint`"
2300+
2301+
2302+
@pytest.mark.parametrize("command", ["format"])
2303+
def test_local_only_commands_skip_state_multiple_paths(
2304+
runner: CliRunner, tmp_path: Path, mocker, command: str
2305+
):
2306+
project_a = tmp_path / "a"
2307+
project_b = tmp_path / "b"
2308+
_create_local_only_project(project_a, "proj_a")
2309+
_create_local_only_project(project_b, "proj_b")
2310+
mock = _patch_state_access(mocker)
2311+
2312+
result = runner.invoke(cli, ["--paths", str(project_a), "--paths", str(project_b), command])
2313+
assert result.exit_code == 0, (
2314+
f"{command} failed: {result.output}\nException: {result.exception}"
2315+
)
2316+
mock.assert_not_called()
2317+
2318+
2319+
def test_plan_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
2320+
"""Guard that `plan` explicitly passes `load_state=True` and still reaches state sync."""
2321+
mock = _setup_local_only_project(tmp_path, mocker)
2322+
init_spy = mocker.spy(Context, "__init__")
2323+
2324+
runner.invoke(cli, ["--paths", str(tmp_path), "plan"], input="n\n")
2325+
2326+
assert init_spy.called, "Context was never constructed"
2327+
for call in init_spy.call_args_list:
2328+
assert "load_state" in call.kwargs, (
2329+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2330+
)
2331+
assert call.kwargs["load_state"] is True, (
2332+
f"Context was constructed with load_state={call.kwargs['load_state']} for `plan`"
2333+
)
2334+
assert mock.called, "state-sync was never accessed during `plan`"
2335+
2336+
2337+
def test_format_does_not_open_state_connection(
2338+
runner: CliRunner, tmp_path: Path, mocker, monkeypatch
2339+
):
2340+
"""Format must not open a configured remote Postgres state connection when CI secrets are unset."""
2341+
pytest.importorskip("psycopg2")
2342+
2343+
for var in ("PG_HOST", "PG_USER", "PG_PASSWORD", "PG_DATABASE"):
2344+
monkeypatch.delenv(var, raising=False)
2345+
2346+
create_example_project(tmp_path, template=ProjectTemplate.EMPTY)
2347+
(tmp_path / "config.yaml").write_text(
2348+
"""project: cli_test
2349+
2350+
gateways:
2351+
prod:
2352+
state_connection:
2353+
type: postgres
2354+
host: "{{ env_var('PG_HOST', 'postgres.internal.example.com') }}"
2355+
port: 5432
2356+
user: "{{ env_var('PG_USER') }}"
2357+
password: "{{ env_var('PG_PASSWORD') }}"
2358+
database: "{{ env_var('PG_DATABASE', 'sqlmesh_state') }}"
2359+
connection:
2360+
type: duckdb
2361+
database: "warehouse.db"
2362+
2363+
default_gateway: prod
2364+
2365+
model_defaults:
2366+
dialect: duckdb
2367+
""",
2368+
encoding="utf-8",
2369+
)
2370+
(tmp_path / "models" / "example.sql").write_text(
2371+
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col\n",
2372+
encoding="utf-8",
2373+
)
2374+
2375+
mock = mocker.patch(
2376+
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
2377+
side_effect=RuntimeError("state should not be accessed"),
2378+
)
2379+
2380+
result = runner.invoke(cli, ["--paths", str(tmp_path), "format"])
2381+
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
2382+
mock.assert_not_called()

tests/core/test_format.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,20 @@ 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+
mock = mocker.patch(
151+
"sqlmesh.core.state_sync.db.facade.EngineAdapterStateSync.get_versions",
152+
side_effect=RuntimeError("state should not be accessed"),
153+
)
154+
155+
create_temp_file(
156+
tmp_path,
157+
pathlib.Path("models/example.sql"),
158+
"MODEL(name local.example, dialect 'duckdb'); SELECT 1 AS col",
159+
)
160+
161+
context = Context(paths=tmp_path, config=Config(project="local_only"), load_state=False)
162+
context.format(check=True)
163+
mock.assert_not_called()

tests/lsp/test_context.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from sqlmesh.core.context import Context
44
from sqlmesh.lsp.context import LSPContext, ModelTarget
5+
from sqlmesh.lsp.main import ContextLoaded, NoContext, SQLMeshLanguageServer
56
from sqlmesh.lsp.uri import URI
67

78

@@ -61,3 +62,32 @@ def test_lsp_context_run_test():
6162
# Check that the result is not None and has the expected properties
6263
assert result is not None
6364
assert result.success is True
65+
66+
67+
def test_context_get_or_load_from_no_context_with_specified_paths():
68+
server = SQLMeshLanguageServer(context_class=Context)
69+
server.server.show_message = lambda *args, **kwargs: None
70+
server.specified_paths = [Path("examples/sushi")]
71+
72+
assert isinstance(server.context_state, NoContext)
73+
74+
lsp_context = server._context_get_or_load()
75+
76+
assert isinstance(lsp_context, LSPContext)
77+
assert isinstance(server.context_state, ContextLoaded)
78+
assert server.context_state.lsp_context is lsp_context
79+
80+
81+
def test_context_get_or_load_from_no_context_via_workspace_folder():
82+
server = SQLMeshLanguageServer(context_class=Context)
83+
server.server.show_message = lambda *args, **kwargs: None
84+
server.specified_paths = None
85+
server.workspace_folders = [Path.cwd() / "examples" / "sushi"]
86+
87+
assert isinstance(server.context_state, NoContext)
88+
89+
lsp_context = server._context_get_or_load()
90+
91+
assert isinstance(lsp_context, LSPContext)
92+
assert isinstance(server.context_state, ContextLoaded)
93+
assert server.context_state.lsp_context is lsp_context

0 commit comments

Comments
 (0)