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.
Files changed (70) hide show
  1. examples/export_tasks.py +16 -5
  2. examples/export_tasks_filtered.py +245 -0
  3. examples/fetch_tasks.py +230 -0
  4. examples/import_tasks.py +140 -8
  5. examples/iterate_verifiers.py +725 -0
  6. fleet/__init__.py +128 -5
  7. fleet/_async/__init__.py +27 -3
  8. fleet/_async/base.py +24 -9
  9. fleet/_async/client.py +938 -41
  10. fleet/_async/env/client.py +60 -3
  11. fleet/_async/instance/client.py +52 -7
  12. fleet/_async/models.py +15 -0
  13. fleet/_async/resources/api.py +200 -0
  14. fleet/_async/resources/sqlite.py +1801 -46
  15. fleet/_async/tasks.py +122 -25
  16. fleet/_async/verifiers/bundler.py +22 -21
  17. fleet/_async/verifiers/verifier.py +25 -19
  18. fleet/agent/__init__.py +32 -0
  19. fleet/agent/gemini_cua/Dockerfile +45 -0
  20. fleet/agent/gemini_cua/__init__.py +10 -0
  21. fleet/agent/gemini_cua/agent.py +759 -0
  22. fleet/agent/gemini_cua/mcp/main.py +108 -0
  23. fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
  24. fleet/agent/gemini_cua/mcp_server/main.py +105 -0
  25. fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
  26. fleet/agent/gemini_cua/requirements.txt +5 -0
  27. fleet/agent/gemini_cua/start.sh +30 -0
  28. fleet/agent/orchestrator.py +854 -0
  29. fleet/agent/types.py +49 -0
  30. fleet/agent/utils.py +34 -0
  31. fleet/base.py +34 -9
  32. fleet/cli.py +1061 -0
  33. fleet/client.py +1060 -48
  34. fleet/config.py +1 -1
  35. fleet/env/__init__.py +16 -0
  36. fleet/env/client.py +60 -3
  37. fleet/eval/__init__.py +15 -0
  38. fleet/eval/uploader.py +231 -0
  39. fleet/exceptions.py +8 -0
  40. fleet/instance/client.py +53 -8
  41. fleet/instance/models.py +1 -0
  42. fleet/models.py +303 -0
  43. fleet/proxy/__init__.py +25 -0
  44. fleet/proxy/proxy.py +453 -0
  45. fleet/proxy/whitelist.py +244 -0
  46. fleet/resources/api.py +200 -0
  47. fleet/resources/sqlite.py +1845 -46
  48. fleet/tasks.py +113 -20
  49. fleet/utils/__init__.py +7 -0
  50. fleet/utils/http_logging.py +178 -0
  51. fleet/utils/logging.py +13 -0
  52. fleet/utils/playwright.py +440 -0
  53. fleet/verifiers/bundler.py +22 -21
  54. fleet/verifiers/db.py +985 -1
  55. fleet/verifiers/decorator.py +1 -1
  56. fleet/verifiers/verifier.py +25 -19
  57. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
  58. fleet_python-0.2.105.dist-info/RECORD +115 -0
  59. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
  60. fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
  61. tests/test_app_method.py +85 -0
  62. tests/test_expect_exactly.py +4148 -0
  63. tests/test_expect_only.py +2593 -0
  64. tests/test_instance_dispatch.py +607 -0
  65. tests/test_sqlite_resource_dual_mode.py +263 -0
  66. tests/test_sqlite_shared_memory_behavior.py +117 -0
  67. fleet_python-0.2.66b2.dist-info/RECORD +0 -81
  68. tests/test_verifier_security.py +0 -427
  69. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
  70. {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["changed"].keys():
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)