Skip to content

Commit 2c4e160

Browse files
authored
fix: normalize contract adoption paths (#720)
Co-authored-by: Kevin Longe <273747162+kvlonge@users.noreply.github.com>
1 parent 5cf4248 commit 2c4e160

5 files changed

Lines changed: 196 additions & 8 deletions

File tree

‎src/sqlbuild/cli/commands/_helpers/entry/dispatch.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@
5454
def dispatch_cli_command(*, args: CliNamespace, handlers: CliEntrypointHandlers) -> int:
5555
"""Route a parsed CLI namespace to its command handler and return its exit code."""
5656

57-
project_dir: Path | None = None if args.project_dir is None else Path(args.project_dir)
57+
project_dir: Path | None = (
58+
None if args.project_dir is None else Path(args.project_dir).resolve()
59+
)
5860
effective_project_dir: Path = project_dir if project_dir is not None else Path.cwd()
5961
selector_inputs: SelectorInputs = read_selector_file_inputs(args.select_file)
6062
select: tuple[str, ...] = (*tuple(args.select), *selector_inputs.selectors)

‎src/sqlbuild/compiler/contract_adoption/_helpers/repository_edits.py‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,17 +283,30 @@ def edit_source(
283283
insertion_lines.extend(_yaml_column_lines(column=column, indent=source_indent + 4))
284284
lines[source_end:source_end] = insertion_lines
285285
return "".join(lines), None
286+
columns_indent: int = _indent(lines[columns_index])
287+
detected_column_indent: int | None = _yaml_sequence_indent(
288+
lines=lines,
289+
start=columns_index + 1,
290+
end=source_end,
291+
key_indent=columns_indent,
292+
)
293+
column_indent: int = (
294+
detected_column_indent if detected_column_indent is not None else columns_indent + 2
295+
)
286296
columns_end: int = columns_index + 1
287297
while columns_end < source_end:
288298
stripped: str = lines[columns_end].strip()
289-
if (
290-
stripped
291-
and not stripped.startswith("#")
292-
and _indent(lines[columns_end]) <= source_indent + 2
293-
):
294-
break
299+
if stripped and not stripped.startswith("#"):
300+
line_indent: int = _indent(lines[columns_end])
301+
if line_indent < columns_indent or (
302+
line_indent == columns_indent
303+
and not (
304+
column_indent == columns_indent
305+
and _yaml_list_name(lines[columns_end]) is not None
306+
)
307+
):
308+
break
295309
columns_end += 1
296-
column_indent: int = source_indent + 4
297310
column_blocks: dict[str, tuple[int, int]] = {}
298311
index: int = columns_index + 1
299312
while index < columns_end:
@@ -400,6 +413,19 @@ def _yaml_key(line: str) -> str | None:
400413
return stripped.split(_YAML_KEY_SEPARATOR, maxsplit=1)[0].strip()
401414

402415

416+
def _yaml_sequence_indent(*, lines: list[str], start: int, end: int, key_indent: int) -> int | None:
417+
for index in range(start, end):
418+
stripped: str = lines[index].strip()
419+
if not stripped or stripped.startswith("#"):
420+
continue
421+
line_indent: int = _indent(lines[index])
422+
if _yaml_list_name(lines[index]) is not None and line_indent >= key_indent:
423+
return line_indent
424+
if line_indent <= key_indent:
425+
return None
426+
return None
427+
428+
403429
def _yaml_comment(line: str) -> str:
404430
marker: int = line.find(" #")
405431
if marker < 0:

‎tests/integration/src/sqlbuild/cli/commands/main/_test_types.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ class ContractNullabilityCompileIntegrationTestCase:
6767
expected_diagnostics: tuple[str, ...]
6868

6969

70+
@dataclass(frozen=True)
71+
class ProjectDirectoryCompileIntegrationTestCase:
72+
"""One project-directory normalization expectation through compile."""
73+
74+
description: str
75+
expected_exit_code: int
76+
77+
7078
@dataclass(frozen=True)
7179
class FormatCompileIntegrationTestCase:
7280
"""One format-to-compile compatibility expectation."""

‎tests/integration/src/sqlbuild/cli/commands/main/test_compile.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sqlbuild.cli.commands.main.entrypoint.entry import main
1212
from tests.integration.src.sqlbuild.cli.commands.main._test_types import (
1313
ContractNullabilityCompileIntegrationTestCase,
14+
ProjectDirectoryCompileIntegrationTestCase,
1415
SnowflakeCompileIntegrationTestCase,
1516
)
1617

@@ -112,3 +113,46 @@ def test_given_nullable_output_when_compiling_contract_then_only_schema_nullabil
112113

113114
assert exit_code == test_case.expected_exit_code
114115
assert tuple(item["code"] for item in diagnostics) == test_case.expected_diagnostics
116+
117+
118+
@pytest.mark.parametrize(
119+
"test_case",
120+
(
121+
ProjectDirectoryCompileIntegrationTestCase(
122+
description="relative project directory preserves private macro scope",
123+
expected_exit_code=0,
124+
),
125+
),
126+
ids=lambda case: case.description,
127+
)
128+
def test_given_relative_project_directory_when_compiling_then_private_macro_is_visible(
129+
test_case: ProjectDirectoryCompileIntegrationTestCase,
130+
tmp_path: Path,
131+
monkeypatch: pytest.MonkeyPatch,
132+
capsys: pytest.CaptureFixture[str],
133+
) -> None:
134+
project_dir: Path = tmp_path / "project"
135+
(project_dir / "models" / "orders" / "_sqlbuild" / "_macros").mkdir(parents=True)
136+
(project_dir / "sqlbuild_project.toml").write_text(
137+
'name = "orders"\nadapter = "duckdb"\n', encoding="utf-8"
138+
)
139+
(project_dir / "models" / "orders" / "_sqlbuild" / "_macros" / "order_id.py").write_text(
140+
'def order_id() -> str:\n return "CAST(1 AS INTEGER)"\n', encoding="utf-8"
141+
)
142+
(project_dir / "models" / "orders" / "orders.sql").write_text(
143+
"MODEL (materialized table);\nSELECT @order_id() AS order_id\n",
144+
encoding="utf-8",
145+
)
146+
monkeypatch.chdir(tmp_path)
147+
148+
exit_code: int = main(["--project-dir", project_dir.name, "compile", "--json", "--no-cache"])
149+
result: dict[str, object] = json.loads(capsys.readouterr().out)
150+
summary: dict[str, object] = cast(dict[str, object], result["summary"])
151+
152+
assert exit_code == test_case.expected_exit_code
153+
assert summary["errors"] == 0
154+
assert summary["models"] == 1
155+
156+
157+
if __name__ == "__main__":
158+
pytest.main([__file__, "-vv"])

‎tests/integration/src/sqlbuild/cli/commands/main/test_contract_command.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,5 +534,113 @@ def test_given_unresolved_additive_type_conflict_when_writing_then_file_is_not_r
534534
assert model_path.read_text(encoding="utf-8") == original
535535

536536

537+
@pytest.mark.parametrize(
538+
"test_case",
539+
[
540+
ContractCommandIntegrationTestCase(
541+
description="relative project directory preserves private macro scope",
542+
expected_exit_code=1,
543+
)
544+
],
545+
ids=lambda case: case.description,
546+
)
547+
def test_given_relative_project_directory_when_diffing_contract_then_private_macro_is_visible(
548+
test_case: ContractCommandIntegrationTestCase,
549+
tmp_path: Path,
550+
monkeypatch: pytest.MonkeyPatch,
551+
capsys: pytest.CaptureFixture[str],
552+
) -> None:
553+
project_dir: Path = tmp_path / "project"
554+
project_dir.mkdir()
555+
_ = prepare_contract_project(project_dir)
556+
root_model_path: Path = project_dir / "models" / "orders.sql"
557+
root_model_path.unlink()
558+
model_path: Path = project_dir / "models" / "orders" / "orders.sql"
559+
model_path.parent.mkdir()
560+
_ = model_path.write_text(
561+
"MODEL (materialized table);\nSELECT @order_id() AS id, CAST('a' AS VARCHAR) AS name\n",
562+
encoding="utf-8",
563+
)
564+
macro_path: Path = model_path.parent / "_sqlbuild" / "_macros" / "order_id.py"
565+
macro_path.parent.mkdir(parents=True)
566+
_ = macro_path.write_text(
567+
'def order_id() -> str:\n return "CAST(1 AS INTEGER)"\n', encoding="utf-8"
568+
)
569+
monkeypatch.chdir(tmp_path)
570+
571+
exit_code: int = main(
572+
[
573+
"--no-color",
574+
"--project-dir",
575+
project_dir.name,
576+
"contract",
577+
"diff",
578+
"--from",
579+
"prod",
580+
"--select",
581+
"orders",
582+
]
583+
)
584+
585+
captured: CaptureResult[str] = capsys.readouterr()
586+
assert exit_code == test_case.expected_exit_code
587+
assert "model:orders" in captured.out
588+
assert "inaccessible" not in captured.err
589+
590+
591+
@pytest.mark.parametrize(
592+
"test_case",
593+
[
594+
ContractCommandIntegrationTestCase(
595+
description="indentless source column sequence receives physical types",
596+
expected_exit_code=0,
597+
)
598+
],
599+
ids=lambda case: case.description,
600+
)
601+
def test_given_indentless_source_columns_when_generating_then_preserves_metadata_and_adds_types(
602+
test_case: ContractCommandIntegrationTestCase,
603+
tmp_path: Path,
604+
capsys: pytest.CaptureFixture[str],
605+
) -> None:
606+
_ = prepare_contract_project(tmp_path)
607+
source_path: Path = tmp_path / "sources" / "raw.yml"
608+
_ = source_path.write_text(
609+
"""sources:
610+
- name: raw_orders
611+
schema: raw
612+
table: orders
613+
description: keep source
614+
columns:
615+
- name: id
616+
description: identifier
617+
""",
618+
encoding="utf-8",
619+
)
620+
621+
exit_code: int = main(
622+
[
623+
"--no-color",
624+
"--project-dir",
625+
str(tmp_path),
626+
"contract",
627+
"generate",
628+
"--from",
629+
"prod",
630+
"--select",
631+
"source:raw_orders",
632+
"--write",
633+
]
634+
)
635+
636+
source_yaml: str = source_path.read_text(encoding="utf-8")
637+
assert exit_code == test_case.expected_exit_code
638+
assert "0 contract difference(s)" in capsys.readouterr().out
639+
assert "description: keep source" in source_yaml
640+
assert "description: identifier" in source_yaml
641+
assert " - name: id\n type: 'BIGINT'" in source_yaml
642+
assert " - name: status\n type: 'VARCHAR'" in source_yaml
643+
644+
537645
if __name__ == "__main__":
538646
pytest.main([__file__, "-vv"])

0 commit comments

Comments
 (0)