fleet-python 0.2.96__tar.gz → 0.2.98__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.
- {fleet_python-0.2.96/fleet_python.egg-info → fleet_python-0.2.98}/PKG-INFO +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/__init__.py +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/__init__.py +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/base.py +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/resources/sqlite.py +330 -2
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/base.py +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/resources/sqlite.py +393 -28
- {fleet_python-0.2.96 → fleet_python-0.2.98/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/pyproject.toml +1 -1
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/test_expect_only.py +672 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/LICENSE +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/README.md +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/diff_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_account.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_sync.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_task.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/iterate_verifiers.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/openai_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/quickstart.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/instance/client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/resources/api.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/Dockerfile +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/agent.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/mcp/main.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/requirements.txt +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/gemini_cua/start.sh +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/orchestrator.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/types.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/agent/utils.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/cli.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/config.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/env/client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/eval/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/eval/uploader.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/global_client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/instance/client.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/models.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/proxy/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/proxy/proxy.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/proxy/whitelist.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/resources/api.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/tasks.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/types.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/utils/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/utils/http_logging.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/utils/logging.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/utils/playwright.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/db.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet_python.egg-info/SOURCES.txt +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet_python.egg-info/entry_points.txt +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/scripts/unasync.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/setup.cfg +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/__init__.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.96 → fleet_python-0.2.98}/tests/test_verifier_from_string.py +0 -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.
|
|
1772
|
+
return await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
|
|
1449
1773
|
|
|
1450
|
-
#
|
|
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
|