fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/export_tasks.py +16 -5
- examples/export_tasks_filtered.py +245 -0
- examples/fetch_tasks.py +230 -0
- examples/import_tasks.py +140 -8
- examples/iterate_verifiers.py +725 -0
- fleet/__init__.py +128 -5
- fleet/_async/__init__.py +27 -3
- fleet/_async/base.py +24 -9
- fleet/_async/client.py +938 -41
- fleet/_async/env/client.py +60 -3
- fleet/_async/instance/client.py +52 -7
- fleet/_async/models.py +15 -0
- fleet/_async/resources/api.py +200 -0
- fleet/_async/resources/sqlite.py +1801 -46
- fleet/_async/tasks.py +122 -25
- fleet/_async/verifiers/bundler.py +22 -21
- fleet/_async/verifiers/verifier.py +25 -19
- fleet/agent/__init__.py +32 -0
- fleet/agent/gemini_cua/Dockerfile +45 -0
- fleet/agent/gemini_cua/__init__.py +10 -0
- fleet/agent/gemini_cua/agent.py +759 -0
- fleet/agent/gemini_cua/mcp/main.py +108 -0
- fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
- fleet/agent/gemini_cua/mcp_server/main.py +105 -0
- fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
- fleet/agent/gemini_cua/requirements.txt +5 -0
- fleet/agent/gemini_cua/start.sh +30 -0
- fleet/agent/orchestrator.py +854 -0
- fleet/agent/types.py +49 -0
- fleet/agent/utils.py +34 -0
- fleet/base.py +34 -9
- fleet/cli.py +1061 -0
- fleet/client.py +1060 -48
- fleet/config.py +1 -1
- fleet/env/__init__.py +16 -0
- fleet/env/client.py +60 -3
- fleet/eval/__init__.py +15 -0
- fleet/eval/uploader.py +231 -0
- fleet/exceptions.py +8 -0
- fleet/instance/client.py +53 -8
- fleet/instance/models.py +1 -0
- fleet/models.py +303 -0
- fleet/proxy/__init__.py +25 -0
- fleet/proxy/proxy.py +453 -0
- fleet/proxy/whitelist.py +244 -0
- fleet/resources/api.py +200 -0
- fleet/resources/sqlite.py +1845 -46
- fleet/tasks.py +113 -20
- fleet/utils/__init__.py +7 -0
- fleet/utils/http_logging.py +178 -0
- fleet/utils/logging.py +13 -0
- fleet/utils/playwright.py +440 -0
- fleet/verifiers/bundler.py +22 -21
- fleet/verifiers/db.py +985 -1
- fleet/verifiers/decorator.py +1 -1
- fleet/verifiers/verifier.py +25 -19
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
- fleet_python-0.2.105.dist-info/RECORD +115 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
- fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
- tests/test_app_method.py +85 -0
- tests/test_expect_exactly.py +4148 -0
- tests/test_expect_only.py +2593 -0
- tests/test_instance_dispatch.py +607 -0
- tests/test_sqlite_resource_dual_mode.py +263 -0
- tests/test_sqlite_shared_memory_behavior.py +117 -0
- fleet_python-0.2.66b2.dist-info/RECORD +0 -81
- tests/test_verifier_security.py +0 -427
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
fleet/verifiers/db.py
CHANGED
|
@@ -18,6 +18,7 @@ from datetime import datetime
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
import json
|
|
20
20
|
|
|
21
|
+
|
|
21
22
|
################################################################################
|
|
22
23
|
# Low‑level helpers
|
|
23
24
|
################################################################################
|
|
@@ -59,6 +60,441 @@ def _values_equivalent(val1: Any, val2: Any) -> bool:
|
|
|
59
60
|
return val1 == val2
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def _parse_fields_spec(fields_spec: List[Tuple[str, Any]]) -> Dict[str, Tuple[bool, Any]]:
|
|
64
|
+
"""Parse a fields spec into a mapping of field_name -> (should_check_value, expected_value)."""
|
|
65
|
+
spec_map = {}
|
|
66
|
+
for item in fields_spec:
|
|
67
|
+
if not isinstance(item, (tuple, list)) or len(item) != 2:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Invalid field spec: {item!r}. "
|
|
70
|
+
f"Each field must be a 2-tuple: (field_name, expected_value). "
|
|
71
|
+
f"Use (field_name, ...) to accept any value."
|
|
72
|
+
)
|
|
73
|
+
field_name, expected_value = item
|
|
74
|
+
if expected_value is ...:
|
|
75
|
+
spec_map[field_name] = (False, None) # Don't check value
|
|
76
|
+
else:
|
|
77
|
+
spec_map[field_name] = (True, expected_value)
|
|
78
|
+
return spec_map
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_diff_expect_exactly(
|
|
82
|
+
diff: Dict[str, Any],
|
|
83
|
+
expected_changes: List[Dict[str, Any]],
|
|
84
|
+
ignore_config: Any = None,
|
|
85
|
+
) -> Tuple[bool, Optional[str], List[Tuple[str, str, str]]]:
|
|
86
|
+
"""
|
|
87
|
+
Validate that EXACTLY the specified changes occurred in the diff.
|
|
88
|
+
|
|
89
|
+
This is stricter than expect_only_v2:
|
|
90
|
+
1. All changes in diff must match a spec (no unexpected changes)
|
|
91
|
+
2. All specs must have a matching change in diff (no missing expected changes)
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
diff: The database diff dictionary
|
|
95
|
+
expected_changes: List of expected change specs
|
|
96
|
+
ignore_config: Optional ignore configuration with should_ignore_field method
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Tuple of (success, error_message, matched_specs)
|
|
100
|
+
- success: True if validation passed
|
|
101
|
+
- error_message: Error message if validation failed, None otherwise
|
|
102
|
+
- matched_specs: List of (table, pk, type) tuples that matched
|
|
103
|
+
"""
|
|
104
|
+
# Validate all specs have required fields
|
|
105
|
+
for i, spec in enumerate(expected_changes):
|
|
106
|
+
if "type" not in spec:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Spec at index {i} is missing required 'type' field. "
|
|
109
|
+
f"expect_exactly requires explicit type: 'insert', 'modify', or 'delete'. "
|
|
110
|
+
f"Got: {spec}"
|
|
111
|
+
)
|
|
112
|
+
if spec["type"] not in ("insert", "modify", "delete"):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Spec at index {i} has invalid type '{spec['type']}'. "
|
|
115
|
+
f"Must be 'insert', 'modify', or 'delete'."
|
|
116
|
+
)
|
|
117
|
+
if "table" not in spec:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Spec at index {i} is missing required 'table' field. Got: {spec}"
|
|
120
|
+
)
|
|
121
|
+
if "pk" not in spec:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Spec at index {i} is missing required 'pk' field. Got: {spec}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Collect all errors into categories
|
|
127
|
+
field_mismatches = [] # Changes that happened but with wrong field values
|
|
128
|
+
unexpected_changes = [] # Changes that happened but no spec allows them
|
|
129
|
+
missing_changes = [] # Specs that expect changes that didn't happen
|
|
130
|
+
matched_specs = [] # Successfully matched specs
|
|
131
|
+
near_matches = [] # Potential matches for hints
|
|
132
|
+
|
|
133
|
+
# Build lookup for specs by (table, pk, type)
|
|
134
|
+
spec_lookup = {}
|
|
135
|
+
for spec in expected_changes:
|
|
136
|
+
key = (spec.get("table"), str(spec.get("pk")), spec.get("type"))
|
|
137
|
+
spec_lookup[key] = spec
|
|
138
|
+
|
|
139
|
+
def should_ignore_field(table: str, field: str) -> bool:
|
|
140
|
+
if ignore_config is None:
|
|
141
|
+
return False
|
|
142
|
+
if hasattr(ignore_config, 'should_ignore_field'):
|
|
143
|
+
return ignore_config.should_ignore_field(table, field)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Check each change in the diff
|
|
147
|
+
for tbl, report in diff.items():
|
|
148
|
+
# Check insertions
|
|
149
|
+
for row in report.get("added_rows", []):
|
|
150
|
+
row_id = row["row_id"]
|
|
151
|
+
row_data = row.get("data", {})
|
|
152
|
+
spec_key = (tbl, str(row_id), "insert")
|
|
153
|
+
spec = spec_lookup.get(spec_key)
|
|
154
|
+
|
|
155
|
+
if spec is None:
|
|
156
|
+
# No spec for this insertion
|
|
157
|
+
unexpected_changes.append({
|
|
158
|
+
"type": "insert",
|
|
159
|
+
"table": tbl,
|
|
160
|
+
"pk": row_id,
|
|
161
|
+
"row_data": row_data,
|
|
162
|
+
"reason": "no spec provided",
|
|
163
|
+
})
|
|
164
|
+
elif "fields" in spec and spec["fields"] is not None:
|
|
165
|
+
# Validate fields
|
|
166
|
+
spec_map = _parse_fields_spec(spec["fields"])
|
|
167
|
+
mismatches = []
|
|
168
|
+
for field_name, field_value in row_data.items():
|
|
169
|
+
if field_name == "rowid":
|
|
170
|
+
continue
|
|
171
|
+
if should_ignore_field(tbl, field_name):
|
|
172
|
+
continue
|
|
173
|
+
if field_name not in spec_map:
|
|
174
|
+
mismatches.append((field_name, None, field_value, "not in spec"))
|
|
175
|
+
else:
|
|
176
|
+
should_check, expected = spec_map[field_name]
|
|
177
|
+
if should_check and not _values_equivalent(expected, field_value):
|
|
178
|
+
mismatches.append((field_name, expected, field_value, "value mismatch"))
|
|
179
|
+
|
|
180
|
+
if mismatches:
|
|
181
|
+
field_mismatches.append({
|
|
182
|
+
"type": "insert",
|
|
183
|
+
"table": tbl,
|
|
184
|
+
"pk": row_id,
|
|
185
|
+
"mismatches": mismatches,
|
|
186
|
+
"row_data": row_data,
|
|
187
|
+
})
|
|
188
|
+
else:
|
|
189
|
+
matched_specs.append(spec_key)
|
|
190
|
+
else:
|
|
191
|
+
# Spec without fields - just check it exists
|
|
192
|
+
matched_specs.append(spec_key)
|
|
193
|
+
|
|
194
|
+
# Check deletions
|
|
195
|
+
for row in report.get("removed_rows", []):
|
|
196
|
+
row_id = row["row_id"]
|
|
197
|
+
row_data = row.get("data", {})
|
|
198
|
+
spec_key = (tbl, str(row_id), "delete")
|
|
199
|
+
spec = spec_lookup.get(spec_key)
|
|
200
|
+
|
|
201
|
+
if spec is None:
|
|
202
|
+
unexpected_changes.append({
|
|
203
|
+
"type": "delete",
|
|
204
|
+
"table": tbl,
|
|
205
|
+
"pk": row_id,
|
|
206
|
+
"row_data": row_data,
|
|
207
|
+
"reason": "no spec provided",
|
|
208
|
+
})
|
|
209
|
+
else:
|
|
210
|
+
# For deletes, just matching the pk is enough (unless fields specified)
|
|
211
|
+
if "fields" in spec and spec["fields"] is not None:
|
|
212
|
+
spec_map = _parse_fields_spec(spec["fields"])
|
|
213
|
+
mismatches = []
|
|
214
|
+
for field_name, field_value in row_data.items():
|
|
215
|
+
if field_name == "rowid":
|
|
216
|
+
continue
|
|
217
|
+
if should_ignore_field(tbl, field_name):
|
|
218
|
+
continue
|
|
219
|
+
if field_name in spec_map:
|
|
220
|
+
should_check, expected = spec_map[field_name]
|
|
221
|
+
if should_check and not _values_equivalent(expected, field_value):
|
|
222
|
+
mismatches.append((field_name, expected, field_value, "value mismatch"))
|
|
223
|
+
if mismatches:
|
|
224
|
+
field_mismatches.append({
|
|
225
|
+
"type": "delete",
|
|
226
|
+
"table": tbl,
|
|
227
|
+
"pk": row_id,
|
|
228
|
+
"mismatches": mismatches,
|
|
229
|
+
"row_data": row_data,
|
|
230
|
+
})
|
|
231
|
+
else:
|
|
232
|
+
matched_specs.append(spec_key)
|
|
233
|
+
else:
|
|
234
|
+
matched_specs.append(spec_key)
|
|
235
|
+
|
|
236
|
+
# Check modifications
|
|
237
|
+
for row in report.get("modified_rows", []):
|
|
238
|
+
row_id = row["row_id"]
|
|
239
|
+
row_changes = row.get("changes", {})
|
|
240
|
+
row_data = row.get("data", {})
|
|
241
|
+
spec_key = (tbl, str(row_id), "modify")
|
|
242
|
+
spec = spec_lookup.get(spec_key)
|
|
243
|
+
|
|
244
|
+
if spec is None:
|
|
245
|
+
unexpected_changes.append({
|
|
246
|
+
"type": "modify",
|
|
247
|
+
"table": tbl,
|
|
248
|
+
"pk": row_id,
|
|
249
|
+
"changes": row_changes,
|
|
250
|
+
"row_data": row_data,
|
|
251
|
+
"reason": "no spec provided",
|
|
252
|
+
})
|
|
253
|
+
elif "resulting_fields" in spec and spec["resulting_fields"] is not None:
|
|
254
|
+
# Validate that no_other_changes is provided and is a boolean
|
|
255
|
+
if "no_other_changes" not in spec:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"Modify spec for table '{tbl}' pk={row_id} "
|
|
258
|
+
f"has 'resulting_fields' but missing required 'no_other_changes' field. "
|
|
259
|
+
f"Set 'no_other_changes': True to verify no other fields changed, "
|
|
260
|
+
f"or 'no_other_changes': False to only check the specified fields."
|
|
261
|
+
)
|
|
262
|
+
no_other_changes = spec["no_other_changes"]
|
|
263
|
+
if not isinstance(no_other_changes, bool):
|
|
264
|
+
raise ValueError(
|
|
265
|
+
f"Modify spec for table '{tbl}' pk={row_id} "
|
|
266
|
+
f"has 'no_other_changes' but it must be a boolean (True or False), "
|
|
267
|
+
f"got {type(no_other_changes).__name__}: {repr(no_other_changes)}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
spec_map = _parse_fields_spec(spec["resulting_fields"])
|
|
271
|
+
mismatches = []
|
|
272
|
+
|
|
273
|
+
for field_name, vals in row_changes.items():
|
|
274
|
+
if should_ignore_field(tbl, field_name):
|
|
275
|
+
continue
|
|
276
|
+
after_value = vals["after"]
|
|
277
|
+
if field_name not in spec_map:
|
|
278
|
+
if no_other_changes:
|
|
279
|
+
mismatches.append((field_name, None, after_value, "not in resulting_fields"))
|
|
280
|
+
else:
|
|
281
|
+
should_check, expected = spec_map[field_name]
|
|
282
|
+
if should_check and not _values_equivalent(expected, after_value):
|
|
283
|
+
mismatches.append((field_name, expected, after_value, "value mismatch"))
|
|
284
|
+
|
|
285
|
+
if mismatches:
|
|
286
|
+
field_mismatches.append({
|
|
287
|
+
"type": "modify",
|
|
288
|
+
"table": tbl,
|
|
289
|
+
"pk": row_id,
|
|
290
|
+
"mismatches": mismatches,
|
|
291
|
+
"changes": row_changes,
|
|
292
|
+
"row_data": row_data,
|
|
293
|
+
})
|
|
294
|
+
else:
|
|
295
|
+
matched_specs.append(spec_key)
|
|
296
|
+
else:
|
|
297
|
+
# Spec without resulting_fields - just check it exists
|
|
298
|
+
matched_specs.append(spec_key)
|
|
299
|
+
|
|
300
|
+
# Check for missing expected changes (specs that weren't matched)
|
|
301
|
+
for spec in expected_changes:
|
|
302
|
+
spec_key = (spec.get("table"), str(spec.get("pk")), spec.get("type"))
|
|
303
|
+
if spec_key not in matched_specs:
|
|
304
|
+
# Check if it's already in field_mismatches (partially matched but wrong values)
|
|
305
|
+
already_reported = any(
|
|
306
|
+
fm["table"] == spec.get("table") and
|
|
307
|
+
str(fm["pk"]) == str(spec.get("pk")) and
|
|
308
|
+
fm["type"] == spec.get("type")
|
|
309
|
+
for fm in field_mismatches
|
|
310
|
+
)
|
|
311
|
+
if not already_reported:
|
|
312
|
+
missing_changes.append({
|
|
313
|
+
"type": spec.get("type"),
|
|
314
|
+
"table": spec.get("table"),
|
|
315
|
+
"pk": spec.get("pk"),
|
|
316
|
+
"spec": spec,
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
# Detect near-matches (potential wrong-row scenarios)
|
|
320
|
+
for uc in unexpected_changes:
|
|
321
|
+
for mc in missing_changes:
|
|
322
|
+
if uc["table"] == mc["table"] and uc["type"] == mc["type"]:
|
|
323
|
+
# Same table and operation type, different pk - might be wrong row
|
|
324
|
+
near_matches.append({
|
|
325
|
+
"unexpected": uc,
|
|
326
|
+
"missing": mc,
|
|
327
|
+
"actual_pk": uc["pk"],
|
|
328
|
+
"expected_pk": mc["pk"],
|
|
329
|
+
"operation": uc["type"],
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
# Build error message if there are any errors
|
|
333
|
+
total_errors = len(field_mismatches) + len(unexpected_changes) + len(missing_changes)
|
|
334
|
+
|
|
335
|
+
if total_errors == 0:
|
|
336
|
+
return True, None, matched_specs
|
|
337
|
+
|
|
338
|
+
# Format error message
|
|
339
|
+
error_msg = _format_expect_exactly_error(
|
|
340
|
+
field_mismatches=field_mismatches,
|
|
341
|
+
unexpected_changes=unexpected_changes,
|
|
342
|
+
missing_changes=missing_changes,
|
|
343
|
+
matched_specs=matched_specs,
|
|
344
|
+
near_matches=near_matches,
|
|
345
|
+
total_errors=total_errors,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return False, error_msg, matched_specs
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _format_expect_exactly_error(
|
|
352
|
+
field_mismatches: List[Dict],
|
|
353
|
+
unexpected_changes: List[Dict],
|
|
354
|
+
missing_changes: List[Dict],
|
|
355
|
+
matched_specs: List[Tuple],
|
|
356
|
+
near_matches: List[Dict],
|
|
357
|
+
total_errors: int,
|
|
358
|
+
) -> str:
|
|
359
|
+
"""Format the error message for expect_exactly failures."""
|
|
360
|
+
lines = []
|
|
361
|
+
lines.append("=" * 80)
|
|
362
|
+
lines.append(f"VERIFICATION FAILED: {total_errors} error(s) detected")
|
|
363
|
+
lines.append("=" * 80)
|
|
364
|
+
lines.append("")
|
|
365
|
+
|
|
366
|
+
# Summary
|
|
367
|
+
lines.append("SUMMARY")
|
|
368
|
+
lines.append(f" Matched: {len(matched_specs)} change(s) verified successfully")
|
|
369
|
+
lines.append(f" Errors: {total_errors}")
|
|
370
|
+
if field_mismatches:
|
|
371
|
+
pks = ", ".join(str(fm["pk"]) for fm in field_mismatches)
|
|
372
|
+
lines.append(f" - Field mismatches: {len(field_mismatches)} (pk: {pks})")
|
|
373
|
+
if unexpected_changes:
|
|
374
|
+
pks = ", ".join(str(uc["pk"]) for uc in unexpected_changes)
|
|
375
|
+
lines.append(f" - Unexpected changes: {len(unexpected_changes)} (pk: {pks})")
|
|
376
|
+
if missing_changes:
|
|
377
|
+
pks = ", ".join(str(mc["pk"]) for mc in missing_changes)
|
|
378
|
+
lines.append(f" - Missing changes: {len(missing_changes)} (pk: {pks})")
|
|
379
|
+
lines.append("")
|
|
380
|
+
|
|
381
|
+
error_num = 1
|
|
382
|
+
|
|
383
|
+
# Field mismatches section
|
|
384
|
+
if field_mismatches:
|
|
385
|
+
lines.append("-" * 80)
|
|
386
|
+
lines.append(f"FIELD MISMATCHES ({len(field_mismatches)})")
|
|
387
|
+
lines.append("-" * 80)
|
|
388
|
+
lines.append("")
|
|
389
|
+
|
|
390
|
+
for fm in field_mismatches:
|
|
391
|
+
op_type = fm["type"].upper()
|
|
392
|
+
lines.append(f"[{error_num}] {op_type} '{fm['table']}' pk={fm['pk']}")
|
|
393
|
+
lines.append("")
|
|
394
|
+
# Side-by-side comparison table
|
|
395
|
+
lines.append(" FIELD EXPECTED ACTUAL")
|
|
396
|
+
lines.append(" " + "-" * 85)
|
|
397
|
+
for field_name, expected, actual, reason in fm["mismatches"]:
|
|
398
|
+
# Truncate field name if too long
|
|
399
|
+
field_display = field_name if len(field_name) <= 20 else field_name[:17] + "..."
|
|
400
|
+
|
|
401
|
+
# Generate clear error message based on reason
|
|
402
|
+
if reason == "not in spec":
|
|
403
|
+
# Insert: field in row but not in fields spec
|
|
404
|
+
exp_str = f"(field '{field_name}' not specified in expected fields)"
|
|
405
|
+
elif reason == "not in resulting_fields":
|
|
406
|
+
# Modify: field changed but not in resulting_fields
|
|
407
|
+
exp_str = f"(field '{field_name}' not specified in resulting_fields)"
|
|
408
|
+
elif expected is None:
|
|
409
|
+
exp_str = "None" # Explicitly expected NULL
|
|
410
|
+
else:
|
|
411
|
+
exp_str = repr(expected)
|
|
412
|
+
act_str = repr(actual)
|
|
413
|
+
# Truncate long values (but not the descriptive error messages)
|
|
414
|
+
if not exp_str.startswith("(field"):
|
|
415
|
+
if len(exp_str) > 20:
|
|
416
|
+
exp_str = exp_str[:17] + "..."
|
|
417
|
+
if len(act_str) > 20:
|
|
418
|
+
act_str = act_str[:17] + "..."
|
|
419
|
+
lines.append(f" {field_display:<20} {exp_str:<45} {act_str:<20}")
|
|
420
|
+
lines.append("")
|
|
421
|
+
error_num += 1
|
|
422
|
+
|
|
423
|
+
# Unexpected changes section
|
|
424
|
+
if unexpected_changes:
|
|
425
|
+
lines.append("-" * 80)
|
|
426
|
+
lines.append(f"UNEXPECTED CHANGES ({len(unexpected_changes)})")
|
|
427
|
+
lines.append("-" * 80)
|
|
428
|
+
lines.append("")
|
|
429
|
+
|
|
430
|
+
for uc in unexpected_changes:
|
|
431
|
+
op_type = uc["type"].upper()
|
|
432
|
+
lines.append(f"[{error_num}] {op_type} '{uc['table']}' pk={uc['pk']}")
|
|
433
|
+
lines.append(f" No spec was provided for this {uc['type']}.")
|
|
434
|
+
if "row_data" in uc and uc["row_data"]:
|
|
435
|
+
# Format row data compactly
|
|
436
|
+
data_parts = []
|
|
437
|
+
for k, v in list(uc["row_data"].items())[:4]:
|
|
438
|
+
if k != "rowid":
|
|
439
|
+
data_parts.append(f"{k}={repr(v)}")
|
|
440
|
+
data_str = ", ".join(data_parts)
|
|
441
|
+
if len(uc["row_data"]) > 4:
|
|
442
|
+
data_str += f", ... +{len(uc['row_data']) - 4} more"
|
|
443
|
+
lines.append(f" Row data: {{{data_str}}}")
|
|
444
|
+
lines.append("")
|
|
445
|
+
error_num += 1
|
|
446
|
+
|
|
447
|
+
# Missing expected changes section
|
|
448
|
+
if missing_changes:
|
|
449
|
+
lines.append("-" * 80)
|
|
450
|
+
lines.append(f"MISSING EXPECTED CHANGES ({len(missing_changes)})")
|
|
451
|
+
lines.append("-" * 80)
|
|
452
|
+
lines.append("")
|
|
453
|
+
|
|
454
|
+
for mc in missing_changes:
|
|
455
|
+
op_type = mc["type"].upper()
|
|
456
|
+
lines.append(f"[{error_num}] {op_type} '{mc['table']}' pk={mc['pk']}")
|
|
457
|
+
if mc["type"] == "insert":
|
|
458
|
+
lines.append(f" Expected this row to be INSERTED, but it was not added.")
|
|
459
|
+
if "spec" in mc and "fields" in mc["spec"] and mc["spec"]["fields"]:
|
|
460
|
+
lines.append(" Expected fields:")
|
|
461
|
+
for field_name, value in mc["spec"]["fields"][:5]:
|
|
462
|
+
if value is ...:
|
|
463
|
+
lines.append(f" - {field_name}: (any value)")
|
|
464
|
+
else:
|
|
465
|
+
lines.append(f" - {field_name}: {repr(value)}")
|
|
466
|
+
if len(mc["spec"]["fields"]) > 5:
|
|
467
|
+
lines.append(f" ... +{len(mc['spec']['fields']) - 5} more")
|
|
468
|
+
elif mc["type"] == "delete":
|
|
469
|
+
lines.append(f" Expected this row to be DELETED, but it still exists.")
|
|
470
|
+
elif mc["type"] == "modify":
|
|
471
|
+
lines.append(f" Expected this row to be MODIFIED, but it was not changed.")
|
|
472
|
+
if "spec" in mc and "resulting_fields" in mc["spec"] and mc["spec"]["resulting_fields"]:
|
|
473
|
+
lines.append(" Expected resulting values:")
|
|
474
|
+
for field_name, value in mc["spec"]["resulting_fields"][:5]:
|
|
475
|
+
if value is ...:
|
|
476
|
+
lines.append(f" - {field_name}: (any value)")
|
|
477
|
+
else:
|
|
478
|
+
lines.append(f" - {field_name}: {repr(value)}")
|
|
479
|
+
lines.append("")
|
|
480
|
+
error_num += 1
|
|
481
|
+
|
|
482
|
+
# Near-match hints section
|
|
483
|
+
if near_matches:
|
|
484
|
+
lines.append("-" * 80)
|
|
485
|
+
lines.append("HINTS: Possible related errors (near-matches detected)")
|
|
486
|
+
lines.append("-" * 80)
|
|
487
|
+
lines.append("")
|
|
488
|
+
for nm in near_matches:
|
|
489
|
+
op_type = nm["operation"].upper()
|
|
490
|
+
lines.append(f" * {op_type} row {nm['actual_pk']} might be intended as row {nm['expected_pk']}")
|
|
491
|
+
lines.append("")
|
|
492
|
+
|
|
493
|
+
lines.append("=" * 80)
|
|
494
|
+
|
|
495
|
+
return "\n".join(lines)
|
|
496
|
+
|
|
497
|
+
|
|
62
498
|
class _CountResult:
|
|
63
499
|
"""Wraps an integer count so we can chain assertions fluently."""
|
|
64
500
|
|
|
@@ -597,6 +1033,554 @@ class SnapshotDiff:
|
|
|
597
1033
|
|
|
598
1034
|
return self
|
|
599
1035
|
|
|
1036
|
+
# ------------------------------------------------------------------
|
|
1037
|
+
def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]):
|
|
1038
|
+
"""Allowed changes with bulk field spec support and explicit type field.
|
|
1039
|
+
|
|
1040
|
+
This version supports explicit change types via the "type" field:
|
|
1041
|
+
1. Insert specs: {"table": "t", "pk": 1, "type": "insert", "fields": [("name", "value"), ("status", ...)]}
|
|
1042
|
+
- ("name", value): check that field equals value
|
|
1043
|
+
- ("name", None): check that field is SQL NULL
|
|
1044
|
+
- ("name", ...): don't check the value, just acknowledge the field exists
|
|
1045
|
+
2. Modify specs: {"table": "t", "pk": 1, "type": "modify", "resulting_fields": [...], "no_other_changes": True/False}
|
|
1046
|
+
- Uses "resulting_fields" (not "fields") to be explicit about what's being checked
|
|
1047
|
+
- "no_other_changes" is REQUIRED and must be True or False:
|
|
1048
|
+
- True: Every changed field must be in resulting_fields (strict mode)
|
|
1049
|
+
- False: Only check fields in resulting_fields match, ignore other changes
|
|
1050
|
+
- ("field_name", value): check that after value equals value
|
|
1051
|
+
- ("field_name", None): check that after value is SQL NULL
|
|
1052
|
+
- ("field_name", ...): don't check value, just acknowledge field changed
|
|
1053
|
+
3. Delete specs:
|
|
1054
|
+
- Without field validation: {"table": "t", "pk": 1, "type": "delete"}
|
|
1055
|
+
- With field validation: {"table": "t", "pk": 1, "type": "delete", "fields": [...]}
|
|
1056
|
+
4. Whole-row specs (legacy):
|
|
1057
|
+
- For additions: {"table": "t", "pk": 1, "fields": None, "after": "__added__"}
|
|
1058
|
+
- For deletions: {"table": "t", "pk": 1, "fields": None, "after": "__removed__"}
|
|
1059
|
+
|
|
1060
|
+
When using "fields" for inserts, every field must be accounted for in the list.
|
|
1061
|
+
For modifications, use "resulting_fields" with explicit "no_other_changes".
|
|
1062
|
+
For deletions with "fields", all specified fields are validated against the deleted row.
|
|
1063
|
+
"""
|
|
1064
|
+
diff = self._collect()
|
|
1065
|
+
|
|
1066
|
+
def _is_change_allowed(
|
|
1067
|
+
table: str, row_id: str, field: Optional[str], after_value: Any
|
|
1068
|
+
) -> bool:
|
|
1069
|
+
"""Check if a change is in the allowed list using semantic comparison."""
|
|
1070
|
+
for allowed in allowed_changes:
|
|
1071
|
+
allowed_pk = allowed.get("pk")
|
|
1072
|
+
# Handle type conversion for primary key comparison
|
|
1073
|
+
# Convert both to strings for comparison to handle int/string mismatches
|
|
1074
|
+
pk_match = (
|
|
1075
|
+
str(allowed_pk) == str(row_id) if allowed_pk is not None else False
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
# For whole-row specs, check "fields": None; for field-level, check "field"
|
|
1079
|
+
field_match = (
|
|
1080
|
+
("fields" in allowed and allowed.get("fields") is None)
|
|
1081
|
+
if field is None
|
|
1082
|
+
else allowed.get("field") == field
|
|
1083
|
+
)
|
|
1084
|
+
if (
|
|
1085
|
+
allowed["table"] == table
|
|
1086
|
+
and pk_match
|
|
1087
|
+
and field_match
|
|
1088
|
+
and _values_equivalent(allowed.get("after"), after_value)
|
|
1089
|
+
):
|
|
1090
|
+
return True
|
|
1091
|
+
return False
|
|
1092
|
+
|
|
1093
|
+
def _get_fields_spec_for_type(table: str, row_id: str, change_type: str) -> Optional[List[tuple]]:
|
|
1094
|
+
"""Get the bulk fields spec for a given table/row/type if it exists.
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
table: The table name
|
|
1098
|
+
row_id: The primary key value
|
|
1099
|
+
change_type: One of "insert", "modify", or "delete"
|
|
1100
|
+
|
|
1101
|
+
Note: For "modify" type, use _get_modify_spec instead.
|
|
1102
|
+
"""
|
|
1103
|
+
for allowed in allowed_changes:
|
|
1104
|
+
allowed_pk = allowed.get("pk")
|
|
1105
|
+
pk_match = (
|
|
1106
|
+
str(allowed_pk) == str(row_id) if allowed_pk is not None else False
|
|
1107
|
+
)
|
|
1108
|
+
if (
|
|
1109
|
+
allowed["table"] == table
|
|
1110
|
+
and pk_match
|
|
1111
|
+
and allowed.get("type") == change_type
|
|
1112
|
+
and "fields" in allowed
|
|
1113
|
+
):
|
|
1114
|
+
return allowed["fields"]
|
|
1115
|
+
return None
|
|
1116
|
+
|
|
1117
|
+
def _get_modify_spec(table: str, row_id: str) -> Optional[Dict[str, Any]]:
|
|
1118
|
+
"""Get the modify spec for a given table/row if it exists.
|
|
1119
|
+
|
|
1120
|
+
Returns the full spec dict containing:
|
|
1121
|
+
- resulting_fields: List of field tuples
|
|
1122
|
+
- no_other_changes: Boolean (required)
|
|
1123
|
+
|
|
1124
|
+
Returns None if no modify spec found.
|
|
1125
|
+
"""
|
|
1126
|
+
for allowed in allowed_changes:
|
|
1127
|
+
allowed_pk = allowed.get("pk")
|
|
1128
|
+
pk_match = (
|
|
1129
|
+
str(allowed_pk) == str(row_id) if allowed_pk is not None else False
|
|
1130
|
+
)
|
|
1131
|
+
if (
|
|
1132
|
+
allowed["table"] == table
|
|
1133
|
+
and pk_match
|
|
1134
|
+
and allowed.get("type") == "modify"
|
|
1135
|
+
):
|
|
1136
|
+
return allowed
|
|
1137
|
+
return None
|
|
1138
|
+
|
|
1139
|
+
def _is_type_allowed(table: str, row_id: str, change_type: str) -> bool:
|
|
1140
|
+
"""Check if a change type is allowed for the given table/row (with or without fields)."""
|
|
1141
|
+
for allowed in allowed_changes:
|
|
1142
|
+
allowed_pk = allowed.get("pk")
|
|
1143
|
+
pk_match = (
|
|
1144
|
+
str(allowed_pk) == str(row_id) if allowed_pk is not None else False
|
|
1145
|
+
)
|
|
1146
|
+
if allowed["table"] == table and pk_match and allowed.get("type") == change_type:
|
|
1147
|
+
return True
|
|
1148
|
+
return False
|
|
1149
|
+
|
|
1150
|
+
def _parse_fields_spec(fields_spec: List[tuple]) -> Dict[str, tuple]:
|
|
1151
|
+
"""Parse a fields spec into a mapping of field_name -> (should_check_value, expected_value)."""
|
|
1152
|
+
spec_map: Dict[str, tuple] = {}
|
|
1153
|
+
for spec_tuple in fields_spec:
|
|
1154
|
+
if len(spec_tuple) != 2:
|
|
1155
|
+
raise ValueError(
|
|
1156
|
+
f"Invalid field spec tuple: {spec_tuple}. "
|
|
1157
|
+
f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
|
|
1158
|
+
)
|
|
1159
|
+
field_name, expected_value = spec_tuple
|
|
1160
|
+
if expected_value is ...:
|
|
1161
|
+
# Ellipsis: don't check value, just acknowledge field exists
|
|
1162
|
+
spec_map[field_name] = (False, None)
|
|
1163
|
+
else:
|
|
1164
|
+
# Any other value (including None for NULL check): check value
|
|
1165
|
+
spec_map[field_name] = (True, expected_value)
|
|
1166
|
+
return spec_map
|
|
1167
|
+
|
|
1168
|
+
def _validate_row_with_fields_spec(
|
|
1169
|
+
table: str,
|
|
1170
|
+
row_id: str,
|
|
1171
|
+
row_data: Dict[str, Any],
|
|
1172
|
+
fields_spec: List[tuple],
|
|
1173
|
+
) -> Optional[List[tuple]]:
|
|
1174
|
+
"""Validate a row against a bulk fields spec.
|
|
1175
|
+
|
|
1176
|
+
Returns None if validation passes, or a list of (field, actual_value, issue)
|
|
1177
|
+
tuples for mismatches.
|
|
1178
|
+
|
|
1179
|
+
Field spec semantics:
|
|
1180
|
+
- ("field_name", value): check that field equals value
|
|
1181
|
+
- ("field_name", None): check that field is SQL NULL
|
|
1182
|
+
- ("field_name", ...): don't check value (acknowledge field exists)
|
|
1183
|
+
"""
|
|
1184
|
+
spec_map = _parse_fields_spec(fields_spec)
|
|
1185
|
+
unmatched_fields = []
|
|
1186
|
+
|
|
1187
|
+
for field_name, field_value in row_data.items():
|
|
1188
|
+
# Skip rowid as it's internal
|
|
1189
|
+
if field_name == "rowid":
|
|
1190
|
+
continue
|
|
1191
|
+
# Skip ignored fields
|
|
1192
|
+
if self.ignore_config.should_ignore_field(table, field_name):
|
|
1193
|
+
continue
|
|
1194
|
+
|
|
1195
|
+
if field_name not in spec_map:
|
|
1196
|
+
# Field not in spec - this is an error
|
|
1197
|
+
unmatched_fields.append(
|
|
1198
|
+
(field_name, field_value, "NOT_IN_FIELDS_SPEC")
|
|
1199
|
+
)
|
|
1200
|
+
else:
|
|
1201
|
+
should_check, expected_value = spec_map[field_name]
|
|
1202
|
+
if should_check and not _values_equivalent(
|
|
1203
|
+
expected_value, field_value
|
|
1204
|
+
):
|
|
1205
|
+
# Value doesn't match
|
|
1206
|
+
unmatched_fields.append(
|
|
1207
|
+
(field_name, field_value, f"expected {repr(expected_value)}")
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
return unmatched_fields if unmatched_fields else None
|
|
1211
|
+
|
|
1212
|
+
def _validate_modification_with_fields_spec(
|
|
1213
|
+
table: str,
|
|
1214
|
+
row_id: str,
|
|
1215
|
+
row_changes: Dict[str, Dict[str, Any]],
|
|
1216
|
+
resulting_fields: List[tuple],
|
|
1217
|
+
no_other_changes: bool,
|
|
1218
|
+
) -> Optional[List[tuple]]:
|
|
1219
|
+
"""Validate a modification against a resulting_fields spec.
|
|
1220
|
+
|
|
1221
|
+
Returns None if validation passes, or a list of (field, actual_value, issue)
|
|
1222
|
+
tuples for mismatches.
|
|
1223
|
+
|
|
1224
|
+
Args:
|
|
1225
|
+
table: The table name
|
|
1226
|
+
row_id: The row primary key
|
|
1227
|
+
row_changes: Dict of field_name -> {"before": ..., "after": ...}
|
|
1228
|
+
resulting_fields: List of field tuples to validate
|
|
1229
|
+
no_other_changes: If True, all changed fields must be in resulting_fields.
|
|
1230
|
+
If False, only validate fields in resulting_fields, ignore others.
|
|
1231
|
+
|
|
1232
|
+
Field spec semantics for modifications:
|
|
1233
|
+
- ("field_name", value): check that after value equals value
|
|
1234
|
+
- ("field_name", None): check that after value is SQL NULL
|
|
1235
|
+
- ("field_name", ...): don't check value, just acknowledge field changed
|
|
1236
|
+
"""
|
|
1237
|
+
spec_map = _parse_fields_spec(resulting_fields)
|
|
1238
|
+
unmatched_fields = []
|
|
1239
|
+
|
|
1240
|
+
for field_name, vals in row_changes.items():
|
|
1241
|
+
# Skip ignored fields
|
|
1242
|
+
if self.ignore_config.should_ignore_field(table, field_name):
|
|
1243
|
+
continue
|
|
1244
|
+
|
|
1245
|
+
after_value = vals["after"]
|
|
1246
|
+
|
|
1247
|
+
if field_name not in spec_map:
|
|
1248
|
+
# Changed field not in spec
|
|
1249
|
+
if no_other_changes:
|
|
1250
|
+
# Strict mode: all changed fields must be accounted for
|
|
1251
|
+
unmatched_fields.append(
|
|
1252
|
+
(field_name, after_value, "NOT_IN_RESULTING_FIELDS")
|
|
1253
|
+
)
|
|
1254
|
+
# If no_other_changes=False, ignore fields not in spec
|
|
1255
|
+
else:
|
|
1256
|
+
should_check, expected_value = spec_map[field_name]
|
|
1257
|
+
if should_check and not _values_equivalent(
|
|
1258
|
+
expected_value, after_value
|
|
1259
|
+
):
|
|
1260
|
+
# Value doesn't match
|
|
1261
|
+
unmatched_fields.append(
|
|
1262
|
+
(field_name, after_value, f"expected {repr(expected_value)}")
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
return unmatched_fields if unmatched_fields else None
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
# Collect all unexpected changes for detailed reporting
|
|
1269
|
+
unexpected_changes = []
|
|
1270
|
+
|
|
1271
|
+
for tbl, report in diff.items():
|
|
1272
|
+
for row in report.get("modified_rows", []):
|
|
1273
|
+
row_changes = row["changes"]
|
|
1274
|
+
|
|
1275
|
+
# Check for modify spec with resulting_fields
|
|
1276
|
+
modify_spec = _get_modify_spec(tbl, row["row_id"])
|
|
1277
|
+
if modify_spec is not None:
|
|
1278
|
+
resulting_fields = modify_spec.get("resulting_fields")
|
|
1279
|
+
if resulting_fields is not None:
|
|
1280
|
+
# Validate that no_other_changes is provided
|
|
1281
|
+
if "no_other_changes" not in modify_spec:
|
|
1282
|
+
raise ValueError(
|
|
1283
|
+
f"Modify spec for table '{tbl}' pk={row['row_id']} "
|
|
1284
|
+
f"has 'resulting_fields' but missing required 'no_other_changes' field. "
|
|
1285
|
+
f"Set 'no_other_changes': True to verify no other fields changed, "
|
|
1286
|
+
f"or 'no_other_changes': False to only check the specified fields."
|
|
1287
|
+
)
|
|
1288
|
+
no_other_changes = modify_spec["no_other_changes"]
|
|
1289
|
+
if not isinstance(no_other_changes, bool):
|
|
1290
|
+
raise ValueError(
|
|
1291
|
+
f"Modify spec for table '{tbl}' pk={row['row_id']} "
|
|
1292
|
+
f"has 'no_other_changes' but it must be a boolean (True or False), "
|
|
1293
|
+
f"got {type(no_other_changes).__name__}: {repr(no_other_changes)}"
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
unmatched = _validate_modification_with_fields_spec(
|
|
1297
|
+
tbl, row["row_id"], row_changes, resulting_fields, no_other_changes
|
|
1298
|
+
)
|
|
1299
|
+
if unmatched:
|
|
1300
|
+
unexpected_changes.append(
|
|
1301
|
+
{
|
|
1302
|
+
"type": "modification",
|
|
1303
|
+
"table": tbl,
|
|
1304
|
+
"row_id": row["row_id"],
|
|
1305
|
+
"field": None,
|
|
1306
|
+
"before": None,
|
|
1307
|
+
"after": None,
|
|
1308
|
+
"full_row": row,
|
|
1309
|
+
"unmatched_fields": unmatched,
|
|
1310
|
+
}
|
|
1311
|
+
)
|
|
1312
|
+
continue # Skip to next row
|
|
1313
|
+
else:
|
|
1314
|
+
# Modify spec without resulting_fields - just allow the modification
|
|
1315
|
+
continue # Skip to next row
|
|
1316
|
+
|
|
1317
|
+
# Fall back to single-field specs (legacy)
|
|
1318
|
+
for f, vals in row_changes.items():
|
|
1319
|
+
if self.ignore_config.should_ignore_field(tbl, f):
|
|
1320
|
+
continue
|
|
1321
|
+
if not _is_change_allowed(tbl, row["row_id"], f, vals["after"]):
|
|
1322
|
+
unexpected_changes.append(
|
|
1323
|
+
{
|
|
1324
|
+
"type": "modification",
|
|
1325
|
+
"table": tbl,
|
|
1326
|
+
"row_id": row["row_id"],
|
|
1327
|
+
"field": f,
|
|
1328
|
+
"before": vals.get("before"),
|
|
1329
|
+
"after": vals["after"],
|
|
1330
|
+
"full_row": row,
|
|
1331
|
+
}
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
for row in report.get("added_rows", []):
|
|
1335
|
+
row_data = row.get("data", {})
|
|
1336
|
+
|
|
1337
|
+
# Check for bulk fields spec (type: "insert")
|
|
1338
|
+
fields_spec = _get_fields_spec_for_type(tbl, row["row_id"], "insert")
|
|
1339
|
+
if fields_spec is not None:
|
|
1340
|
+
unmatched = _validate_row_with_fields_spec(
|
|
1341
|
+
tbl, row["row_id"], row_data, fields_spec
|
|
1342
|
+
)
|
|
1343
|
+
if unmatched:
|
|
1344
|
+
unexpected_changes.append(
|
|
1345
|
+
{
|
|
1346
|
+
"type": "insertion",
|
|
1347
|
+
"table": tbl,
|
|
1348
|
+
"row_id": row["row_id"],
|
|
1349
|
+
"field": None,
|
|
1350
|
+
"after": "__added__",
|
|
1351
|
+
"full_row": row,
|
|
1352
|
+
"unmatched_fields": unmatched,
|
|
1353
|
+
}
|
|
1354
|
+
)
|
|
1355
|
+
continue # Skip to next row
|
|
1356
|
+
|
|
1357
|
+
# Check if insertion is allowed without field validation
|
|
1358
|
+
if _is_type_allowed(tbl, row["row_id"], "insert"):
|
|
1359
|
+
continue # Insertion is allowed, skip to next row
|
|
1360
|
+
|
|
1361
|
+
# Check for whole-row spec (legacy)
|
|
1362
|
+
whole_row_allowed = _is_change_allowed(
|
|
1363
|
+
tbl, row["row_id"], None, "__added__"
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
if not whole_row_allowed:
|
|
1367
|
+
unexpected_changes.append(
|
|
1368
|
+
{
|
|
1369
|
+
"type": "insertion",
|
|
1370
|
+
"table": tbl,
|
|
1371
|
+
"row_id": row["row_id"],
|
|
1372
|
+
"field": None,
|
|
1373
|
+
"after": "__added__",
|
|
1374
|
+
"full_row": row,
|
|
1375
|
+
}
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
for row in report.get("removed_rows", []):
|
|
1379
|
+
row_data = row.get("data", {})
|
|
1380
|
+
|
|
1381
|
+
# Check for bulk fields spec (type: "delete")
|
|
1382
|
+
fields_spec = _get_fields_spec_for_type(tbl, row["row_id"], "delete")
|
|
1383
|
+
if fields_spec is not None:
|
|
1384
|
+
unmatched = _validate_row_with_fields_spec(
|
|
1385
|
+
tbl, row["row_id"], row_data, fields_spec
|
|
1386
|
+
)
|
|
1387
|
+
if unmatched:
|
|
1388
|
+
unexpected_changes.append(
|
|
1389
|
+
{
|
|
1390
|
+
"type": "deletion",
|
|
1391
|
+
"table": tbl,
|
|
1392
|
+
"row_id": row["row_id"],
|
|
1393
|
+
"field": None,
|
|
1394
|
+
"after": "__removed__",
|
|
1395
|
+
"full_row": row,
|
|
1396
|
+
"unmatched_fields": unmatched,
|
|
1397
|
+
}
|
|
1398
|
+
)
|
|
1399
|
+
continue # Skip to next row
|
|
1400
|
+
|
|
1401
|
+
# Check if deletion is allowed without field validation
|
|
1402
|
+
if _is_type_allowed(tbl, row["row_id"], "delete"):
|
|
1403
|
+
continue # Deletion is allowed, skip to next row
|
|
1404
|
+
|
|
1405
|
+
# Check for whole-row spec (legacy)
|
|
1406
|
+
whole_row_allowed = _is_change_allowed(
|
|
1407
|
+
tbl, row["row_id"], None, "__removed__"
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
if not whole_row_allowed:
|
|
1411
|
+
unexpected_changes.append(
|
|
1412
|
+
{
|
|
1413
|
+
"type": "deletion",
|
|
1414
|
+
"table": tbl,
|
|
1415
|
+
"row_id": row["row_id"],
|
|
1416
|
+
"field": None,
|
|
1417
|
+
"after": "__removed__",
|
|
1418
|
+
"full_row": row,
|
|
1419
|
+
}
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
if unexpected_changes:
|
|
1423
|
+
# Build comprehensive error message
|
|
1424
|
+
error_lines = ["Unexpected database changes detected:"]
|
|
1425
|
+
error_lines.append("")
|
|
1426
|
+
|
|
1427
|
+
for i, change in enumerate(
|
|
1428
|
+
unexpected_changes[:5], 1
|
|
1429
|
+
): # Show first 5 changes
|
|
1430
|
+
error_lines.append(
|
|
1431
|
+
f"{i}. {change['type'].upper()} in table '{change['table']}':"
|
|
1432
|
+
)
|
|
1433
|
+
error_lines.append(f" Row ID: {change['row_id']}")
|
|
1434
|
+
|
|
1435
|
+
if change["type"] == "modification":
|
|
1436
|
+
error_lines.append(f" Field: {change['field']}")
|
|
1437
|
+
error_lines.append(f" Before: {repr(change['before'])}")
|
|
1438
|
+
error_lines.append(f" After: {repr(change['after'])}")
|
|
1439
|
+
elif change["type"] == "insertion":
|
|
1440
|
+
error_lines.append(" New row added")
|
|
1441
|
+
elif change["type"] == "deletion":
|
|
1442
|
+
error_lines.append(" Row deleted")
|
|
1443
|
+
|
|
1444
|
+
# Show unmatched fields if present (from bulk fields spec validation)
|
|
1445
|
+
if "unmatched_fields" in change and change["unmatched_fields"]:
|
|
1446
|
+
error_lines.append(" Unmatched fields:")
|
|
1447
|
+
for field_info in change["unmatched_fields"][:5]:
|
|
1448
|
+
field_name, actual_value, issue = field_info
|
|
1449
|
+
error_lines.append(
|
|
1450
|
+
f" - {field_name}: {repr(actual_value)} ({issue})"
|
|
1451
|
+
)
|
|
1452
|
+
if len(change["unmatched_fields"]) > 10:
|
|
1453
|
+
error_lines.append(
|
|
1454
|
+
f" ... and {len(change['unmatched_fields']) - 10} more"
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
# Show some context from the row
|
|
1458
|
+
if "full_row" in change and change["full_row"]:
|
|
1459
|
+
row_data = change["full_row"]
|
|
1460
|
+
if change["type"] == "modification" and "data" in row_data:
|
|
1461
|
+
# For modifications, show the current state
|
|
1462
|
+
formatted_row = _format_row_for_error(
|
|
1463
|
+
row_data.get("data", {}), max_fields=5
|
|
1464
|
+
)
|
|
1465
|
+
error_lines.append(f" Row data: {formatted_row}")
|
|
1466
|
+
elif (
|
|
1467
|
+
change["type"] in ["insertion", "deletion"]
|
|
1468
|
+
and "data" in row_data
|
|
1469
|
+
):
|
|
1470
|
+
# For insertions/deletions, show the row data
|
|
1471
|
+
formatted_row = _format_row_for_error(
|
|
1472
|
+
row_data.get("data", {}), max_fields=5
|
|
1473
|
+
)
|
|
1474
|
+
error_lines.append(f" Row data: {formatted_row}")
|
|
1475
|
+
|
|
1476
|
+
error_lines.append("")
|
|
1477
|
+
|
|
1478
|
+
if len(unexpected_changes) > 10:
|
|
1479
|
+
error_lines.append(
|
|
1480
|
+
f"... and {len(unexpected_changes) - 10} more unexpected changes"
|
|
1481
|
+
)
|
|
1482
|
+
error_lines.append("")
|
|
1483
|
+
|
|
1484
|
+
# Show what changes were allowed
|
|
1485
|
+
error_lines.append("Allowed changes were:")
|
|
1486
|
+
if allowed_changes:
|
|
1487
|
+
for i, allowed in enumerate(allowed_changes[:3], 1):
|
|
1488
|
+
change_type = allowed.get("type", "unspecified")
|
|
1489
|
+
|
|
1490
|
+
# For modify type, use resulting_fields
|
|
1491
|
+
if change_type == "modify" and "resulting_fields" in allowed and allowed["resulting_fields"] is not None:
|
|
1492
|
+
fields_summary = ", ".join(
|
|
1493
|
+
f[0] if len(f) == 1 else f"{f[0]}={'NOT_CHECKED' if f[1] is ... else repr(f[1])}"
|
|
1494
|
+
for f in allowed["resulting_fields"][:3]
|
|
1495
|
+
)
|
|
1496
|
+
if len(allowed["resulting_fields"]) > 3:
|
|
1497
|
+
fields_summary += f", ... +{len(allowed['resulting_fields']) - 3} more"
|
|
1498
|
+
no_other = allowed.get("no_other_changes", "NOT_SET")
|
|
1499
|
+
error_lines.append(
|
|
1500
|
+
f" {i}. Table: {allowed.get('table')}, "
|
|
1501
|
+
f"ID: {allowed.get('pk')}, "
|
|
1502
|
+
f"Type: {change_type}, "
|
|
1503
|
+
f"resulting_fields: [{fields_summary}], "
|
|
1504
|
+
f"no_other_changes: {no_other}"
|
|
1505
|
+
)
|
|
1506
|
+
elif "fields" in allowed and allowed["fields"] is not None:
|
|
1507
|
+
# Show bulk fields spec (for insert/delete)
|
|
1508
|
+
fields_summary = ", ".join(
|
|
1509
|
+
f[0] if len(f) == 1 else f"{f[0]}={'NOT_CHECKED' if f[1] is ... else repr(f[1])}"
|
|
1510
|
+
for f in allowed["fields"][:3]
|
|
1511
|
+
)
|
|
1512
|
+
if len(allowed["fields"]) > 3:
|
|
1513
|
+
fields_summary += f", ... +{len(allowed['fields']) - 3} more"
|
|
1514
|
+
error_lines.append(
|
|
1515
|
+
f" {i}. Table: {allowed.get('table')}, "
|
|
1516
|
+
f"ID: {allowed.get('pk')}, "
|
|
1517
|
+
f"Type: {change_type}, "
|
|
1518
|
+
f"Fields: [{fields_summary}]"
|
|
1519
|
+
)
|
|
1520
|
+
else:
|
|
1521
|
+
error_lines.append(
|
|
1522
|
+
f" {i}. Table: {allowed.get('table')}, "
|
|
1523
|
+
f"ID: {allowed.get('pk')}, "
|
|
1524
|
+
f"Type: {change_type}"
|
|
1525
|
+
)
|
|
1526
|
+
if len(allowed_changes) > 3:
|
|
1527
|
+
error_lines.append(
|
|
1528
|
+
f" ... and {len(allowed_changes) - 3} more allowed changes"
|
|
1529
|
+
)
|
|
1530
|
+
else:
|
|
1531
|
+
error_lines.append(" (No changes were allowed)")
|
|
1532
|
+
|
|
1533
|
+
raise AssertionError("\n".join(error_lines))
|
|
1534
|
+
|
|
1535
|
+
return self
|
|
1536
|
+
|
|
1537
|
+
def expect_exactly(self, expected_changes: List[Dict[str, Any]]):
|
|
1538
|
+
"""Verify that EXACTLY the specified changes occurred.
|
|
1539
|
+
|
|
1540
|
+
This is stricter than expect_only_v2:
|
|
1541
|
+
1. All changes in diff must match a spec (no unexpected changes)
|
|
1542
|
+
2. All specs must have a matching change in diff (no missing expected changes)
|
|
1543
|
+
|
|
1544
|
+
This method is ideal for verifying that an agent performed exactly what was expected -
|
|
1545
|
+
not more, not less.
|
|
1546
|
+
|
|
1547
|
+
Args:
|
|
1548
|
+
expected_changes: List of expected change specs. Each spec requires:
|
|
1549
|
+
- "type": "insert", "modify", or "delete" (required)
|
|
1550
|
+
- "table": table name (required)
|
|
1551
|
+
- "pk": primary key value (required)
|
|
1552
|
+
|
|
1553
|
+
Spec formats by type:
|
|
1554
|
+
- Insert: {"type": "insert", "table": "t", "pk": 1, "fields": [...]}
|
|
1555
|
+
- Modify: {"type": "modify", "table": "t", "pk": 1, "resulting_fields": [...], "no_other_changes": True/False}
|
|
1556
|
+
- Delete: {"type": "delete", "table": "t", "pk": 1}
|
|
1557
|
+
|
|
1558
|
+
Field specs are 2-tuples: (field_name, expected_value)
|
|
1559
|
+
- ("name", "Alice"): check field equals "Alice"
|
|
1560
|
+
- ("name", ...): accept any value (ellipsis)
|
|
1561
|
+
- ("name", None): check field is SQL NULL
|
|
1562
|
+
|
|
1563
|
+
Note: Legacy specs without explicit "type" are not supported.
|
|
1564
|
+
|
|
1565
|
+
Returns:
|
|
1566
|
+
self for method chaining
|
|
1567
|
+
|
|
1568
|
+
Raises:
|
|
1569
|
+
AssertionError: If there are unexpected changes OR if expected changes are missing
|
|
1570
|
+
ValueError: If specs are missing required fields or have invalid format
|
|
1571
|
+
"""
|
|
1572
|
+
diff = self._collect()
|
|
1573
|
+
|
|
1574
|
+
# Use shared validation logic
|
|
1575
|
+
success, error_msg, _ = validate_diff_expect_exactly(
|
|
1576
|
+
diff, expected_changes, self.ignore_config
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
if not success:
|
|
1580
|
+
raise AssertionError(error_msg)
|
|
1581
|
+
|
|
1582
|
+
return self
|
|
1583
|
+
|
|
600
1584
|
def expect(
|
|
601
1585
|
self,
|
|
602
1586
|
*,
|
|
@@ -611,7 +1595,7 @@ class SnapshotDiff:
|
|
|
611
1595
|
diff = self._collect()
|
|
612
1596
|
for tbl, report in diff.items():
|
|
613
1597
|
for row in report.get("modified_rows", []):
|
|
614
|
-
for f in row["
|
|
1598
|
+
for f in row["changes"].keys():
|
|
615
1599
|
if self.ignore_config.should_ignore_field(tbl, f):
|
|
616
1600
|
continue
|
|
617
1601
|
key = (tbl, f)
|