fleet-python 0.2.96__tar.gz → 0.2.97__tar.gz

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 (118) hide show
  1. {fleet_python-0.2.96/fleet_python.egg-info → fleet_python-0.2.97}/PKG-INFO +1 -1
  2. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/resources/sqlite.py +330 -2
  3. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/resources/sqlite.py +356 -2
  4. {fleet_python-0.2.96 → fleet_python-0.2.97/fleet_python.egg-info}/PKG-INFO +1 -1
  5. {fleet_python-0.2.96 → fleet_python-0.2.97}/pyproject.toml +1 -1
  6. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/test_expect_only.py +672 -0
  7. {fleet_python-0.2.96 → fleet_python-0.2.97}/LICENSE +0 -0
  8. {fleet_python-0.2.96 → fleet_python-0.2.97}/README.md +0 -0
  9. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/diff_example.py +0 -0
  10. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/dsl_example.py +0 -0
  11. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example.py +0 -0
  12. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/exampleResume.py +0 -0
  13. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_account.py +0 -0
  14. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_action_log.py +0 -0
  15. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_client.py +0 -0
  16. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_mcp_anthropic.py +0 -0
  17. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_mcp_openai.py +0 -0
  18. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_sync.py +0 -0
  19. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_task.py +0 -0
  20. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_tasks.py +0 -0
  21. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/example_verifier.py +0 -0
  22. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/export_tasks.py +0 -0
  23. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/fetch_tasks.py +0 -0
  24. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/gemini_example.py +0 -0
  25. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/import_tasks.py +0 -0
  26. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/iterate_verifiers.py +0 -0
  27. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/json_tasks_example.py +0 -0
  28. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/nova_act_example.py +0 -0
  29. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/openai_example.py +0 -0
  30. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/openai_simple_example.py +0 -0
  31. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/query_builder_example.py +0 -0
  32. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/quickstart.py +0 -0
  33. {fleet_python-0.2.96 → fleet_python-0.2.97}/examples/test_cdp_logging.py +0 -0
  34. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/__init__.py +0 -0
  35. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/__init__.py +0 -0
  36. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/base.py +0 -0
  37. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/client.py +0 -0
  38. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/env/__init__.py +0 -0
  39. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/env/client.py +0 -0
  40. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/exceptions.py +0 -0
  41. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/global_client.py +0 -0
  42. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/instance/__init__.py +0 -0
  43. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/instance/base.py +0 -0
  44. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/instance/client.py +0 -0
  45. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/models.py +0 -0
  46. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/resources/__init__.py +0 -0
  47. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/resources/api.py +0 -0
  48. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/resources/base.py +0 -0
  49. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/resources/browser.py +0 -0
  50. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/resources/mcp.py +0 -0
  51. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/tasks.py +0 -0
  52. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/verifiers/__init__.py +0 -0
  53. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/verifiers/bundler.py +0 -0
  54. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/_async/verifiers/verifier.py +0 -0
  55. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/__init__.py +0 -0
  56. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/Dockerfile +0 -0
  57. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/__init__.py +0 -0
  58. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/agent.py +0 -0
  59. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp/main.py +0 -0
  60. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
  61. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
  62. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
  63. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/requirements.txt +0 -0
  64. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/gemini_cua/start.sh +0 -0
  65. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/orchestrator.py +0 -0
  66. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/types.py +0 -0
  67. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/agent/utils.py +0 -0
  68. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/base.py +0 -0
  69. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/cli.py +0 -0
  70. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/client.py +0 -0
  71. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/config.py +0 -0
  72. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/env/__init__.py +0 -0
  73. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/env/client.py +0 -0
  74. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/eval/__init__.py +0 -0
  75. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/eval/uploader.py +0 -0
  76. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/exceptions.py +0 -0
  77. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/global_client.py +0 -0
  78. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/instance/__init__.py +0 -0
  79. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/instance/base.py +0 -0
  80. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/instance/client.py +0 -0
  81. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/instance/models.py +0 -0
  82. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/models.py +0 -0
  83. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/proxy/__init__.py +0 -0
  84. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/proxy/proxy.py +0 -0
  85. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/proxy/whitelist.py +0 -0
  86. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/resources/__init__.py +0 -0
  87. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/resources/api.py +0 -0
  88. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/resources/base.py +0 -0
  89. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/resources/browser.py +0 -0
  90. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/resources/mcp.py +0 -0
  91. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/tasks.py +0 -0
  92. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/types.py +0 -0
  93. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/utils/__init__.py +0 -0
  94. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/utils/http_logging.py +0 -0
  95. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/utils/logging.py +0 -0
  96. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/utils/playwright.py +0 -0
  97. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/__init__.py +0 -0
  98. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/bundler.py +0 -0
  99. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/code.py +0 -0
  100. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/db.py +0 -0
  101. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/decorator.py +0 -0
  102. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/parse.py +0 -0
  103. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/sql_differ.py +0 -0
  104. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet/verifiers/verifier.py +0 -0
  105. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet_python.egg-info/SOURCES.txt +0 -0
  106. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet_python.egg-info/dependency_links.txt +0 -0
  107. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet_python.egg-info/entry_points.txt +0 -0
  108. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet_python.egg-info/requires.txt +0 -0
  109. {fleet_python-0.2.96 → fleet_python-0.2.97}/fleet_python.egg-info/top_level.txt +0 -0
  110. {fleet_python-0.2.96 → fleet_python-0.2.97}/scripts/fix_sync_imports.py +0 -0
  111. {fleet_python-0.2.96 → fleet_python-0.2.97}/scripts/unasync.py +0 -0
  112. {fleet_python-0.2.96 → fleet_python-0.2.97}/setup.cfg +0 -0
  113. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/__init__.py +0 -0
  114. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/test_app_method.py +0 -0
  115. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/test_instance_dispatch.py +0 -0
  116. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/test_sqlite_resource_dual_mode.py +0 -0
  117. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  118. {fleet_python-0.2.96 → fleet_python-0.2.97}/tests/test_verifier_from_string.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.96
3
+ Version: 0.2.97
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -891,6 +891,326 @@ class AsyncSnapshotDiff:
891
891
 
892
892
  return self
893
893
 
894
+ async def _expect_only_targeted_v2(self, allowed_changes: List[Dict[str, Any]]):
895
+ """Optimized version that only queries specific rows mentioned in allowed_changes.
896
+
897
+ Supports v2 spec formats:
898
+ - {"table": "t", "pk": 1, "type": "insert", "fields": [...]}
899
+ - {"table": "t", "pk": 1, "type": "modify", "resulting_fields": [...], "no_other_changes": bool}
900
+ - {"table": "t", "pk": 1, "type": "delete", "fields": [...]}
901
+ - Legacy single-field specs: {"table": "t", "pk": 1, "field": "x", "after": val}
902
+ """
903
+ import asyncio
904
+
905
+ # Helper functions for v2 spec validation
906
+ def _parse_fields_spec(
907
+ fields_spec: List[Tuple[str, Any]]
908
+ ) -> Dict[str, Tuple[bool, Any]]:
909
+ """Parse a fields spec into a mapping of field_name -> (should_check_value, expected_value)."""
910
+ spec_map: Dict[str, Tuple[bool, Any]] = {}
911
+ for spec_tuple in fields_spec:
912
+ if len(spec_tuple) != 2:
913
+ raise ValueError(
914
+ f"Invalid field spec tuple: {spec_tuple}. "
915
+ f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
916
+ )
917
+ field_name, expected_value = spec_tuple
918
+ if expected_value is ...:
919
+ spec_map[field_name] = (False, None)
920
+ else:
921
+ spec_map[field_name] = (True, expected_value)
922
+ return spec_map
923
+
924
+ def _get_all_specs_for_pk(table: str, pk: Any) -> List[Dict[str, Any]]:
925
+ """Get all specs for a given table/pk (for legacy multi-field specs)."""
926
+ specs = []
927
+ for allowed in allowed_changes:
928
+ if (
929
+ allowed["table"] == table
930
+ and str(allowed.get("pk")) == str(pk)
931
+ ):
932
+ specs.append(allowed)
933
+ return specs
934
+
935
+ def _validate_insert_row(
936
+ table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
937
+ ) -> Optional[str]:
938
+ """Validate an inserted row against specs. Returns error message or None."""
939
+ # Check for type: "insert" spec with fields
940
+ for spec in specs:
941
+ if spec.get("type") == "insert":
942
+ fields_spec = spec.get("fields")
943
+ if fields_spec is not None:
944
+ # Validate each field
945
+ spec_map = _parse_fields_spec(fields_spec)
946
+ for field_name, field_value in row_data.items():
947
+ if field_name == "rowid":
948
+ continue
949
+ if self.ignore_config.should_ignore_field(table, field_name):
950
+ continue
951
+ if field_name not in spec_map:
952
+ return f"Field '{field_name}' not in insert spec for table '{table}' pk={pk}"
953
+ should_check, expected_value = spec_map[field_name]
954
+ if should_check and not _values_equivalent(expected_value, field_value):
955
+ return (
956
+ f"Insert mismatch in table '{table}' pk={pk}, "
957
+ f"field '{field_name}': expected {repr(expected_value)}, got {repr(field_value)}"
958
+ )
959
+ # type: "insert" found (with or without fields) - allowed
960
+ return None
961
+
962
+ # Check for legacy whole-row spec
963
+ for spec in specs:
964
+ if spec.get("fields") is None and spec.get("after") == "__added__":
965
+ return None
966
+
967
+ return f"Unexpected row added in table '{table}': pk={pk}"
968
+
969
+ def _validate_delete_row(
970
+ table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
971
+ ) -> Optional[str]:
972
+ """Validate a deleted row against specs. Returns error message or None."""
973
+ # Check for type: "delete" spec with optional fields
974
+ for spec in specs:
975
+ if spec.get("type") == "delete":
976
+ fields_spec = spec.get("fields")
977
+ if fields_spec is not None:
978
+ # Validate each field against the deleted row
979
+ spec_map = _parse_fields_spec(fields_spec)
980
+ for field_name, (should_check, expected_value) in spec_map.items():
981
+ if field_name not in row_data:
982
+ return f"Field '{field_name}' in delete spec not found in row for table '{table}' pk={pk}"
983
+ if should_check and not _values_equivalent(expected_value, row_data[field_name]):
984
+ return (
985
+ f"Delete mismatch in table '{table}' pk={pk}, "
986
+ f"field '{field_name}': expected {repr(expected_value)}, got {repr(row_data[field_name])}"
987
+ )
988
+ # type: "delete" found (with or without fields) - allowed
989
+ return None
990
+
991
+ # Check for legacy whole-row spec
992
+ for spec in specs:
993
+ if spec.get("fields") is None and spec.get("after") == "__removed__":
994
+ return None
995
+
996
+ return f"Unexpected row removed from table '{table}': pk={pk}"
997
+
998
+ def _validate_modify_row(
999
+ table: str,
1000
+ pk: Any,
1001
+ before_row: Dict[str, Any],
1002
+ after_row: Dict[str, Any],
1003
+ specs: List[Dict[str, Any]],
1004
+ ) -> Optional[str]:
1005
+ """Validate a modified row against specs. Returns error message or None."""
1006
+ # Collect actual changes
1007
+ changed_fields: Dict[str, Dict[str, Any]] = {}
1008
+ for field in set(before_row.keys()) | set(after_row.keys()):
1009
+ if self.ignore_config.should_ignore_field(table, field):
1010
+ continue
1011
+ before_val = before_row.get(field)
1012
+ after_val = after_row.get(field)
1013
+ if not _values_equivalent(before_val, after_val):
1014
+ changed_fields[field] = {"before": before_val, "after": after_val}
1015
+
1016
+ if not changed_fields:
1017
+ return None # No changes
1018
+
1019
+ # Check for type: "modify" spec with resulting_fields
1020
+ for spec in specs:
1021
+ if spec.get("type") == "modify":
1022
+ resulting_fields = spec.get("resulting_fields")
1023
+ if resulting_fields is not None:
1024
+ # Validate no_other_changes is provided
1025
+ if "no_other_changes" not in spec:
1026
+ raise ValueError(
1027
+ f"Modify spec for table '{table}' pk={pk} "
1028
+ f"has 'resulting_fields' but missing required 'no_other_changes' field."
1029
+ )
1030
+ no_other_changes = spec["no_other_changes"]
1031
+ if not isinstance(no_other_changes, bool):
1032
+ raise ValueError(
1033
+ f"Modify spec for table '{table}' pk={pk} "
1034
+ f"'no_other_changes' must be boolean, got {type(no_other_changes).__name__}"
1035
+ )
1036
+
1037
+ spec_map = _parse_fields_spec(resulting_fields)
1038
+
1039
+ # Validate changed fields
1040
+ for field_name, vals in changed_fields.items():
1041
+ after_val = vals["after"]
1042
+ if field_name not in spec_map:
1043
+ if no_other_changes:
1044
+ return (
1045
+ f"Unexpected field change in table '{table}' pk={pk}: "
1046
+ f"field '{field_name}' not in resulting_fields"
1047
+ )
1048
+ # no_other_changes=False: ignore this field
1049
+ else:
1050
+ should_check, expected_value = spec_map[field_name]
1051
+ if should_check and not _values_equivalent(expected_value, after_val):
1052
+ return (
1053
+ f"Modify mismatch in table '{table}' pk={pk}, "
1054
+ f"field '{field_name}': expected {repr(expected_value)}, got {repr(after_val)}"
1055
+ )
1056
+ return None # Validation passed
1057
+ else:
1058
+ # type: "modify" without resulting_fields - allow any modification
1059
+ return None
1060
+
1061
+ # Check for legacy single-field specs
1062
+ for field_name, vals in changed_fields.items():
1063
+ after_val = vals["after"]
1064
+ field_allowed = False
1065
+ for spec in specs:
1066
+ if (
1067
+ spec.get("field") == field_name
1068
+ and _values_equivalent(spec.get("after"), after_val)
1069
+ ):
1070
+ field_allowed = True
1071
+ break
1072
+ if not field_allowed:
1073
+ return (
1074
+ f"Unexpected change in table '{table}' pk={pk}, "
1075
+ f"field '{field_name}': {repr(vals['before'])} -> {repr(after_val)}"
1076
+ )
1077
+
1078
+ return None
1079
+
1080
+ # Group allowed changes by table
1081
+ changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
1082
+ for change in allowed_changes:
1083
+ table = change["table"]
1084
+ if table not in changes_by_table:
1085
+ changes_by_table[table] = []
1086
+ changes_by_table[table].append(change)
1087
+
1088
+ errors: List[Exception] = []
1089
+
1090
+ # Async function to check a single row
1091
+ async def check_row(
1092
+ table: str,
1093
+ pk: Any,
1094
+ pk_columns: List[str],
1095
+ ):
1096
+ try:
1097
+ # Build WHERE clause for this PK
1098
+ where_sql = self._build_pk_where_clause(pk_columns, pk)
1099
+
1100
+ # Query before snapshot
1101
+ before_query = f"SELECT * FROM {table} WHERE {where_sql}"
1102
+ before_response = await self.before.resource.query(before_query)
1103
+ before_row = (
1104
+ dict(zip(before_response.columns, before_response.rows[0]))
1105
+ if before_response.rows
1106
+ else None
1107
+ )
1108
+
1109
+ # Query after snapshot
1110
+ after_response = await self.after.resource.query(before_query)
1111
+ after_row = (
1112
+ dict(zip(after_response.columns, after_response.rows[0]))
1113
+ if after_response.rows
1114
+ else None
1115
+ )
1116
+
1117
+ # Get all specs for this table/pk
1118
+ specs = _get_all_specs_for_pk(table, pk)
1119
+
1120
+ # Check changes for this row
1121
+ if before_row and after_row:
1122
+ # Modified row
1123
+ error = _validate_modify_row(table, pk, before_row, after_row, specs)
1124
+ if error:
1125
+ errors.append(AssertionError(error))
1126
+ elif not before_row and after_row:
1127
+ # Added row
1128
+ error = _validate_insert_row(table, pk, after_row, specs)
1129
+ if error:
1130
+ errors.append(AssertionError(error))
1131
+ elif before_row and not after_row:
1132
+ # Removed row
1133
+ error = _validate_delete_row(table, pk, before_row, specs)
1134
+ if error:
1135
+ errors.append(AssertionError(error))
1136
+
1137
+ except Exception as e:
1138
+ errors.append(e)
1139
+
1140
+ # Prepare all row checks
1141
+ row_tasks = []
1142
+ for table, table_changes in changes_by_table.items():
1143
+ if self.ignore_config.should_ignore_table(table):
1144
+ continue
1145
+
1146
+ # Get primary key columns once per table
1147
+ pk_columns = self._get_primary_key_columns(table)
1148
+
1149
+ # Extract unique PKs to check
1150
+ pks_to_check = {change["pk"] for change in table_changes}
1151
+
1152
+ for pk in pks_to_check:
1153
+ row_tasks.append(check_row(table, pk, pk_columns))
1154
+
1155
+ # Execute row checks concurrently
1156
+ if row_tasks:
1157
+ await asyncio.gather(*row_tasks)
1158
+
1159
+ # Check for errors from row checks
1160
+ if errors:
1161
+ raise errors[0]
1162
+
1163
+ # Now check tables not mentioned in allowed_changes to ensure no changes
1164
+ all_tables = set(await self.before.tables()) | set(await self.after.tables())
1165
+ tables_to_verify = []
1166
+
1167
+ for table in all_tables:
1168
+ if (
1169
+ table not in changes_by_table
1170
+ and not self.ignore_config.should_ignore_table(table)
1171
+ ):
1172
+ tables_to_verify.append(table)
1173
+
1174
+ # Async function to verify no changes in a table
1175
+ async def verify_no_changes(table: str):
1176
+ try:
1177
+ # For tables with no allowed changes, just check row counts
1178
+ before_count_response = await self.before.resource.query(
1179
+ f"SELECT COUNT(*) FROM {table}"
1180
+ )
1181
+ before_count = (
1182
+ before_count_response.rows[0][0]
1183
+ if before_count_response.rows
1184
+ else 0
1185
+ )
1186
+
1187
+ after_count_response = await self.after.resource.query(
1188
+ f"SELECT COUNT(*) FROM {table}"
1189
+ )
1190
+ after_count = (
1191
+ after_count_response.rows[0][0] if after_count_response.rows else 0
1192
+ )
1193
+
1194
+ if before_count != after_count:
1195
+ error_msg = (
1196
+ f"Unexpected change in table '{table}': "
1197
+ f"row count changed from {before_count} to {after_count}"
1198
+ )
1199
+ errors.append(AssertionError(error_msg))
1200
+ except Exception as e:
1201
+ errors.append(e)
1202
+
1203
+ # Execute table verification concurrently
1204
+ if tables_to_verify:
1205
+ verify_tasks = [verify_no_changes(table) for table in tables_to_verify]
1206
+ await asyncio.gather(*verify_tasks)
1207
+
1208
+ # Final error check
1209
+ if errors:
1210
+ raise errors[0]
1211
+
1212
+ return self
1213
+
894
1214
  async def _validate_diff_against_allowed_changes_v2(
895
1215
  self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
896
1216
  ):
@@ -1417,6 +1737,10 @@ class AsyncSnapshotDiff:
1417
1737
  This version supports field-level specifications for added/removed rows,
1418
1738
  allowing users to specify expected field values instead of just whole-row specs.
1419
1739
  """
1740
+ # Special case: empty allowed_changes means no changes should have occurred
1741
+ if not allowed_changes:
1742
+ return await self._expect_no_changes()
1743
+
1420
1744
  resource = self.after.resource
1421
1745
  if resource.client is not None and resource._mode == "http":
1422
1746
  api_diff = None
@@ -1445,9 +1769,13 @@ class AsyncSnapshotDiff:
1445
1769
 
1446
1770
  # Validate outside try block so AssertionError propagates
1447
1771
  if api_diff is not None:
1448
- return await self._validate_diff_against_allowed_changes(api_diff, allowed_changes)
1772
+ return await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
1449
1773
 
1450
- # Fall back to full diff for v2 (no targeted optimization yet)
1774
+ # For expect_only_v2, we can optimize by only checking the specific rows mentioned
1775
+ if self._can_use_targeted_queries(allowed_changes):
1776
+ return await self._expect_only_targeted_v2(allowed_changes)
1777
+
1778
+ # Fall back to full diff for complex cases
1451
1779
  diff = await self._collect()
1452
1780
  return await self._validate_diff_against_allowed_changes_v2(
1453
1781
  diff, allowed_changes