Skip to content

Commit 83c146c

Browse files
committed
hx-9ed0389d migrate authoring consumers to ExpectationSuite
Governed by docs/helix/02-design/adr/ADR-005-unified-expectation-model.md. Verification: uv run pytest -q tests/unit/test_apply_response.py tests/unit/test_mutations.py tests/unit/test_preview.py tests/unit/test_cli_validation_commands.py tests/unit/test_cli_apply_response.py; uv run ruff check src/tablespec/authoring/mutations.py src/tablespec/authoring/apply_response.py src/tablespec/cli.py tests/unit/test_apply_response.py tests/unit/test_mutations.py tests/unit/test_preview.py tests/unit/test_cli_validation_commands.py tests/unit/test_cli_apply_response.py; uv run pyright src/tablespec/authoring/mutations.py src/tablespec/authoring/apply_response.py src/tablespec/cli.py
1 parent 12814ba commit 83c146c

8 files changed

Lines changed: 228 additions & 11 deletions

File tree

.helix/issues.jsonl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@
1414
{"id":"hx-2c3c331f","title":"ADR-005 Phase C: migrate consumers to ExpectationSuite","type":"epic","status":"open","priority":2,"labels":["helix","phase:build","kind:refactor","area:validation"],"deps":[],"parent":"","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"All runtime consumers read from ExpectationSuite; quality_checks retained for backward-compatible loading only","assignee":"","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:39:59Z","updated":"2026-04-02T03:39:59Z"}
1515
{"id":"hx-bdb8fff2","title":"Migrate UMFLoader to populate ExpectationSuite on read","type":"task","status":"in_progress","priority":2,"labels":["helix","phase:build","kind:refactor","area:loader"],"deps":[],"parent":"hx-2c3c331f","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"UMFLoader.load() populates umf.expectations from validation_rules + quality_checks via expectation_migration; split-format saver writes expectations.yaml","assignee":"helix","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:40:07Z","updated":"2026-04-02T03:44:10Z"}
1616
{"id":"hx-8da6f798","title":"Migrate QualityCheckExecutor to read from ExpectationSuite","type":"task","status":"closed","priority":2,"labels":["helix","phase:build","kind:refactor","area:validation"],"deps":["hx-bdb8fff2"],"parent":"hx-2c3c331f","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"QualityCheckExecutor reads from umf.expectations; falls back to quality_checks if expectations is empty","assignee":"helix","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:40:13Z","updated":"2026-04-02T03:48:32Z"}
17-
{"id":"hx-9ed0389d","title":"Migrate authoring commands to use ExpectationSuite","type":"task","status":"open","priority":2,"labels":["helix","phase:build","kind:refactor","area:authoring"],"deps":["hx-bdb8fff2"],"parent":"hx-2c3c331f","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"mutations.py, preview.py, apply_response.py, and cli.py search/mutate umf.expectations instead of quality_checks","assignee":"","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:40:19Z","updated":"2026-04-02T03:40:19Z"}
18-
{"id":"hx-f3261259","title":"Add deprecation warnings to quality_checks and validation_rules fields","type":"task","status":"open","priority":2,"labels":["helix","phase:build","kind:refactor","area:models"],"deps":["hx-8da6f798","hx-9ed0389d"],"parent":"hx-2c3c331f","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"Pydantic model emits DeprecationWarning when quality_checks or validation_rules are populated directly; ADR-005 status updated to Phase C","assignee":"","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:40:24Z","updated":"2026-04-02T03:40:24Z"}
17+
{"id":"hx-9ed0389d","title":"Migrate authoring commands to use ExpectationSuite","type":"task","status":"in_progress","priority":2,"labels":["helix","phase:build","kind:refactor","area:authoring"],"deps":["hx-bdb8fff2"],"parent":"hx-2c3c331f","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"mutations.py, preview.py, apply_response.py, and cli.py search/mutate umf.expectations instead of quality_checks","assignee":"helix","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:40:19Z","updated":"2026-04-02T04:11:16Z"}
18+
{"id":"hx-f3261259","title":"Add deprecation warnings to quality_checks and validation_rules fields","type":"task","status":"in_progress","priority":2,"labels":["helix","phase:build","kind:refactor","area:models"],"deps":["hx-8da6f798","hx-9ed0389d"],"parent":"hx-2c3c331f","spec-id":"docs/helix/02-design/adr/ADR-005-unified-expectation-model.md","description":"","design":"","acceptance":"Pydantic model emits DeprecationWarning when quality_checks or validation_rules are populated directly; ADR-005 status updated to Phase C","assignee":"helix","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T03:40:24Z","updated":"2026-04-02T04:14:47Z"}
19+
{"id":"hx-747cdaa0","title":"Restore canonical make check signal","type":"chore","status":"open","priority":2,"labels":["helix","phase:iterate","area:tooling"],"deps":[],"parent":"","spec-id":"","description":"Running 'make check' on 2026-04-02 during hx-9ed0389d failed before this issue's slice could be evaluated cleanly. Failures include hundreds of existing ruff violations across tracked tests plus untracked .claude/worktrees content being linted. This should be split from feature work so implementation issues can rely on a trustworthy pre-push gate.","design":"","acceptance":"1. 'make check' excludes ephemeral local worktree content such as .claude/worktrees or otherwise ignores non-project artifacts. 2. Remaining lint/type/test failures in tracked project files are reduced until 'make check' passes from a clean checkout. 3. The issue records the exact commands and any config changes needed to keep the canonical gate trustworthy.","assignee":"","notes":"","execution-eligible":true,"superseded-by":"","replaces":"","created":"2026-04-02T04:16:22Z","updated":"2026-04-02T04:16:22Z"}

src/tablespec/authoring/apply_response.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77

88
from tablespec.models.umf import (
9+
Expectation,
910
INGESTED_QUALITY_CHECK_TYPES,
1011
RAW_VALIDATION_TYPES,
1112
UMF,
@@ -22,6 +23,7 @@ class ApplyResult:
2223
deduplicated: list[dict[str, Any]] = field(default_factory=list)
2324
invalid: list[tuple[dict[str, Any], str]] = field(default_factory=list)
2425
warnings: list[str] = field(default_factory=list)
26+
updated_umf: UMF | None = None
2527

2628

2729
def apply_validation_response(
@@ -40,10 +42,15 @@ def apply_validation_response(
4042
"""
4143
known_types = RAW_VALIDATION_TYPES | INGESTED_QUALITY_CHECK_TYPES
4244
result = ApplyResult()
45+
new_expectations: list[Expectation] = []
4346

4447
# Get existing expectations for dedup
4548
existing_signatures: set[str] = set()
46-
suite = umf.expectations or migrate_to_expectation_suite(umf.model_dump(exclude_none=True))
49+
suite = (
50+
umf.expectations.model_copy(deep=True)
51+
if umf.expectations is not None
52+
else migrate_to_expectation_suite(umf.model_dump(exclude_none=True))
53+
)
4754
for exp in suite.expectations:
4855
sig = _expectation_signature(exp)
4956
existing_signatures.add(sig)
@@ -82,8 +89,20 @@ def apply_validation_response(
8289
continue
8390

8491
result.added.append(exp_dict)
92+
new_expectations.append(Expectation.from_gx_dict(exp_dict))
8593
existing_signatures.add(sig)
8694

95+
if new_expectations:
96+
result.updated_umf = umf.model_copy(
97+
update={
98+
"expectations": suite.model_copy(
99+
update={"expectations": [*suite.expectations, *new_expectations]}
100+
),
101+
"validation_rules": None,
102+
"quality_checks": None,
103+
}
104+
)
105+
87106
return result
88107

89108

src/tablespec/authoring/mutations.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ def _matches(exp: dict[str, Any] | Any) -> bool:
7272

7373
if removed:
7474
updates["expectations"] = suite.model_copy(update={"expectations": filtered_expectations})
75+
updates["validation_rules"] = None
76+
updates["quality_checks"] = None
7577

7678
return umf.model_copy(update=updates) if updates else umf, removed
7779

src/tablespec/cli.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,7 @@ def domains_set(
825825
raise typer.Exit(1)
826826

827827
loader = UMFLoader()
828-
umf = loader.load(table_path)
828+
umf = loader.load(Path(table_path))
829829
updated = modify_column(umf, column, domain_type=domain_type)
830830
dest = Path(table_path)
831831
fmt = UMFFormat.JSON if dest.suffix == ".json" else UMFFormat.SPLIT
@@ -857,7 +857,7 @@ def column_add(
857857

858858
try:
859859
loader = UMFLoader()
860-
umf = loader.load(table_path)
860+
umf = loader.load(Path(table_path))
861861

862862
kwargs: dict = {}
863863
if description is not None:
@@ -893,7 +893,7 @@ def column_remove(
893893

894894
try:
895895
loader = UMFLoader()
896-
umf = loader.load(table_path)
896+
umf = loader.load(Path(table_path))
897897
updated = remove_column(umf, name)
898898
dest = Path(table_path)
899899
fmt = UMFFormat.JSON if dest.suffix == ".json" else UMFFormat.SPLIT
@@ -924,7 +924,7 @@ def column_modify(
924924

925925
try:
926926
loader = UMFLoader()
927-
umf = loader.load(table_path)
927+
umf = loader.load(Path(table_path))
928928

929929
changes: dict = {}
930930
if data_type is not None:
@@ -966,7 +966,7 @@ def column_rename(
966966

967967
try:
968968
loader = UMFLoader()
969-
umf = loader.load(table_path)
969+
umf = loader.load(Path(table_path))
970970
updated = rename_column(umf, old_name, new_name, keep_alias=keep_alias)
971971
dest = Path(table_path)
972972
fmt = UMFFormat.JSON if dest.suffix == ".json" else UMFFormat.SPLIT
@@ -1044,7 +1044,7 @@ def validation_remove(
10441044

10451045
try:
10461046
loader = UMFLoader()
1047-
umf = loader.load(table_path)
1047+
umf = loader.load(Path(table_path))
10481048
updated, count = remove_expectation(umf, expectation_type, column)
10491049

10501050
if count == 0:
@@ -1106,7 +1106,7 @@ def preview(
11061106
from tablespec.gx_baseline import BaselineExpectationGenerator
11071107

11081108
loader = UMFLoader()
1109-
umf = loader.load(table_path)
1109+
umf = loader.load(Path(table_path))
11101110
umf_data = umf.model_dump()
11111111

11121112
# Also generate baseline expectations
@@ -1200,6 +1200,10 @@ def apply_response(
12001200

12011201
result = apply_validation_response(umf, response)
12021202

1203+
if result.updated_umf is not None and not dry_run:
1204+
fmt = UMFFormat.JSON if source.suffix == ".json" else UMFFormat.SPLIT
1205+
loader.save(result.updated_umf, source, format=fmt)
1206+
12031207
if result.added:
12041208
console.print(f"[green]Added:[/green] {len(result.added)} expectations")
12051209
for exp in result.added:

tests/unit/test_apply_response.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def _make_umf(
1515
columns: list[dict] | None = None,
1616
validation_rules: dict | None = None,
1717
quality_checks: dict | None = None,
18+
expectations: dict | None = None,
1819
) -> UMF:
1920
"""Build a minimal UMF for testing."""
2021
if columns is None:
@@ -28,6 +29,8 @@ def _make_umf(
2829
data["validation_rules"] = validation_rules
2930
if quality_checks is not None:
3031
data["quality_checks"] = quality_checks
32+
if expectations is not None:
33+
data["expectations"] = expectations
3134
return UMF(**data)
3235

3336

@@ -46,6 +49,8 @@ def test_adds_new_expectations(self):
4649
assert len(result.added) == 1
4750
assert result.added[0]["meta"]["generated_from"] == "llm"
4851
assert result.added[0]["meta"]["validation_stage"] == "raw"
52+
assert result.updated_umf is not None
53+
assert len(result.updated_umf.expectations.expectations) == 1
4954

5055
def test_deduplicates_existing(self):
5156
umf = _make_umf(
@@ -191,3 +196,53 @@ def test_result_dataclass_defaults(self):
191196
assert result.deduplicated == []
192197
assert result.invalid == []
193198
assert result.warnings == []
199+
assert result.updated_umf is None
200+
201+
def test_updates_expectation_suite_and_clears_legacy_fields(self):
202+
umf = _make_umf(
203+
validation_rules={
204+
"expectations": [
205+
{
206+
"type": "expect_column_values_to_not_be_null",
207+
"kwargs": {"column": "id"},
208+
}
209+
]
210+
}
211+
)
212+
response = [
213+
{
214+
"type": "expect_column_values_to_match_regex",
215+
"kwargs": {"column": "id", "regex": "^\\d+$"},
216+
}
217+
]
218+
219+
result = apply_validation_response(umf, response)
220+
221+
assert result.updated_umf is not None
222+
assert len(result.updated_umf.expectations.expectations) == 2
223+
assert result.updated_umf.validation_rules is None
224+
assert result.updated_umf.quality_checks is None
225+
226+
def test_dedup_does_not_create_updated_umf(self):
227+
umf = _make_umf(
228+
expectations={
229+
"expectations": [
230+
{
231+
"type": "expect_column_values_to_not_be_null",
232+
"kwargs": {"column": "id"},
233+
"meta": {"validation_stage": "raw"},
234+
}
235+
]
236+
}
237+
)
238+
response = [
239+
{
240+
"type": "expect_column_values_to_not_be_null",
241+
"kwargs": {"column": "id"},
242+
}
243+
]
244+
245+
result = apply_validation_response(umf, response)
246+
247+
assert len(result.deduplicated) == 1
248+
assert result.updated_umf is None
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for CLI apply-response command."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
8+
import pytest
9+
from typer.testing import CliRunner
10+
11+
from tablespec.cli import app
12+
13+
pytestmark = [pytest.mark.no_spark, pytest.mark.fast]
14+
15+
runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"})
16+
17+
18+
def _write_umf(tmp_path: Path) -> Path:
19+
path = tmp_path / "table.json"
20+
path.write_text(
21+
json.dumps(
22+
{
23+
"version": "1.0",
24+
"table_name": "TestTable",
25+
"columns": [{"name": "id", "data_type": "INTEGER"}],
26+
"validation_rules": {
27+
"expectations": [
28+
{
29+
"type": "expect_column_values_to_not_be_null",
30+
"kwargs": {"column": "id"},
31+
}
32+
]
33+
},
34+
}
35+
)
36+
)
37+
return path
38+
39+
40+
def _write_response(tmp_path: Path) -> Path:
41+
path = tmp_path / "response.json"
42+
path.write_text(
43+
json.dumps(
44+
[
45+
{
46+
"type": "expect_column_values_to_match_regex",
47+
"kwargs": {"column": "id", "regex": "^\\d+$"},
48+
}
49+
]
50+
)
51+
)
52+
return path
53+
54+
55+
def test_apply_response_persists_expectation_suite(tmp_path: Path) -> None:
56+
umf_file = _write_umf(tmp_path)
57+
response_file = _write_response(tmp_path)
58+
59+
result = runner.invoke(app, ["apply-response", str(umf_file), str(response_file)])
60+
61+
assert result.exit_code == 0
62+
assert "Added:" in result.output
63+
64+
updated = json.loads(umf_file.read_text())
65+
assert "expectations" in updated
66+
assert "validation_rules" not in updated
67+
expectations = updated["expectations"]["expectations"]
68+
assert len(expectations) == 2
69+
assert expectations[1]["meta"]["generated_from"] == "llm"
70+
71+
72+
def test_apply_response_dry_run_does_not_persist(tmp_path: Path) -> None:
73+
umf_file = _write_umf(tmp_path)
74+
response_file = _write_response(tmp_path)
75+
76+
result = runner.invoke(app, ["apply-response", str(umf_file), str(response_file), "--dry-run"])
77+
78+
assert result.exit_code == 0
79+
assert "Dry run" in result.output
80+
81+
updated = json.loads(umf_file.read_text())
82+
assert "validation_rules" in updated
83+
assert "expectations" not in updated

tests/unit/test_mutations.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,26 @@ def test_updates_only_expectations_field(self):
152152
# legacy fields are untouched (None on the builder-produced UMF)
153153
assert result.quality_checks is None
154154
assert result.validation_rules is None
155+
156+
def test_removes_from_migrated_legacy_expectations(self):
157+
umf = UMF(
158+
version="1.0",
159+
table_name="t",
160+
columns=[{"name": "id", "data_type": "INTEGER"}],
161+
validation_rules={
162+
"expectations": [
163+
{
164+
"type": "expect_column_values_to_not_be_null",
165+
"kwargs": {"column": "id"},
166+
}
167+
]
168+
},
169+
)
170+
171+
result, count = remove_expectation(umf, "expect_column_values_to_not_be_null", "id")
172+
173+
assert count == 1
174+
assert result.expectations is not None
175+
assert result.expectations.expectations == []
176+
assert result.validation_rules is None
177+
assert result.quality_checks is None

tests/unit/test_preview.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from tablespec.authoring.preview import generate_preview, PreviewResult
3+
from tablespec.authoring.preview import generate_preview
44

55
pytestmark = [pytest.mark.no_spark, pytest.mark.fast]
66

@@ -113,3 +113,33 @@ def test_severity_from_meta(self):
113113
}
114114
result = generate_preview(data)
115115
assert result.raw[0].severity == "critical"
116+
117+
def test_prefers_expectation_suite_over_legacy_quality_checks(self):
118+
data = {
119+
"expectations": {
120+
"expectations": [
121+
{
122+
"type": "expect_column_values_to_not_be_null",
123+
"kwargs": {"column": "id"},
124+
"meta": {"validation_stage": "raw", "severity": "critical"},
125+
}
126+
]
127+
},
128+
"quality_checks": {
129+
"checks": [
130+
{
131+
"expectation": {
132+
"type": "expect_column_values_to_be_between",
133+
"kwargs": {"column": "age", "min_value": 0},
134+
},
135+
"severity": "warning",
136+
"blocking": False,
137+
}
138+
]
139+
},
140+
}
141+
142+
result = generate_preview(data)
143+
144+
assert len(result.raw) == 1
145+
assert len(result.ingested) == 0

0 commit comments

Comments
 (0)