fleet-python 0.2.75b3__tar.gz → 0.2.76__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 (92) hide show
  1. {fleet_python-0.2.75b3/fleet_python.egg-info → fleet_python-0.2.76}/PKG-INFO +1 -1
  2. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/iterate_verifiers.py +0 -6
  3. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/resources/sqlite.py +316 -1
  4. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/verifiers/verifier.py +19 -1
  5. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/resources/sqlite.py +334 -1
  6. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/verifier.py +19 -1
  7. {fleet_python-0.2.75b3 → fleet_python-0.2.76/fleet_python.egg-info}/PKG-INFO +1 -1
  8. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/pyproject.toml +1 -1
  9. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/LICENSE +0 -0
  10. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/README.md +0 -0
  11. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/diff_example.py +0 -0
  12. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/dsl_example.py +0 -0
  13. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example.py +0 -0
  14. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/exampleResume.py +0 -0
  15. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_account.py +0 -0
  16. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_action_log.py +0 -0
  17. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_client.py +0 -0
  18. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_mcp_anthropic.py +0 -0
  19. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_mcp_openai.py +0 -0
  20. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_sync.py +0 -0
  21. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_task.py +0 -0
  22. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_tasks.py +0 -0
  23. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/example_verifier.py +0 -0
  24. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/export_tasks.py +0 -0
  25. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/fetch_tasks.py +0 -0
  26. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/gemini_example.py +0 -0
  27. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/import_tasks.py +0 -0
  28. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/json_tasks_example.py +0 -0
  29. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/nova_act_example.py +0 -0
  30. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/openai_example.py +0 -0
  31. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/openai_simple_example.py +0 -0
  32. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/query_builder_example.py +0 -0
  33. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/quickstart.py +0 -0
  34. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/examples/test_cdp_logging.py +0 -0
  35. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/__init__.py +0 -0
  36. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/__init__.py +0 -0
  37. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/base.py +0 -0
  38. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/client.py +0 -0
  39. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/env/__init__.py +0 -0
  40. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/env/client.py +0 -0
  41. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/exceptions.py +0 -0
  42. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/global_client.py +0 -0
  43. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/instance/__init__.py +0 -0
  44. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/instance/base.py +0 -0
  45. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/instance/client.py +0 -0
  46. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/models.py +0 -0
  47. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/resources/__init__.py +0 -0
  48. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/resources/base.py +0 -0
  49. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/resources/browser.py +0 -0
  50. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/resources/mcp.py +0 -0
  51. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/tasks.py +0 -0
  52. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/verifiers/__init__.py +0 -0
  53. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/_async/verifiers/bundler.py +0 -0
  54. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/base.py +0 -0
  55. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/client.py +0 -0
  56. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/config.py +0 -0
  57. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/env/__init__.py +0 -0
  58. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/env/client.py +0 -0
  59. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/exceptions.py +0 -0
  60. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/global_client.py +0 -0
  61. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/instance/__init__.py +0 -0
  62. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/instance/base.py +0 -0
  63. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/instance/client.py +0 -0
  64. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/instance/models.py +0 -0
  65. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/models.py +0 -0
  66. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/resources/__init__.py +0 -0
  67. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/resources/base.py +0 -0
  68. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/resources/browser.py +0 -0
  69. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/resources/mcp.py +0 -0
  70. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/tasks.py +0 -0
  71. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/types.py +0 -0
  72. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/__init__.py +0 -0
  73. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/bundler.py +0 -0
  74. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/code.py +0 -0
  75. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/db.py +0 -0
  76. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/decorator.py +0 -0
  77. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/parse.py +0 -0
  78. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet/verifiers/sql_differ.py +0 -0
  79. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet_python.egg-info/SOURCES.txt +0 -0
  80. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet_python.egg-info/dependency_links.txt +0 -0
  81. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet_python.egg-info/requires.txt +0 -0
  82. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/fleet_python.egg-info/top_level.txt +0 -0
  83. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/scripts/fix_sync_imports.py +0 -0
  84. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/scripts/unasync.py +0 -0
  85. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/setup.cfg +0 -0
  86. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/tests/__init__.py +0 -0
  87. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/tests/test_app_method.py +0 -0
  88. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/tests/test_expect_only.py +0 -0
  89. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/tests/test_instance_dispatch.py +0 -0
  90. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/tests/test_sqlite_resource_dual_mode.py +0 -0
  91. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  92. {fleet_python-0.2.75b3 → fleet_python-0.2.76}/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.75b3
3
+ Version: 0.2.76
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -387,12 +387,6 @@ 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
-
396
390
  old_code = task.get("verifier_func", "").strip()
397
391
 
398
392
  # Only update if the code actually changed
@@ -761,6 +761,317 @@ 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
+
764
1075
  async def _validate_diff_against_allowed_changes(
765
1076
  self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
766
1077
  ):
@@ -1421,7 +1732,11 @@ class AsyncSnapshotDiff:
1421
1732
  if not allowed_changes:
1422
1733
  return await self._expect_no_changes()
1423
1734
 
1424
- # Fall back to full diff for v2 (no targeted optimization yet)
1735
+ # Use targeted optimization when possible (all changes have table and pk)
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
1425
1740
  diff = await self._collect()
1426
1741
  return await self._validate_diff_against_allowed_changes_v2(
1427
1742
  diff, allowed_changes
@@ -232,7 +232,25 @@ 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
- args_array.append({"env": env.instance_id})
235
+ # Send complete environment data for remote reconstruction
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})
236
254
  args = tuple(args_array)
237
255
 
238
256
  try:
@@ -783,6 +783,335 @@ 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
+
786
1115
  def _validate_diff_against_allowed_changes(
787
1116
  self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
788
1117
  ):
@@ -1443,7 +1772,11 @@ class SyncSnapshotDiff:
1443
1772
  if not allowed_changes:
1444
1773
  return self._expect_no_changes()
1445
1774
 
1446
- # Fall back to full diff for v2 (no targeted optimization yet)
1775
+ # Use targeted optimization when possible (all changes have table and pk)
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
1447
1780
  diff = self._collect()
1448
1781
  return self._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
1449
1782
 
@@ -243,7 +243,25 @@ 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
- args_array.append({"env": env.instance_id})
246
+ # Send complete environment data for remote reconstruction
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})
247
265
  args = tuple(args_array)
248
266
 
249
267
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.75b3
3
+ Version: 0.2.76
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "fleet-python"
7
7
 
8
- version = "0.2.75b3"
8
+ version = "0.2.76"
9
9
  description = "Python SDK for Fleet environments"
10
10
  authors = [
11
11
  {name = "Fleet AI", email = "nic@fleet.so"},
File without changes
File without changes
File without changes