fleet-python 0.2.76__tar.gz → 0.2.78__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.76/fleet_python.egg-info → fleet_python-0.2.78}/PKG-INFO +1 -1
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/iterate_verifiers.py +6 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/sqlite.py +45 -316
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/verifiers/verifier.py +1 -19
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/sqlite.py +45 -334
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/verifier.py +1 -19
- {fleet_python-0.2.76 → fleet_python-0.2.78/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.76 → fleet_python-0.2.78}/pyproject.toml +1 -1
- {fleet_python-0.2.76 → fleet_python-0.2.78}/LICENSE +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/README.md +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/diff_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_account.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_sync.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_task.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/openai_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/quickstart.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/base.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/instance/client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/base.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/config.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/env/client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/global_client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/client.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/models.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/tasks.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/types.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/db.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/SOURCES.txt +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/scripts/unasync.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/setup.cfg +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/__init__.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_expect_only.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_verifier_from_string.py +0 -0
|
@@ -387,6 +387,12 @@ def apply_verifiers_to_json(json_path: str, python_path: str) -> None:
|
|
|
387
387
|
|
|
388
388
|
if task_key in verifiers:
|
|
389
389
|
new_code = verifiers[task_key]
|
|
390
|
+
|
|
391
|
+
# Escape newlines in debug print patterns (>>> and <<<)
|
|
392
|
+
# These should be \n escape sequences, not actual newlines
|
|
393
|
+
new_code = new_code.replace(">>>\n", ">>>\\n")
|
|
394
|
+
new_code = new_code.replace("\n<<<", "\\n<<<")
|
|
395
|
+
|
|
390
396
|
old_code = task.get("verifier_func", "").strip()
|
|
391
397
|
|
|
392
398
|
# Only update if the code actually changed
|
|
@@ -761,317 +761,6 @@ class AsyncSnapshotDiff:
|
|
|
761
761
|
|
|
762
762
|
return self
|
|
763
763
|
|
|
764
|
-
async def _expect_only_targeted_v2(self, allowed_changes: List[Dict[str, Any]]):
|
|
765
|
-
"""Optimized v2 version that only queries specific rows mentioned in allowed_changes."""
|
|
766
|
-
import asyncio
|
|
767
|
-
|
|
768
|
-
# Helper functions for v2 validation (same as in _validate_diff_against_allowed_changes_v2)
|
|
769
|
-
def _parse_fields_spec(
|
|
770
|
-
fields_spec: List[Tuple[str, Any]]
|
|
771
|
-
) -> Dict[str, Tuple[bool, Any]]:
|
|
772
|
-
"""Parse a fields spec into a mapping of field_name -> (should_check_value, expected_value)."""
|
|
773
|
-
spec_map: Dict[str, Tuple[bool, Any]] = {}
|
|
774
|
-
for spec_tuple in fields_spec:
|
|
775
|
-
if len(spec_tuple) != 2:
|
|
776
|
-
raise ValueError(
|
|
777
|
-
f"Invalid field spec tuple: {spec_tuple}. "
|
|
778
|
-
f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
|
|
779
|
-
)
|
|
780
|
-
field_name, expected_value = spec_tuple
|
|
781
|
-
if expected_value is ...:
|
|
782
|
-
spec_map[field_name] = (False, None)
|
|
783
|
-
else:
|
|
784
|
-
spec_map[field_name] = (True, expected_value)
|
|
785
|
-
return spec_map
|
|
786
|
-
|
|
787
|
-
def _validate_row_with_fields_spec(
|
|
788
|
-
table: str,
|
|
789
|
-
row_id: Any,
|
|
790
|
-
row_data: Dict[str, Any],
|
|
791
|
-
fields_spec: List[Tuple[str, Any]],
|
|
792
|
-
) -> Optional[List[Tuple[str, Any, str]]]:
|
|
793
|
-
"""Validate an inserted/deleted row against a bulk fields spec."""
|
|
794
|
-
spec_map = _parse_fields_spec(fields_spec)
|
|
795
|
-
unmatched_fields: List[Tuple[str, Any, str]] = []
|
|
796
|
-
|
|
797
|
-
for field_name, field_value in row_data.items():
|
|
798
|
-
if field_name == "rowid":
|
|
799
|
-
continue
|
|
800
|
-
if self.ignore_config.should_ignore_field(table, field_name):
|
|
801
|
-
continue
|
|
802
|
-
|
|
803
|
-
if field_name not in spec_map:
|
|
804
|
-
unmatched_fields.append(
|
|
805
|
-
(field_name, field_value, "NOT_IN_FIELDS_SPEC")
|
|
806
|
-
)
|
|
807
|
-
else:
|
|
808
|
-
should_check, expected_value = spec_map[field_name]
|
|
809
|
-
if should_check and not _values_equivalent(
|
|
810
|
-
expected_value, field_value
|
|
811
|
-
):
|
|
812
|
-
unmatched_fields.append(
|
|
813
|
-
(field_name, field_value, f"expected {repr(expected_value)}")
|
|
814
|
-
)
|
|
815
|
-
|
|
816
|
-
return unmatched_fields if unmatched_fields else None
|
|
817
|
-
|
|
818
|
-
def _validate_modification_with_fields_spec(
|
|
819
|
-
table: str,
|
|
820
|
-
row_id: Any,
|
|
821
|
-
row_changes: Dict[str, Dict[str, Any]],
|
|
822
|
-
resulting_fields: List[Tuple[str, Any]],
|
|
823
|
-
no_other_changes: bool,
|
|
824
|
-
) -> Optional[List[Tuple[str, Any, str]]]:
|
|
825
|
-
"""Validate a modification against a resulting_fields spec."""
|
|
826
|
-
spec_map = _parse_fields_spec(resulting_fields)
|
|
827
|
-
unmatched_fields: List[Tuple[str, Any, str]] = []
|
|
828
|
-
|
|
829
|
-
for field_name, vals in row_changes.items():
|
|
830
|
-
if self.ignore_config.should_ignore_field(table, field_name):
|
|
831
|
-
continue
|
|
832
|
-
|
|
833
|
-
after_value = vals["after"]
|
|
834
|
-
|
|
835
|
-
if field_name not in spec_map:
|
|
836
|
-
if no_other_changes:
|
|
837
|
-
unmatched_fields.append(
|
|
838
|
-
(field_name, after_value, "NOT_IN_RESULTING_FIELDS")
|
|
839
|
-
)
|
|
840
|
-
else:
|
|
841
|
-
should_check, expected_value = spec_map[field_name]
|
|
842
|
-
if should_check and not _values_equivalent(
|
|
843
|
-
expected_value, after_value
|
|
844
|
-
):
|
|
845
|
-
unmatched_fields.append(
|
|
846
|
-
(field_name, after_value, f"expected {repr(expected_value)}")
|
|
847
|
-
)
|
|
848
|
-
|
|
849
|
-
return unmatched_fields if unmatched_fields else None
|
|
850
|
-
|
|
851
|
-
# Group allowed changes by table
|
|
852
|
-
changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
|
|
853
|
-
for change in allowed_changes:
|
|
854
|
-
table = change["table"]
|
|
855
|
-
if table not in changes_by_table:
|
|
856
|
-
changes_by_table[table] = []
|
|
857
|
-
changes_by_table[table].append(change)
|
|
858
|
-
|
|
859
|
-
errors = []
|
|
860
|
-
|
|
861
|
-
def _get_change_spec(table_changes: List[Dict[str, Any]], pk: Any) -> Optional[Dict[str, Any]]:
|
|
862
|
-
"""Get the change spec for a given pk."""
|
|
863
|
-
for change in table_changes:
|
|
864
|
-
if str(change.get("pk")) == str(pk):
|
|
865
|
-
return change
|
|
866
|
-
return None
|
|
867
|
-
|
|
868
|
-
async def check_row(
|
|
869
|
-
table: str,
|
|
870
|
-
pk: Any,
|
|
871
|
-
table_changes: List[Dict[str, Any]],
|
|
872
|
-
pk_columns: List[str],
|
|
873
|
-
):
|
|
874
|
-
"""Check a single row against v2 specs."""
|
|
875
|
-
try:
|
|
876
|
-
where_sql = self._build_pk_where_clause(pk_columns, pk)
|
|
877
|
-
|
|
878
|
-
# Query both snapshots
|
|
879
|
-
before_query = f"SELECT * FROM {table} WHERE {where_sql}"
|
|
880
|
-
before_response = await self.before.resource.query(before_query)
|
|
881
|
-
before_row = (
|
|
882
|
-
dict(zip(before_response.columns, before_response.rows[0]))
|
|
883
|
-
if before_response.rows
|
|
884
|
-
else None
|
|
885
|
-
)
|
|
886
|
-
|
|
887
|
-
after_response = await self.after.resource.query(before_query)
|
|
888
|
-
after_row = (
|
|
889
|
-
dict(zip(after_response.columns, after_response.rows[0]))
|
|
890
|
-
if after_response.rows
|
|
891
|
-
else None
|
|
892
|
-
)
|
|
893
|
-
|
|
894
|
-
# Get the spec for this pk
|
|
895
|
-
spec = _get_change_spec(table_changes, pk)
|
|
896
|
-
if spec is None:
|
|
897
|
-
return # No spec found, shouldn't happen
|
|
898
|
-
|
|
899
|
-
change_type = spec.get("type")
|
|
900
|
-
|
|
901
|
-
if not before_row and after_row:
|
|
902
|
-
# Inserted row
|
|
903
|
-
if change_type != "insert":
|
|
904
|
-
error_msg = f"Row {pk} in table '{table}' was inserted but spec type is '{change_type}'"
|
|
905
|
-
errors.append(AssertionError(error_msg))
|
|
906
|
-
return
|
|
907
|
-
|
|
908
|
-
fields_spec = spec.get("fields")
|
|
909
|
-
if fields_spec is not None:
|
|
910
|
-
unmatched = _validate_row_with_fields_spec(
|
|
911
|
-
table, pk, after_row, fields_spec
|
|
912
|
-
)
|
|
913
|
-
if unmatched:
|
|
914
|
-
error_msg = (
|
|
915
|
-
f"Insert validation failed for table '{table}', row {pk}:\n"
|
|
916
|
-
+ "\n".join(
|
|
917
|
-
f" - {f}: {repr(v)} ({issue})"
|
|
918
|
-
for f, v, issue in unmatched[:5]
|
|
919
|
-
)
|
|
920
|
-
)
|
|
921
|
-
errors.append(AssertionError(error_msg))
|
|
922
|
-
|
|
923
|
-
elif before_row and not after_row:
|
|
924
|
-
# Deleted row
|
|
925
|
-
if change_type != "delete":
|
|
926
|
-
error_msg = f"Row {pk} in table '{table}' was deleted but spec type is '{change_type}'"
|
|
927
|
-
errors.append(AssertionError(error_msg))
|
|
928
|
-
return
|
|
929
|
-
|
|
930
|
-
fields_spec = spec.get("fields")
|
|
931
|
-
if fields_spec is not None:
|
|
932
|
-
unmatched = _validate_row_with_fields_spec(
|
|
933
|
-
table, pk, before_row, fields_spec
|
|
934
|
-
)
|
|
935
|
-
if unmatched:
|
|
936
|
-
error_msg = (
|
|
937
|
-
f"Delete validation failed for table '{table}', row {pk}:\n"
|
|
938
|
-
+ "\n".join(
|
|
939
|
-
f" - {f}: {repr(v)} ({issue})"
|
|
940
|
-
for f, v, issue in unmatched[:5]
|
|
941
|
-
)
|
|
942
|
-
)
|
|
943
|
-
errors.append(AssertionError(error_msg))
|
|
944
|
-
|
|
945
|
-
elif before_row and after_row:
|
|
946
|
-
# Modified row - compute changes
|
|
947
|
-
row_changes = {}
|
|
948
|
-
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
949
|
-
if self.ignore_config.should_ignore_field(table, field):
|
|
950
|
-
continue
|
|
951
|
-
before_val = before_row.get(field)
|
|
952
|
-
after_val = after_row.get(field)
|
|
953
|
-
if not _values_equivalent(before_val, after_val):
|
|
954
|
-
row_changes[field] = {"before": before_val, "after": after_val}
|
|
955
|
-
|
|
956
|
-
if not row_changes:
|
|
957
|
-
# No actual changes (after ignores), this is fine
|
|
958
|
-
return
|
|
959
|
-
|
|
960
|
-
if change_type != "modify":
|
|
961
|
-
error_msg = f"Row {pk} in table '{table}' was modified but spec type is '{change_type}'"
|
|
962
|
-
errors.append(AssertionError(error_msg))
|
|
963
|
-
return
|
|
964
|
-
|
|
965
|
-
resulting_fields = spec.get("resulting_fields")
|
|
966
|
-
if resulting_fields is not None:
|
|
967
|
-
if "no_other_changes" not in spec:
|
|
968
|
-
error_msg = (
|
|
969
|
-
f"Modify spec for table '{table}' pk={pk} "
|
|
970
|
-
f"has 'resulting_fields' but missing required 'no_other_changes' field."
|
|
971
|
-
)
|
|
972
|
-
errors.append(ValueError(error_msg))
|
|
973
|
-
return
|
|
974
|
-
|
|
975
|
-
no_other_changes = spec["no_other_changes"]
|
|
976
|
-
if not isinstance(no_other_changes, bool):
|
|
977
|
-
error_msg = (
|
|
978
|
-
f"Modify spec for table '{table}' pk={pk} "
|
|
979
|
-
f"has 'no_other_changes' but it must be a boolean."
|
|
980
|
-
)
|
|
981
|
-
errors.append(ValueError(error_msg))
|
|
982
|
-
return
|
|
983
|
-
|
|
984
|
-
unmatched = _validate_modification_with_fields_spec(
|
|
985
|
-
table, pk, row_changes, resulting_fields, no_other_changes
|
|
986
|
-
)
|
|
987
|
-
if unmatched:
|
|
988
|
-
error_msg = (
|
|
989
|
-
f"Modify validation failed for table '{table}', row {pk}:\n"
|
|
990
|
-
+ "\n".join(
|
|
991
|
-
f" - {f}: {repr(v)} ({issue})"
|
|
992
|
-
for f, v, issue in unmatched[:5]
|
|
993
|
-
)
|
|
994
|
-
)
|
|
995
|
-
errors.append(AssertionError(error_msg))
|
|
996
|
-
|
|
997
|
-
else:
|
|
998
|
-
# Row doesn't exist in either snapshot
|
|
999
|
-
error_msg = f"Row {pk} not found in table '{table}' in either snapshot"
|
|
1000
|
-
errors.append(AssertionError(error_msg))
|
|
1001
|
-
|
|
1002
|
-
except Exception as e:
|
|
1003
|
-
errors.append(e)
|
|
1004
|
-
|
|
1005
|
-
# Prepare all row checks
|
|
1006
|
-
row_checks = []
|
|
1007
|
-
for table, table_changes in changes_by_table.items():
|
|
1008
|
-
if self.ignore_config.should_ignore_table(table):
|
|
1009
|
-
continue
|
|
1010
|
-
|
|
1011
|
-
pk_columns = await self._get_primary_key_columns(table)
|
|
1012
|
-
pks_to_check = {change["pk"] for change in table_changes}
|
|
1013
|
-
|
|
1014
|
-
for pk in pks_to_check:
|
|
1015
|
-
row_checks.append((table, pk, table_changes, pk_columns))
|
|
1016
|
-
|
|
1017
|
-
# Execute row checks in parallel
|
|
1018
|
-
if row_checks:
|
|
1019
|
-
await asyncio.gather(
|
|
1020
|
-
*[
|
|
1021
|
-
check_row(table, pk, table_changes, pk_columns)
|
|
1022
|
-
for table, pk, table_changes, pk_columns in row_checks
|
|
1023
|
-
]
|
|
1024
|
-
)
|
|
1025
|
-
|
|
1026
|
-
if errors:
|
|
1027
|
-
raise errors[0]
|
|
1028
|
-
|
|
1029
|
-
# Verify tables not mentioned have no changes
|
|
1030
|
-
all_tables = set(await self.before.tables()) | set(await self.after.tables())
|
|
1031
|
-
tables_to_verify = []
|
|
1032
|
-
|
|
1033
|
-
for table in all_tables:
|
|
1034
|
-
if (
|
|
1035
|
-
table not in changes_by_table
|
|
1036
|
-
and not self.ignore_config.should_ignore_table(table)
|
|
1037
|
-
):
|
|
1038
|
-
tables_to_verify.append(table)
|
|
1039
|
-
|
|
1040
|
-
async def verify_no_changes(table: str):
|
|
1041
|
-
try:
|
|
1042
|
-
before_count_response = await self.before.resource.query(
|
|
1043
|
-
f"SELECT COUNT(*) FROM {table}"
|
|
1044
|
-
)
|
|
1045
|
-
before_count = (
|
|
1046
|
-
before_count_response.rows[0][0]
|
|
1047
|
-
if before_count_response.rows
|
|
1048
|
-
else 0
|
|
1049
|
-
)
|
|
1050
|
-
|
|
1051
|
-
after_count_response = await self.after.resource.query(
|
|
1052
|
-
f"SELECT COUNT(*) FROM {table}"
|
|
1053
|
-
)
|
|
1054
|
-
after_count = (
|
|
1055
|
-
after_count_response.rows[0][0] if after_count_response.rows else 0
|
|
1056
|
-
)
|
|
1057
|
-
|
|
1058
|
-
if before_count != after_count:
|
|
1059
|
-
error_msg = (
|
|
1060
|
-
f"Unexpected change in table '{table}': "
|
|
1061
|
-
f"row count changed from {before_count} to {after_count}"
|
|
1062
|
-
)
|
|
1063
|
-
errors.append(AssertionError(error_msg))
|
|
1064
|
-
except Exception as e:
|
|
1065
|
-
errors.append(e)
|
|
1066
|
-
|
|
1067
|
-
if tables_to_verify:
|
|
1068
|
-
await asyncio.gather(*[verify_no_changes(table) for table in tables_to_verify])
|
|
1069
|
-
|
|
1070
|
-
if errors:
|
|
1071
|
-
raise errors[0]
|
|
1072
|
-
|
|
1073
|
-
return self
|
|
1074
|
-
|
|
1075
764
|
async def _validate_diff_against_allowed_changes(
|
|
1076
765
|
self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
|
|
1077
766
|
):
|
|
@@ -1732,16 +1421,56 @@ class AsyncSnapshotDiff:
|
|
|
1732
1421
|
if not allowed_changes:
|
|
1733
1422
|
return await self._expect_no_changes()
|
|
1734
1423
|
|
|
1735
|
-
#
|
|
1736
|
-
if self._can_use_targeted_queries(allowed_changes):
|
|
1737
|
-
return await self._expect_only_targeted_v2(allowed_changes)
|
|
1738
|
-
|
|
1739
|
-
# Fall back to full diff for complex cases
|
|
1424
|
+
# Fall back to full diff for v2 (no targeted optimization yet)
|
|
1740
1425
|
diff = await self._collect()
|
|
1741
1426
|
return await self._validate_diff_against_allowed_changes_v2(
|
|
1742
1427
|
diff, allowed_changes
|
|
1743
1428
|
)
|
|
1744
1429
|
|
|
1430
|
+
async def _ensure_all_fetched(self):
|
|
1431
|
+
"""Fetch ALL data from ALL tables upfront (non-lazy loading).
|
|
1432
|
+
|
|
1433
|
+
This is the old approach before lazy loading was introduced.
|
|
1434
|
+
Used by expect_only_v1 for simpler, non-optimized diffing.
|
|
1435
|
+
"""
|
|
1436
|
+
# Get all tables from before snapshot
|
|
1437
|
+
tables_response = await self.before.resource.query(
|
|
1438
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
if tables_response.rows:
|
|
1442
|
+
before_tables = [row[0] for row in tables_response.rows]
|
|
1443
|
+
for table in before_tables:
|
|
1444
|
+
await self.before._ensure_table_data(table)
|
|
1445
|
+
|
|
1446
|
+
# Also fetch from after snapshot
|
|
1447
|
+
tables_response = await self.after.resource.query(
|
|
1448
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
if tables_response.rows:
|
|
1452
|
+
after_tables = [row[0] for row in tables_response.rows]
|
|
1453
|
+
for table in after_tables:
|
|
1454
|
+
await self.after._ensure_table_data(table)
|
|
1455
|
+
|
|
1456
|
+
async def expect_only_v1(self, allowed_changes: List[Dict[str, Any]]):
|
|
1457
|
+
"""Ensure only specified changes occurred using the original (non-optimized) approach.
|
|
1458
|
+
|
|
1459
|
+
This is the original expect_only logic before lazy loading and targeted query
|
|
1460
|
+
optimizations were introduced. It fetches all data upfront and does a full diff.
|
|
1461
|
+
|
|
1462
|
+
Use this when you want the simpler, more predictable behavior of the original
|
|
1463
|
+
implementation without any query optimizations.
|
|
1464
|
+
"""
|
|
1465
|
+
# Fetch all data upfront (old approach)
|
|
1466
|
+
await self._ensure_all_fetched()
|
|
1467
|
+
|
|
1468
|
+
# Collect full diff
|
|
1469
|
+
diff = await self._collect()
|
|
1470
|
+
|
|
1471
|
+
# Validate using the original validation logic
|
|
1472
|
+
return await self._validate_diff_against_allowed_changes(diff, allowed_changes)
|
|
1473
|
+
|
|
1745
1474
|
|
|
1746
1475
|
class AsyncQueryBuilder:
|
|
1747
1476
|
"""Async query builder that translates DSL to SQL and executes through the API."""
|
|
@@ -232,25 +232,7 @@ Remote traceback:
|
|
|
232
232
|
) -> "VerifiersExecuteResponse":
|
|
233
233
|
"""Remote execution of the verifier function that returns the full response model."""
|
|
234
234
|
args_array = list(args)
|
|
235
|
-
|
|
236
|
-
env_data = {
|
|
237
|
-
"instance_id": env.instance_id,
|
|
238
|
-
"env_key": env.env_key,
|
|
239
|
-
"version": env.version,
|
|
240
|
-
"status": env.status,
|
|
241
|
-
"subdomain": env.subdomain,
|
|
242
|
-
"created_at": env.created_at,
|
|
243
|
-
"updated_at": env.updated_at,
|
|
244
|
-
"terminated_at": env.terminated_at,
|
|
245
|
-
"team_id": env.team_id,
|
|
246
|
-
"region": env.region,
|
|
247
|
-
"env_variables": env.env_variables,
|
|
248
|
-
"data_key": env.data_key,
|
|
249
|
-
"data_version": env.data_version,
|
|
250
|
-
"urls": env.urls.model_dump() if env.urls else None,
|
|
251
|
-
"health": env.health,
|
|
252
|
-
}
|
|
253
|
-
args_array.append({"env": env_data})
|
|
235
|
+
args_array.append({"env": env.instance_id})
|
|
254
236
|
args = tuple(args_array)
|
|
255
237
|
|
|
256
238
|
try:
|
|
@@ -783,335 +783,6 @@ class SyncSnapshotDiff:
|
|
|
783
783
|
|
|
784
784
|
return self
|
|
785
785
|
|
|
786
|
-
def _expect_only_targeted_v2(self, allowed_changes: List[Dict[str, Any]]):
|
|
787
|
-
"""Optimized v2 version that only queries specific rows mentioned in allowed_changes."""
|
|
788
|
-
import concurrent.futures
|
|
789
|
-
from threading import Lock
|
|
790
|
-
|
|
791
|
-
# Helper functions for v2 validation (same as in _validate_diff_against_allowed_changes_v2)
|
|
792
|
-
def _parse_fields_spec(
|
|
793
|
-
fields_spec: List[Tuple[str, Any]]
|
|
794
|
-
) -> Dict[str, Tuple[bool, Any]]:
|
|
795
|
-
"""Parse a fields spec into a mapping of field_name -> (should_check_value, expected_value)."""
|
|
796
|
-
spec_map: Dict[str, Tuple[bool, Any]] = {}
|
|
797
|
-
for spec_tuple in fields_spec:
|
|
798
|
-
if len(spec_tuple) != 2:
|
|
799
|
-
raise ValueError(
|
|
800
|
-
f"Invalid field spec tuple: {spec_tuple}. "
|
|
801
|
-
f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
|
|
802
|
-
)
|
|
803
|
-
field_name, expected_value = spec_tuple
|
|
804
|
-
if expected_value is ...:
|
|
805
|
-
spec_map[field_name] = (False, None)
|
|
806
|
-
else:
|
|
807
|
-
spec_map[field_name] = (True, expected_value)
|
|
808
|
-
return spec_map
|
|
809
|
-
|
|
810
|
-
def _validate_row_with_fields_spec(
|
|
811
|
-
table: str,
|
|
812
|
-
row_id: Any,
|
|
813
|
-
row_data: Dict[str, Any],
|
|
814
|
-
fields_spec: List[Tuple[str, Any]],
|
|
815
|
-
) -> Optional[List[Tuple[str, Any, str]]]:
|
|
816
|
-
"""Validate an inserted/deleted row against a bulk fields spec."""
|
|
817
|
-
spec_map = _parse_fields_spec(fields_spec)
|
|
818
|
-
unmatched_fields: List[Tuple[str, Any, str]] = []
|
|
819
|
-
|
|
820
|
-
for field_name, field_value in row_data.items():
|
|
821
|
-
if field_name == "rowid":
|
|
822
|
-
continue
|
|
823
|
-
if self.ignore_config.should_ignore_field(table, field_name):
|
|
824
|
-
continue
|
|
825
|
-
|
|
826
|
-
if field_name not in spec_map:
|
|
827
|
-
unmatched_fields.append(
|
|
828
|
-
(field_name, field_value, "NOT_IN_FIELDS_SPEC")
|
|
829
|
-
)
|
|
830
|
-
else:
|
|
831
|
-
should_check, expected_value = spec_map[field_name]
|
|
832
|
-
if should_check and not _values_equivalent(
|
|
833
|
-
expected_value, field_value
|
|
834
|
-
):
|
|
835
|
-
unmatched_fields.append(
|
|
836
|
-
(field_name, field_value, f"expected {repr(expected_value)}")
|
|
837
|
-
)
|
|
838
|
-
|
|
839
|
-
return unmatched_fields if unmatched_fields else None
|
|
840
|
-
|
|
841
|
-
def _validate_modification_with_fields_spec(
|
|
842
|
-
table: str,
|
|
843
|
-
row_id: Any,
|
|
844
|
-
row_changes: Dict[str, Dict[str, Any]],
|
|
845
|
-
resulting_fields: List[Tuple[str, Any]],
|
|
846
|
-
no_other_changes: bool,
|
|
847
|
-
) -> Optional[List[Tuple[str, Any, str]]]:
|
|
848
|
-
"""Validate a modification against a resulting_fields spec."""
|
|
849
|
-
spec_map = _parse_fields_spec(resulting_fields)
|
|
850
|
-
unmatched_fields: List[Tuple[str, Any, str]] = []
|
|
851
|
-
|
|
852
|
-
for field_name, vals in row_changes.items():
|
|
853
|
-
if self.ignore_config.should_ignore_field(table, field_name):
|
|
854
|
-
continue
|
|
855
|
-
|
|
856
|
-
after_value = vals["after"]
|
|
857
|
-
|
|
858
|
-
if field_name not in spec_map:
|
|
859
|
-
if no_other_changes:
|
|
860
|
-
unmatched_fields.append(
|
|
861
|
-
(field_name, after_value, "NOT_IN_RESULTING_FIELDS")
|
|
862
|
-
)
|
|
863
|
-
else:
|
|
864
|
-
should_check, expected_value = spec_map[field_name]
|
|
865
|
-
if should_check and not _values_equivalent(
|
|
866
|
-
expected_value, after_value
|
|
867
|
-
):
|
|
868
|
-
unmatched_fields.append(
|
|
869
|
-
(field_name, after_value, f"expected {repr(expected_value)}")
|
|
870
|
-
)
|
|
871
|
-
|
|
872
|
-
return unmatched_fields if unmatched_fields else None
|
|
873
|
-
|
|
874
|
-
# Group allowed changes by table
|
|
875
|
-
changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
|
|
876
|
-
for change in allowed_changes:
|
|
877
|
-
table = change["table"]
|
|
878
|
-
if table not in changes_by_table:
|
|
879
|
-
changes_by_table[table] = []
|
|
880
|
-
changes_by_table[table].append(change)
|
|
881
|
-
|
|
882
|
-
errors = []
|
|
883
|
-
errors_lock = Lock()
|
|
884
|
-
|
|
885
|
-
def _get_change_spec(table_changes: List[Dict[str, Any]], pk: Any) -> Optional[Dict[str, Any]]:
|
|
886
|
-
"""Get the change spec for a given pk."""
|
|
887
|
-
for change in table_changes:
|
|
888
|
-
if str(change.get("pk")) == str(pk):
|
|
889
|
-
return change
|
|
890
|
-
return None
|
|
891
|
-
|
|
892
|
-
def check_row(
|
|
893
|
-
table: str,
|
|
894
|
-
pk: Any,
|
|
895
|
-
table_changes: List[Dict[str, Any]],
|
|
896
|
-
pk_columns: List[str],
|
|
897
|
-
):
|
|
898
|
-
"""Check a single row against v2 specs."""
|
|
899
|
-
try:
|
|
900
|
-
where_sql = self._build_pk_where_clause(pk_columns, pk)
|
|
901
|
-
|
|
902
|
-
# Query both snapshots
|
|
903
|
-
before_query = f"SELECT * FROM {table} WHERE {where_sql}"
|
|
904
|
-
before_response = self.before.resource.query(before_query)
|
|
905
|
-
before_row = (
|
|
906
|
-
dict(zip(before_response.columns, before_response.rows[0]))
|
|
907
|
-
if before_response.rows
|
|
908
|
-
else None
|
|
909
|
-
)
|
|
910
|
-
|
|
911
|
-
after_response = self.after.resource.query(before_query)
|
|
912
|
-
after_row = (
|
|
913
|
-
dict(zip(after_response.columns, after_response.rows[0]))
|
|
914
|
-
if after_response.rows
|
|
915
|
-
else None
|
|
916
|
-
)
|
|
917
|
-
|
|
918
|
-
# Get the spec for this pk
|
|
919
|
-
spec = _get_change_spec(table_changes, pk)
|
|
920
|
-
if spec is None:
|
|
921
|
-
return # No spec found, shouldn't happen
|
|
922
|
-
|
|
923
|
-
change_type = spec.get("type")
|
|
924
|
-
|
|
925
|
-
if not before_row and after_row:
|
|
926
|
-
# Inserted row
|
|
927
|
-
if change_type != "insert":
|
|
928
|
-
error_msg = f"Row {pk} in table '{table}' was inserted but spec type is '{change_type}'"
|
|
929
|
-
with errors_lock:
|
|
930
|
-
errors.append(AssertionError(error_msg))
|
|
931
|
-
return
|
|
932
|
-
|
|
933
|
-
fields_spec = spec.get("fields")
|
|
934
|
-
if fields_spec is not None:
|
|
935
|
-
unmatched = _validate_row_with_fields_spec(
|
|
936
|
-
table, pk, after_row, fields_spec
|
|
937
|
-
)
|
|
938
|
-
if unmatched:
|
|
939
|
-
error_msg = (
|
|
940
|
-
f"Insert validation failed for table '{table}', row {pk}:\n"
|
|
941
|
-
+ "\n".join(
|
|
942
|
-
f" - {f}: {repr(v)} ({issue})"
|
|
943
|
-
for f, v, issue in unmatched[:5]
|
|
944
|
-
)
|
|
945
|
-
)
|
|
946
|
-
with errors_lock:
|
|
947
|
-
errors.append(AssertionError(error_msg))
|
|
948
|
-
|
|
949
|
-
elif before_row and not after_row:
|
|
950
|
-
# Deleted row
|
|
951
|
-
if change_type != "delete":
|
|
952
|
-
error_msg = f"Row {pk} in table '{table}' was deleted but spec type is '{change_type}'"
|
|
953
|
-
with errors_lock:
|
|
954
|
-
errors.append(AssertionError(error_msg))
|
|
955
|
-
return
|
|
956
|
-
|
|
957
|
-
fields_spec = spec.get("fields")
|
|
958
|
-
if fields_spec is not None:
|
|
959
|
-
unmatched = _validate_row_with_fields_spec(
|
|
960
|
-
table, pk, before_row, fields_spec
|
|
961
|
-
)
|
|
962
|
-
if unmatched:
|
|
963
|
-
error_msg = (
|
|
964
|
-
f"Delete validation failed for table '{table}', row {pk}:\n"
|
|
965
|
-
+ "\n".join(
|
|
966
|
-
f" - {f}: {repr(v)} ({issue})"
|
|
967
|
-
for f, v, issue in unmatched[:5]
|
|
968
|
-
)
|
|
969
|
-
)
|
|
970
|
-
with errors_lock:
|
|
971
|
-
errors.append(AssertionError(error_msg))
|
|
972
|
-
|
|
973
|
-
elif before_row and after_row:
|
|
974
|
-
# Modified row - compute changes
|
|
975
|
-
row_changes = {}
|
|
976
|
-
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
977
|
-
if self.ignore_config.should_ignore_field(table, field):
|
|
978
|
-
continue
|
|
979
|
-
before_val = before_row.get(field)
|
|
980
|
-
after_val = after_row.get(field)
|
|
981
|
-
if not _values_equivalent(before_val, after_val):
|
|
982
|
-
row_changes[field] = {"before": before_val, "after": after_val}
|
|
983
|
-
|
|
984
|
-
if not row_changes:
|
|
985
|
-
# No actual changes (after ignores), this is fine
|
|
986
|
-
return
|
|
987
|
-
|
|
988
|
-
if change_type != "modify":
|
|
989
|
-
error_msg = f"Row {pk} in table '{table}' was modified but spec type is '{change_type}'"
|
|
990
|
-
with errors_lock:
|
|
991
|
-
errors.append(AssertionError(error_msg))
|
|
992
|
-
return
|
|
993
|
-
|
|
994
|
-
resulting_fields = spec.get("resulting_fields")
|
|
995
|
-
if resulting_fields is not None:
|
|
996
|
-
if "no_other_changes" not in spec:
|
|
997
|
-
error_msg = (
|
|
998
|
-
f"Modify spec for table '{table}' pk={pk} "
|
|
999
|
-
f"has 'resulting_fields' but missing required 'no_other_changes' field."
|
|
1000
|
-
)
|
|
1001
|
-
with errors_lock:
|
|
1002
|
-
errors.append(ValueError(error_msg))
|
|
1003
|
-
return
|
|
1004
|
-
|
|
1005
|
-
no_other_changes = spec["no_other_changes"]
|
|
1006
|
-
if not isinstance(no_other_changes, bool):
|
|
1007
|
-
error_msg = (
|
|
1008
|
-
f"Modify spec for table '{table}' pk={pk} "
|
|
1009
|
-
f"has 'no_other_changes' but it must be a boolean."
|
|
1010
|
-
)
|
|
1011
|
-
with errors_lock:
|
|
1012
|
-
errors.append(ValueError(error_msg))
|
|
1013
|
-
return
|
|
1014
|
-
|
|
1015
|
-
unmatched = _validate_modification_with_fields_spec(
|
|
1016
|
-
table, pk, row_changes, resulting_fields, no_other_changes
|
|
1017
|
-
)
|
|
1018
|
-
if unmatched:
|
|
1019
|
-
error_msg = (
|
|
1020
|
-
f"Modify validation failed for table '{table}', row {pk}:\n"
|
|
1021
|
-
+ "\n".join(
|
|
1022
|
-
f" - {f}: {repr(v)} ({issue})"
|
|
1023
|
-
for f, v, issue in unmatched[:5]
|
|
1024
|
-
)
|
|
1025
|
-
)
|
|
1026
|
-
with errors_lock:
|
|
1027
|
-
errors.append(AssertionError(error_msg))
|
|
1028
|
-
|
|
1029
|
-
else:
|
|
1030
|
-
# Row doesn't exist in either snapshot
|
|
1031
|
-
error_msg = f"Row {pk} not found in table '{table}' in either snapshot"
|
|
1032
|
-
with errors_lock:
|
|
1033
|
-
errors.append(AssertionError(error_msg))
|
|
1034
|
-
|
|
1035
|
-
except Exception as e:
|
|
1036
|
-
with errors_lock:
|
|
1037
|
-
errors.append(e)
|
|
1038
|
-
|
|
1039
|
-
# Prepare all row checks
|
|
1040
|
-
row_checks = []
|
|
1041
|
-
for table, table_changes in changes_by_table.items():
|
|
1042
|
-
if self.ignore_config.should_ignore_table(table):
|
|
1043
|
-
continue
|
|
1044
|
-
|
|
1045
|
-
pk_columns = self._get_primary_key_columns(table)
|
|
1046
|
-
pks_to_check = {change["pk"] for change in table_changes}
|
|
1047
|
-
|
|
1048
|
-
for pk in pks_to_check:
|
|
1049
|
-
row_checks.append((table, pk, table_changes, pk_columns))
|
|
1050
|
-
|
|
1051
|
-
# Execute row checks in parallel
|
|
1052
|
-
if row_checks:
|
|
1053
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
|
1054
|
-
futures = [
|
|
1055
|
-
executor.submit(check_row, table, pk, table_changes, pk_columns)
|
|
1056
|
-
for table, pk, table_changes, pk_columns in row_checks
|
|
1057
|
-
]
|
|
1058
|
-
concurrent.futures.wait(futures)
|
|
1059
|
-
|
|
1060
|
-
if errors:
|
|
1061
|
-
raise errors[0]
|
|
1062
|
-
|
|
1063
|
-
# Verify tables not mentioned have no changes
|
|
1064
|
-
all_tables = set(self.before.tables()) | set(self.after.tables())
|
|
1065
|
-
tables_to_verify = []
|
|
1066
|
-
|
|
1067
|
-
for table in all_tables:
|
|
1068
|
-
if (
|
|
1069
|
-
table not in changes_by_table
|
|
1070
|
-
and not self.ignore_config.should_ignore_table(table)
|
|
1071
|
-
):
|
|
1072
|
-
tables_to_verify.append(table)
|
|
1073
|
-
|
|
1074
|
-
def verify_no_changes(table: str):
|
|
1075
|
-
try:
|
|
1076
|
-
before_count_response = self.before.resource.query(
|
|
1077
|
-
f"SELECT COUNT(*) FROM {table}"
|
|
1078
|
-
)
|
|
1079
|
-
before_count = (
|
|
1080
|
-
before_count_response.rows[0][0]
|
|
1081
|
-
if before_count_response.rows
|
|
1082
|
-
else 0
|
|
1083
|
-
)
|
|
1084
|
-
|
|
1085
|
-
after_count_response = self.after.resource.query(
|
|
1086
|
-
f"SELECT COUNT(*) FROM {table}"
|
|
1087
|
-
)
|
|
1088
|
-
after_count = (
|
|
1089
|
-
after_count_response.rows[0][0] if after_count_response.rows else 0
|
|
1090
|
-
)
|
|
1091
|
-
|
|
1092
|
-
if before_count != after_count:
|
|
1093
|
-
error_msg = (
|
|
1094
|
-
f"Unexpected change in table '{table}': "
|
|
1095
|
-
f"row count changed from {before_count} to {after_count}"
|
|
1096
|
-
)
|
|
1097
|
-
with errors_lock:
|
|
1098
|
-
errors.append(AssertionError(error_msg))
|
|
1099
|
-
except Exception as e:
|
|
1100
|
-
with errors_lock:
|
|
1101
|
-
errors.append(e)
|
|
1102
|
-
|
|
1103
|
-
if tables_to_verify:
|
|
1104
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
|
1105
|
-
futures = [
|
|
1106
|
-
executor.submit(verify_no_changes, table) for table in tables_to_verify
|
|
1107
|
-
]
|
|
1108
|
-
concurrent.futures.wait(futures)
|
|
1109
|
-
|
|
1110
|
-
if errors:
|
|
1111
|
-
raise errors[0]
|
|
1112
|
-
|
|
1113
|
-
return self
|
|
1114
|
-
|
|
1115
786
|
def _validate_diff_against_allowed_changes(
|
|
1116
787
|
self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
|
|
1117
788
|
):
|
|
@@ -1772,14 +1443,54 @@ class SyncSnapshotDiff:
|
|
|
1772
1443
|
if not allowed_changes:
|
|
1773
1444
|
return self._expect_no_changes()
|
|
1774
1445
|
|
|
1775
|
-
#
|
|
1776
|
-
if self._can_use_targeted_queries(allowed_changes):
|
|
1777
|
-
return self._expect_only_targeted_v2(allowed_changes)
|
|
1778
|
-
|
|
1779
|
-
# Fall back to full diff for complex cases
|
|
1446
|
+
# Fall back to full diff for v2 (no targeted optimization yet)
|
|
1780
1447
|
diff = self._collect()
|
|
1781
1448
|
return self._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
1782
1449
|
|
|
1450
|
+
def _ensure_all_fetched(self):
|
|
1451
|
+
"""Fetch ALL data from ALL tables upfront (non-lazy loading).
|
|
1452
|
+
|
|
1453
|
+
This is the old approach before lazy loading was introduced.
|
|
1454
|
+
Used by expect_only_v1 for simpler, non-optimized diffing.
|
|
1455
|
+
"""
|
|
1456
|
+
# Get all tables
|
|
1457
|
+
tables_response = self.before.resource.query(
|
|
1458
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
if tables_response.rows:
|
|
1462
|
+
before_tables = [row[0] for row in tables_response.rows]
|
|
1463
|
+
for table in before_tables:
|
|
1464
|
+
self.before._ensure_table_data(table)
|
|
1465
|
+
|
|
1466
|
+
# Also fetch from after snapshot
|
|
1467
|
+
tables_response = self.after.resource.query(
|
|
1468
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
if tables_response.rows:
|
|
1472
|
+
after_tables = [row[0] for row in tables_response.rows]
|
|
1473
|
+
for table in after_tables:
|
|
1474
|
+
self.after._ensure_table_data(table)
|
|
1475
|
+
|
|
1476
|
+
def expect_only_v1(self, allowed_changes: List[Dict[str, Any]]):
|
|
1477
|
+
"""Ensure only specified changes occurred using the original (non-optimized) approach.
|
|
1478
|
+
|
|
1479
|
+
This is the original expect_only logic before lazy loading and targeted query
|
|
1480
|
+
optimizations were introduced. It fetches all data upfront and does a full diff.
|
|
1481
|
+
|
|
1482
|
+
Use this when you want the simpler, more predictable behavior of the original
|
|
1483
|
+
implementation without any query optimizations.
|
|
1484
|
+
"""
|
|
1485
|
+
# Fetch all data upfront (old approach)
|
|
1486
|
+
self._ensure_all_fetched()
|
|
1487
|
+
|
|
1488
|
+
# Collect full diff
|
|
1489
|
+
diff = self._collect()
|
|
1490
|
+
|
|
1491
|
+
# Validate using the original validation logic
|
|
1492
|
+
return self._validate_diff_against_allowed_changes(diff, allowed_changes)
|
|
1493
|
+
|
|
1783
1494
|
|
|
1784
1495
|
class SyncQueryBuilder:
|
|
1785
1496
|
"""Async query builder that translates DSL to SQL and executes through the API."""
|
|
@@ -243,25 +243,7 @@ Remote traceback:
|
|
|
243
243
|
) -> "VerifiersExecuteResponse":
|
|
244
244
|
"""Remote execution of the verifier function that returns the full response model."""
|
|
245
245
|
args_array = list(args)
|
|
246
|
-
|
|
247
|
-
env_data = {
|
|
248
|
-
"instance_id": env.instance_id,
|
|
249
|
-
"env_key": env.env_key,
|
|
250
|
-
"version": env.version,
|
|
251
|
-
"status": env.status,
|
|
252
|
-
"subdomain": env.subdomain,
|
|
253
|
-
"created_at": env.created_at,
|
|
254
|
-
"updated_at": env.updated_at,
|
|
255
|
-
"terminated_at": env.terminated_at,
|
|
256
|
-
"team_id": env.team_id,
|
|
257
|
-
"region": env.region,
|
|
258
|
-
"env_variables": env.env_variables,
|
|
259
|
-
"data_key": env.data_key,
|
|
260
|
-
"data_version": env.data_version,
|
|
261
|
-
"urls": env.urls.model_dump() if env.urls else None,
|
|
262
|
-
"health": env.health,
|
|
263
|
-
}
|
|
264
|
-
args_array.append({"env": env_data})
|
|
246
|
+
args_array.append({"env": env.instance_id})
|
|
265
247
|
args = tuple(args_array)
|
|
266
248
|
|
|
267
249
|
try:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|