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.
Files changed (92) hide show
  1. {fleet_python-0.2.76/fleet_python.egg-info → fleet_python-0.2.78}/PKG-INFO +1 -1
  2. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/iterate_verifiers.py +6 -0
  3. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/sqlite.py +45 -316
  4. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/verifiers/verifier.py +1 -19
  5. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/sqlite.py +45 -334
  6. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/verifier.py +1 -19
  7. {fleet_python-0.2.76 → fleet_python-0.2.78/fleet_python.egg-info}/PKG-INFO +1 -1
  8. {fleet_python-0.2.76 → fleet_python-0.2.78}/pyproject.toml +1 -1
  9. {fleet_python-0.2.76 → fleet_python-0.2.78}/LICENSE +0 -0
  10. {fleet_python-0.2.76 → fleet_python-0.2.78}/README.md +0 -0
  11. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/diff_example.py +0 -0
  12. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/dsl_example.py +0 -0
  13. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example.py +0 -0
  14. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/exampleResume.py +0 -0
  15. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_account.py +0 -0
  16. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_action_log.py +0 -0
  17. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_client.py +0 -0
  18. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_mcp_anthropic.py +0 -0
  19. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_mcp_openai.py +0 -0
  20. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_sync.py +0 -0
  21. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_task.py +0 -0
  22. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_tasks.py +0 -0
  23. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/example_verifier.py +0 -0
  24. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/export_tasks.py +0 -0
  25. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/fetch_tasks.py +0 -0
  26. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/gemini_example.py +0 -0
  27. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/import_tasks.py +0 -0
  28. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/json_tasks_example.py +0 -0
  29. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/nova_act_example.py +0 -0
  30. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/openai_example.py +0 -0
  31. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/openai_simple_example.py +0 -0
  32. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/query_builder_example.py +0 -0
  33. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/quickstart.py +0 -0
  34. {fleet_python-0.2.76 → fleet_python-0.2.78}/examples/test_cdp_logging.py +0 -0
  35. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/__init__.py +0 -0
  36. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/__init__.py +0 -0
  37. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/base.py +0 -0
  38. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/client.py +0 -0
  39. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/env/__init__.py +0 -0
  40. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/env/client.py +0 -0
  41. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/exceptions.py +0 -0
  42. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/global_client.py +0 -0
  43. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/instance/__init__.py +0 -0
  44. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/instance/base.py +0 -0
  45. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/instance/client.py +0 -0
  46. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/models.py +0 -0
  47. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/__init__.py +0 -0
  48. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/base.py +0 -0
  49. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/browser.py +0 -0
  50. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/resources/mcp.py +0 -0
  51. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/tasks.py +0 -0
  52. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/verifiers/__init__.py +0 -0
  53. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/_async/verifiers/bundler.py +0 -0
  54. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/base.py +0 -0
  55. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/client.py +0 -0
  56. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/config.py +0 -0
  57. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/env/__init__.py +0 -0
  58. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/env/client.py +0 -0
  59. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/exceptions.py +0 -0
  60. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/global_client.py +0 -0
  61. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/__init__.py +0 -0
  62. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/base.py +0 -0
  63. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/client.py +0 -0
  64. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/instance/models.py +0 -0
  65. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/models.py +0 -0
  66. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/__init__.py +0 -0
  67. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/base.py +0 -0
  68. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/browser.py +0 -0
  69. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/resources/mcp.py +0 -0
  70. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/tasks.py +0 -0
  71. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/types.py +0 -0
  72. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/__init__.py +0 -0
  73. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/bundler.py +0 -0
  74. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/code.py +0 -0
  75. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/db.py +0 -0
  76. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/decorator.py +0 -0
  77. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/parse.py +0 -0
  78. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet/verifiers/sql_differ.py +0 -0
  79. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/SOURCES.txt +0 -0
  80. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/dependency_links.txt +0 -0
  81. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/requires.txt +0 -0
  82. {fleet_python-0.2.76 → fleet_python-0.2.78}/fleet_python.egg-info/top_level.txt +0 -0
  83. {fleet_python-0.2.76 → fleet_python-0.2.78}/scripts/fix_sync_imports.py +0 -0
  84. {fleet_python-0.2.76 → fleet_python-0.2.78}/scripts/unasync.py +0 -0
  85. {fleet_python-0.2.76 → fleet_python-0.2.78}/setup.cfg +0 -0
  86. {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/__init__.py +0 -0
  87. {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_app_method.py +0 -0
  88. {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_expect_only.py +0 -0
  89. {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_instance_dispatch.py +0 -0
  90. {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_sqlite_resource_dual_mode.py +0 -0
  91. {fleet_python-0.2.76 → fleet_python-0.2.78}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  92. {fleet_python-0.2.76 → fleet_python-0.2.78}/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.76
3
+ Version: 0.2.78
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.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
- # 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
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
- # 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})
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
- # 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
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
- # 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})
246
+ args_array.append({"env": env.instance_id})
265
247
  args = tuple(args_array)
266
248
 
267
249
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.76
3
+ Version: 0.2.78
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.76"
8
+ version = "0.2.78"
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