fleet-python 0.2.118__tar.gz → 0.2.119__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 (126) hide show
  1. {fleet_python-0.2.118/fleet_python.egg-info → fleet_python-0.2.119}/PKG-INFO +1 -1
  2. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/__init__.py +1 -1
  3. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/__init__.py +1 -1
  4. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/base.py +1 -1
  5. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/base.py +1 -1
  6. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/db.py +429 -0
  7. {fleet_python-0.2.118 → fleet_python-0.2.119/fleet_python.egg-info}/PKG-INFO +1 -1
  8. {fleet_python-0.2.118 → fleet_python-0.2.119}/pyproject.toml +1 -1
  9. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_expect_only.py +15 -15
  10. {fleet_python-0.2.118 → fleet_python-0.2.119}/LICENSE +0 -0
  11. {fleet_python-0.2.118 → fleet_python-0.2.119}/README.md +0 -0
  12. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/diff_example.py +0 -0
  13. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/dsl_example.py +0 -0
  14. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example.py +0 -0
  15. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/exampleResume.py +0 -0
  16. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_account.py +0 -0
  17. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_action_log.py +0 -0
  18. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_client.py +0 -0
  19. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_mcp_anthropic.py +0 -0
  20. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_mcp_openai.py +0 -0
  21. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_sync.py +0 -0
  22. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_task.py +0 -0
  23. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_tasks.py +0 -0
  24. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/example_verifier.py +0 -0
  25. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/export_tasks.py +0 -0
  26. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/export_tasks_filtered.py +0 -0
  27. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/fetch_tasks.py +0 -0
  28. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/gemini_example.py +0 -0
  29. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/import_tasks.py +0 -0
  30. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/iterate_verifiers.py +0 -0
  31. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/json_tasks_example.py +0 -0
  32. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/nova_act_example.py +0 -0
  33. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/openai_example.py +0 -0
  34. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/openai_simple_example.py +0 -0
  35. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/query_builder_example.py +0 -0
  36. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/quickstart.py +0 -0
  37. {fleet_python-0.2.118 → fleet_python-0.2.119}/examples/test_cdp_logging.py +0 -0
  38. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/client.py +0 -0
  39. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/env/__init__.py +0 -0
  40. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/env/client.py +0 -0
  41. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/exceptions.py +0 -0
  42. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/global_client.py +0 -0
  43. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/instance/__init__.py +0 -0
  44. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/instance/base.py +0 -0
  45. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/instance/client.py +0 -0
  46. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/judge.py +0 -0
  47. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/models.py +0 -0
  48. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/__init__.py +0 -0
  49. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/api.py +0 -0
  50. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/base.py +0 -0
  51. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/browser.py +0 -0
  52. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/filesystem.py +0 -0
  53. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/mcp.py +0 -0
  54. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/resources/sqlite.py +0 -0
  55. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/tasks.py +0 -0
  56. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/verifiers/__init__.py +0 -0
  57. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/verifiers/bundler.py +0 -0
  58. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/_async/verifiers/verifier.py +0 -0
  59. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/__init__.py +0 -0
  60. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/Dockerfile +0 -0
  61. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/__init__.py +0 -0
  62. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/agent.py +0 -0
  63. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp/main.py +0 -0
  64. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
  65. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
  66. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
  67. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/requirements.txt +0 -0
  68. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/gemini_cua/start.sh +0 -0
  69. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/orchestrator.py +0 -0
  70. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/types.py +0 -0
  71. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/agent/utils.py +0 -0
  72. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/cli.py +0 -0
  73. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/client.py +0 -0
  74. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/config.py +0 -0
  75. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/env/__init__.py +0 -0
  76. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/env/client.py +0 -0
  77. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/eval/__init__.py +0 -0
  78. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/eval/uploader.py +0 -0
  79. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/exceptions.py +0 -0
  80. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/global_client.py +0 -0
  81. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/__init__.py +0 -0
  82. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/base.py +0 -0
  83. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/client.py +0 -0
  84. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/instance/models.py +0 -0
  85. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/judge.py +0 -0
  86. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/models.py +0 -0
  87. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/proxy/__init__.py +0 -0
  88. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/proxy/proxy.py +0 -0
  89. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/proxy/whitelist.py +0 -0
  90. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/__init__.py +0 -0
  91. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/api.py +0 -0
  92. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/base.py +0 -0
  93. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/browser.py +0 -0
  94. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/filesystem.py +0 -0
  95. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/mcp.py +0 -0
  96. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/resources/sqlite.py +0 -0
  97. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/tasks.py +0 -0
  98. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/types.py +0 -0
  99. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/__init__.py +0 -0
  100. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/http_logging.py +0 -0
  101. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/logging.py +0 -0
  102. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/utils/playwright.py +0 -0
  103. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/__init__.py +0 -0
  104. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/bundler.py +0 -0
  105. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/code.py +0 -0
  106. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/decorator.py +0 -0
  107. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/local_executor.py +0 -0
  108. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/parse.py +0 -0
  109. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/sql_differ.py +0 -0
  110. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet/verifiers/verifier.py +0 -0
  111. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/SOURCES.txt +0 -0
  112. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/dependency_links.txt +0 -0
  113. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/entry_points.txt +0 -0
  114. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/requires.txt +0 -0
  115. {fleet_python-0.2.118 → fleet_python-0.2.119}/fleet_python.egg-info/top_level.txt +0 -0
  116. {fleet_python-0.2.118 → fleet_python-0.2.119}/scripts/fix_sync_imports.py +0 -0
  117. {fleet_python-0.2.118 → fleet_python-0.2.119}/scripts/unasync.py +0 -0
  118. {fleet_python-0.2.118 → fleet_python-0.2.119}/setup.cfg +0 -0
  119. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/__init__.py +0 -0
  120. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_app_method.py +0 -0
  121. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_expect_exactly.py +0 -0
  122. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_instance_dispatch.py +0 -0
  123. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_judge_criteria_markers.py +0 -0
  124. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_sqlite_resource_dual_mode.py +0 -0
  125. {fleet_python-0.2.118 → fleet_python-0.2.119}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  126. {fleet_python-0.2.118 → fleet_python-0.2.119}/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.118
3
+ Version: 0.2.119
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -79,7 +79,7 @@ from . import env
79
79
  from . import global_client as _global_client
80
80
  from ._async import global_client as _async_global_client
81
81
 
82
- __version__ = "0.2.118"
82
+ __version__ = "0.2.119"
83
83
 
84
84
  __all__ = [
85
85
  # Core classes
@@ -44,7 +44,7 @@ from ..types import VerifierFunction
44
44
  from .. import env
45
45
  from . import global_client as _async_global_client
46
46
 
47
- __version__ = "0.2.118"
47
+ __version__ = "0.2.119"
48
48
 
49
49
  __all__ = [
50
50
  # Core classes
@@ -26,7 +26,7 @@ from .exceptions import (
26
26
  try:
27
27
  from .. import __version__
28
28
  except ImportError:
29
- __version__ = "0.2.118"
29
+ __version__ = "0.2.119"
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -27,7 +27,7 @@ from .exceptions import (
27
27
  try:
28
28
  from . import __version__
29
29
  except ImportError:
30
- __version__ = "0.2.118"
30
+ __version__ = "0.2.119"
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -891,9 +891,413 @@ class SnapshotDiff:
891
891
  return diff
892
892
 
893
893
  # ------------------------------------------------------------------
894
+ def _can_use_targeted_queries(self, allowed_changes: List[Dict[str, Any]]) -> bool:
895
+ """Check if we can use targeted queries for optimization."""
896
+ for change in allowed_changes:
897
+ if "table" not in change or "pk" not in change:
898
+ return False
899
+ return True
900
+
901
+ def _query_row(self, db_path: str, table: str, pk_columns: List[str], pk: Any) -> Optional[Dict[str, Any]]:
902
+ """Query a single row by primary key from a SQLite database."""
903
+ conn = sqlite3.connect(db_path)
904
+ conn.row_factory = sqlite3.Row
905
+ try:
906
+ if len(pk_columns) == 1:
907
+ where_sql = f"{pk_columns[0]} = ?"
908
+ params = [pk]
909
+ else:
910
+ parts = []
911
+ params = []
912
+ pk_vals = pk if isinstance(pk, tuple) else (pk,)
913
+ for col, val in zip(pk_columns, pk_vals):
914
+ parts.append(f"{col} = ?")
915
+ params.append(val)
916
+ where_sql = " AND ".join(parts)
917
+ cursor = conn.execute(
918
+ f"SELECT rowid, * FROM {table} WHERE {where_sql}", params
919
+ )
920
+ row = cursor.fetchone()
921
+ return dict(row) if row else None
922
+ finally:
923
+ conn.close()
924
+
925
+ def _get_row_count(self, db_path: str, table: str) -> int:
926
+ """Get row count for a table."""
927
+ conn = sqlite3.connect(db_path)
928
+ try:
929
+ cursor = conn.execute(f"SELECT COUNT(*) FROM {table}")
930
+ return cursor.fetchone()[0]
931
+ finally:
932
+ conn.close()
933
+
934
+ def _get_pk_columns(self, table: str) -> List[str]:
935
+ """Get primary key columns for a table."""
936
+ return self._differ.get_primary_key_columns(self._differ.before_db, table) or ["id"]
937
+
938
+ def _expect_only_targeted(self, allowed_changes: List[Dict[str, Any]]):
939
+ """Optimized version that only queries specific rows mentioned in allowed_changes.
940
+
941
+ Matches the behavior of the production SyncSnapshotDiff._expect_only_targeted
942
+ in fleet/resources/sqlite.py: checks specified rows for expected changes, then
943
+ verifies tables not mentioned in allowed_changes have no row count changes.
944
+ """
945
+ # Group allowed changes by table
946
+ changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
947
+ for change in allowed_changes:
948
+ table = change["table"]
949
+ if table not in changes_by_table:
950
+ changes_by_table[table] = []
951
+ changes_by_table[table].append(change)
952
+
953
+ errors: List[Exception] = []
954
+
955
+ # Check each specified row
956
+ for table, table_changes in changes_by_table.items():
957
+ if self.ignore_config.should_ignore_table(table):
958
+ continue
959
+
960
+ pk_columns = self._get_pk_columns(table)
961
+ pks_to_check = {change["pk"] for change in table_changes}
962
+
963
+ for pk in pks_to_check:
964
+ before_row = self._query_row(self.before.db_path, table, pk_columns, pk)
965
+ after_row = self._query_row(self.after.db_path, table, pk_columns, pk)
966
+
967
+ if before_row and after_row:
968
+ # Modified row - check fields
969
+ for field in set(before_row.keys()) | set(after_row.keys()):
970
+ if self.ignore_config.should_ignore_field(table, field):
971
+ continue
972
+ before_val = before_row.get(field)
973
+ after_val = after_row.get(field)
974
+ if not _values_equivalent(before_val, after_val):
975
+ # Check if this field change is allowed
976
+ allowed = False
977
+ for tc in table_changes:
978
+ tc_pk = tc.get("pk")
979
+ pk_match = (
980
+ str(tc_pk) == str(pk) if tc_pk is not None else False
981
+ )
982
+ if (
983
+ pk_match
984
+ and tc.get("field") == field
985
+ and _values_equivalent(tc.get("after"), after_val)
986
+ ):
987
+ allowed = True
988
+ break
989
+ if not allowed:
990
+ errors.append(
991
+ AssertionError(
992
+ f"Unexpected change in table '{table}', "
993
+ f"row {pk}, field '{field}': "
994
+ f"{repr(before_val)} -> {repr(after_val)}"
995
+ )
996
+ )
997
+ break # Stop checking this row
998
+ elif not before_row and after_row:
999
+ # Added row - check if allowed
1000
+ allowed = False
1001
+ for tc in table_changes:
1002
+ tc_pk = tc.get("pk")
1003
+ pk_match = (
1004
+ str(tc_pk) == str(pk) if tc_pk is not None else False
1005
+ )
1006
+ if pk_match and _values_equivalent(tc.get("after"), "__added__"):
1007
+ allowed = True
1008
+ break
1009
+ if not allowed:
1010
+ errors.append(
1011
+ AssertionError(f"Unexpected row added in table '{table}': {pk}")
1012
+ )
1013
+ elif before_row and not after_row:
1014
+ # Removed row - check if allowed
1015
+ allowed = False
1016
+ for tc in table_changes:
1017
+ tc_pk = tc.get("pk")
1018
+ pk_match = (
1019
+ str(tc_pk) == str(pk) if tc_pk is not None else False
1020
+ )
1021
+ if pk_match and _values_equivalent(tc.get("after"), "__removed__"):
1022
+ allowed = True
1023
+ break
1024
+ if not allowed:
1025
+ errors.append(
1026
+ AssertionError(f"Unexpected row removed from table '{table}': {pk}")
1027
+ )
1028
+
1029
+ if errors:
1030
+ raise errors[0]
1031
+
1032
+ # Check tables not mentioned in allowed_changes for row count changes
1033
+ all_tables = set(self.before.tables()) | set(self.after.tables())
1034
+ for table in all_tables:
1035
+ if table in changes_by_table:
1036
+ continue
1037
+ if self.ignore_config.should_ignore_table(table):
1038
+ continue
1039
+ before_count = self._get_row_count(self.before.db_path, table)
1040
+ after_count = self._get_row_count(self.after.db_path, table)
1041
+ if before_count != after_count:
1042
+ errors.append(
1043
+ AssertionError(
1044
+ f"Unexpected change in table '{table}': "
1045
+ f"row count changed from {before_count} to {after_count}"
1046
+ )
1047
+ )
1048
+
1049
+ if errors:
1050
+ raise errors[0]
1051
+
1052
+ return self
1053
+
1054
+ def _expect_only_targeted_v2(self, allowed_changes: List[Dict[str, Any]]):
1055
+ """Targeted version of expect_only_v2 that only queries specific rows.
1056
+
1057
+ Matches the behavior of the production SyncSnapshotDiff._expect_only_targeted_v2
1058
+ in fleet/resources/sqlite.py.
1059
+ """
1060
+ # Helper functions for v2 spec validation
1061
+ def _parse_fields_spec(
1062
+ fields_spec: List[tuple],
1063
+ ) -> Dict[str, tuple]:
1064
+ spec_map: Dict[str, tuple] = {}
1065
+ for spec_tuple in fields_spec:
1066
+ if len(spec_tuple) != 2:
1067
+ raise ValueError(
1068
+ f"Invalid field spec tuple: {spec_tuple}. "
1069
+ f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
1070
+ )
1071
+ field_name, expected_value = spec_tuple
1072
+ if expected_value is ...:
1073
+ spec_map[field_name] = (False, None)
1074
+ else:
1075
+ spec_map[field_name] = (True, expected_value)
1076
+ return spec_map
1077
+
1078
+ def _get_all_specs_for_pk(table: str, pk: Any) -> List[Dict[str, Any]]:
1079
+ specs = []
1080
+ for allowed in allowed_changes:
1081
+ if (
1082
+ allowed["table"] == table
1083
+ and str(allowed.get("pk")) == str(pk)
1084
+ ):
1085
+ specs.append(allowed)
1086
+ return specs
1087
+
1088
+ def _validate_insert_row(
1089
+ table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
1090
+ ) -> Optional[str]:
1091
+ for spec in specs:
1092
+ if spec.get("type") == "insert":
1093
+ fields_spec = spec.get("fields")
1094
+ if fields_spec is not None:
1095
+ spec_map = _parse_fields_spec(fields_spec)
1096
+ for field_name, field_value in row_data.items():
1097
+ if field_name == "rowid":
1098
+ continue
1099
+ if self.ignore_config.should_ignore_field(table, field_name):
1100
+ continue
1101
+ if field_name not in spec_map:
1102
+ return f"Field '{field_name}' not in insert spec for table '{table}' pk={pk}"
1103
+ should_check, expected_value = spec_map[field_name]
1104
+ if should_check and not _values_equivalent(expected_value, field_value):
1105
+ return (
1106
+ f"Insert mismatch in table '{table}' pk={pk}, "
1107
+ f"field '{field_name}': expected {repr(expected_value)}, got {repr(field_value)}"
1108
+ )
1109
+ return None
1110
+ for spec in specs:
1111
+ if spec.get("fields") is None and spec.get("after") == "__added__":
1112
+ return None
1113
+ return f"Unexpected row added in table '{table}': pk={pk}"
1114
+
1115
+ def _validate_delete_row(
1116
+ table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
1117
+ ) -> Optional[str]:
1118
+ for spec in specs:
1119
+ if spec.get("type") == "delete":
1120
+ fields_spec = spec.get("fields")
1121
+ if fields_spec is not None:
1122
+ spec_map = _parse_fields_spec(fields_spec)
1123
+ for field_name, (should_check, expected_value) in spec_map.items():
1124
+ if field_name not in row_data:
1125
+ return f"Field '{field_name}' in delete spec not found in row for table '{table}' pk={pk}"
1126
+ if should_check and not _values_equivalent(expected_value, row_data[field_name]):
1127
+ return (
1128
+ f"Delete mismatch in table '{table}' pk={pk}, "
1129
+ f"field '{field_name}': expected {repr(expected_value)}, got {repr(row_data[field_name])}"
1130
+ )
1131
+ return None
1132
+ for spec in specs:
1133
+ if spec.get("fields") is None and spec.get("after") == "__removed__":
1134
+ return None
1135
+ return f"Unexpected row removed from table '{table}': pk={pk}"
1136
+
1137
+ def _validate_modify_row(
1138
+ table: str,
1139
+ pk: Any,
1140
+ before_row: Dict[str, Any],
1141
+ after_row: Dict[str, Any],
1142
+ specs: List[Dict[str, Any]],
1143
+ ) -> Optional[str]:
1144
+ changed_fields: Dict[str, Dict[str, Any]] = {}
1145
+ for field in set(before_row.keys()) | set(after_row.keys()):
1146
+ if self.ignore_config.should_ignore_field(table, field):
1147
+ continue
1148
+ before_val = before_row.get(field)
1149
+ after_val = after_row.get(field)
1150
+ if not _values_equivalent(before_val, after_val):
1151
+ changed_fields[field] = {"before": before_val, "after": after_val}
1152
+ if not changed_fields:
1153
+ return None
1154
+ for spec in specs:
1155
+ if spec.get("type") == "modify":
1156
+ resulting_fields = spec.get("resulting_fields")
1157
+ if resulting_fields is not None:
1158
+ if "no_other_changes" not in spec:
1159
+ raise ValueError(
1160
+ f"Modify spec for table '{table}' pk={pk} "
1161
+ f"has 'resulting_fields' but missing required 'no_other_changes' field."
1162
+ )
1163
+ no_other_changes = spec["no_other_changes"]
1164
+ if not isinstance(no_other_changes, bool):
1165
+ raise ValueError(
1166
+ f"Modify spec for table '{table}' pk={pk} "
1167
+ f"'no_other_changes' must be boolean, got {type(no_other_changes).__name__}"
1168
+ )
1169
+ spec_map = _parse_fields_spec(resulting_fields)
1170
+ for field_name, vals in changed_fields.items():
1171
+ after_val = vals["after"]
1172
+ if field_name not in spec_map:
1173
+ if no_other_changes:
1174
+ return (
1175
+ f"Unexpected field change in table '{table}' pk={pk}: "
1176
+ f"field '{field_name}' not in resulting_fields"
1177
+ )
1178
+ else:
1179
+ should_check, expected_value = spec_map[field_name]
1180
+ if should_check and not _values_equivalent(expected_value, after_val):
1181
+ return (
1182
+ f"Modify mismatch in table '{table}' pk={pk}, "
1183
+ f"field '{field_name}': expected {repr(expected_value)}, got {repr(after_val)}"
1184
+ )
1185
+ return None
1186
+ else:
1187
+ return None
1188
+ # Legacy single-field specs
1189
+ for field_name, vals in changed_fields.items():
1190
+ after_val = vals["after"]
1191
+ field_allowed = False
1192
+ for spec in specs:
1193
+ if (
1194
+ spec.get("field") == field_name
1195
+ and _values_equivalent(spec.get("after"), after_val)
1196
+ ):
1197
+ field_allowed = True
1198
+ break
1199
+ if not field_allowed:
1200
+ return (
1201
+ f"Unexpected change in table '{table}' pk={pk}, "
1202
+ f"field '{field_name}': {repr(vals['before'])} -> {repr(after_val)}"
1203
+ )
1204
+ return None
1205
+
1206
+ # Group allowed changes by table
1207
+ changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
1208
+ for change in allowed_changes:
1209
+ table = change["table"]
1210
+ if table not in changes_by_table:
1211
+ changes_by_table[table] = []
1212
+ changes_by_table[table].append(change)
1213
+
1214
+ errors: List[Exception] = []
1215
+
1216
+ # Check each specified row
1217
+ for table, table_changes in changes_by_table.items():
1218
+ if self.ignore_config.should_ignore_table(table):
1219
+ continue
1220
+
1221
+ pk_columns = self._get_pk_columns(table)
1222
+ pks_to_check = {change["pk"] for change in table_changes}
1223
+
1224
+ for pk in pks_to_check:
1225
+ before_row = self._query_row(self.before.db_path, table, pk_columns, pk)
1226
+ after_row = self._query_row(self.after.db_path, table, pk_columns, pk)
1227
+ specs = _get_all_specs_for_pk(table, pk)
1228
+
1229
+ if before_row and after_row:
1230
+ error = _validate_modify_row(table, pk, before_row, after_row, specs)
1231
+ if error:
1232
+ errors.append(AssertionError(error))
1233
+ elif not before_row and after_row:
1234
+ error = _validate_insert_row(table, pk, after_row, specs)
1235
+ if error:
1236
+ errors.append(AssertionError(error))
1237
+ elif before_row and not after_row:
1238
+ error = _validate_delete_row(table, pk, before_row, specs)
1239
+ if error:
1240
+ errors.append(AssertionError(error))
1241
+
1242
+ if errors:
1243
+ raise errors[0]
1244
+
1245
+ # Check tables not mentioned in allowed_changes for row count changes
1246
+ all_tables = set(self.before.tables()) | set(self.after.tables())
1247
+ for table in all_tables:
1248
+ if table in changes_by_table:
1249
+ continue
1250
+ if self.ignore_config.should_ignore_table(table):
1251
+ continue
1252
+ before_count = self._get_row_count(self.before.db_path, table)
1253
+ after_count = self._get_row_count(self.after.db_path, table)
1254
+ if before_count != after_count:
1255
+ errors.append(
1256
+ AssertionError(
1257
+ f"Unexpected change in table '{table}': "
1258
+ f"row count changed from {before_count} to {after_count}"
1259
+ )
1260
+ )
1261
+
1262
+ if errors:
1263
+ raise errors[0]
1264
+
1265
+ return self
1266
+
894
1267
  def expect_only(self, allowed_changes: List[Dict[str, Any]]):
895
1268
  """Allowed changes is a list of {table, pk, field, after} (before optional)."""
1269
+ # Normalize pk values
1270
+ for change in allowed_changes:
1271
+ if "pk" in change and isinstance(change["pk"], list):
1272
+ change["pk"] = tuple(change["pk"])
1273
+
1274
+ # Special case: empty allowed_changes means no changes should have occurred
1275
+ if not allowed_changes:
1276
+ diff = self._collect()
1277
+ for tbl, report in diff.items():
1278
+ total = (
1279
+ len(report.get("added_rows", []))
1280
+ + len(report.get("removed_rows", []))
1281
+ + len(report.get("modified_rows", []))
1282
+ )
1283
+ if total > 0:
1284
+ raise AssertionError(
1285
+ f"Expected no changes but found {total} change(s) in table '{tbl}'"
1286
+ )
1287
+ return self
1288
+
1289
+ # Use targeted queries when possible (matches production behavior)
1290
+ if self._can_use_targeted_queries(allowed_changes):
1291
+ return self._expect_only_targeted(allowed_changes)
1292
+
1293
+ # Fall back to full diff for complex cases
896
1294
  diff = self._collect()
1295
+ return self._validate_diff_against_allowed_changes(diff, allowed_changes)
1296
+
1297
+ def _validate_diff_against_allowed_changes(
1298
+ self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
1299
+ ):
1300
+ """Validate a collected diff against allowed changes (full-diff fallback)."""
897
1301
 
898
1302
  def _is_change_allowed(
899
1303
  table: str, row_id: str, field: Optional[str], after_value: Any
@@ -1061,6 +1465,31 @@ class SnapshotDiff:
1061
1465
  For modifications, use "resulting_fields" with explicit "no_other_changes".
1062
1466
  For deletions with "fields", all specified fields are validated against the deleted row.
1063
1467
  """
1468
+ # Normalize pk values
1469
+ for change in allowed_changes:
1470
+ if "pk" in change and isinstance(change["pk"], list):
1471
+ change["pk"] = tuple(change["pk"])
1472
+
1473
+ # Special case: empty allowed_changes means no changes should have occurred
1474
+ if not allowed_changes:
1475
+ diff = self._collect()
1476
+ for tbl, report in diff.items():
1477
+ total = (
1478
+ len(report.get("added_rows", []))
1479
+ + len(report.get("removed_rows", []))
1480
+ + len(report.get("modified_rows", []))
1481
+ )
1482
+ if total > 0:
1483
+ raise AssertionError(
1484
+ f"Expected no changes but found {total} change(s) in table '{tbl}'"
1485
+ )
1486
+ return self
1487
+
1488
+ # Use targeted queries when possible (matches production behavior)
1489
+ if self._can_use_targeted_queries(allowed_changes):
1490
+ return self._expect_only_targeted_v2(allowed_changes)
1491
+
1492
+ # Fall back to full diff for complex cases
1064
1493
  diff = self._collect()
1065
1494
 
1066
1495
  def _is_change_allowed(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.118
3
+ Version: 0.2.119
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.118"
8
+ version = "0.2.119"
9
9
  description = "Python SDK for Fleet environments"
10
10
  authors = [
11
11
  {name = "Fleet AI", email = "nic@fleet.so"},
@@ -97,7 +97,7 @@ def test_field_level_specs_with_wrong_values():
97
97
  after = DatabaseSnapshot(after_db)
98
98
 
99
99
  # Should fail because status value is wrong
100
- with pytest.raises(AssertionError, match="Unexpected database changes"):
100
+ with pytest.raises(AssertionError):
101
101
  before.diff(after).expect_only_v2(
102
102
  [
103
103
  {
@@ -195,7 +195,7 @@ def test_modification_with_bulk_fields_spec_wrong_value():
195
195
  after = DatabaseSnapshot(after_db)
196
196
 
197
197
  # Should fail because status value is wrong
198
- with pytest.raises(AssertionError, match="Unexpected database changes"):
198
+ with pytest.raises(AssertionError):
199
199
  before.diff(after).expect_only_v2(
200
200
  [
201
201
  {
@@ -263,7 +263,7 @@ def test_modification_with_bulk_fields_spec_missing_field():
263
263
  )
264
264
 
265
265
  assert "status" in str(exc_info.value)
266
- assert "NOT_IN_RESULTING_FIELDS" in str(exc_info.value)
266
+ assert "NOT_IN_RESULTING_FIELDS" in str(exc_info.value) or "not in resulting_fields" in str(exc_info.value)
267
267
 
268
268
  finally:
269
269
  os.unlink(before_db)
@@ -431,7 +431,7 @@ def test_modification_no_other_changes_false_still_validates_specified():
431
431
  after = DatabaseSnapshot(after_db)
432
432
 
433
433
  # Should fail because name value is wrong, even with no_other_changes=False
434
- with pytest.raises(AssertionError, match="Unexpected database changes"):
434
+ with pytest.raises(AssertionError):
435
435
  before.diff(after).expect_only_v2(
436
436
  [
437
437
  {
@@ -662,7 +662,7 @@ def test_partial_field_specs_with_unexpected_changes():
662
662
  after = DatabaseSnapshot(after_db)
663
663
 
664
664
  # Only specify price change, but stock also changed - should fail
665
- with pytest.raises(AssertionError, match="Unexpected database changes"):
665
+ with pytest.raises(AssertionError):
666
666
  before.diff(after).expect_only(
667
667
  [
668
668
  {"table": "products", "pk": 1, "field": "price", "after": 12.99},
@@ -903,7 +903,7 @@ def test_missing_field_specs():
903
903
  after = DatabaseSnapshot(after_db)
904
904
 
905
905
  # Should fail because status field is missing from the fields spec
906
- with pytest.raises(AssertionError, match="Unexpected database changes"):
906
+ with pytest.raises(AssertionError):
907
907
  before.diff(after).expect_only_v2(
908
908
  [
909
909
  {
@@ -953,7 +953,7 @@ def test_modified_row_with_unauthorized_field_change():
953
953
  after = DatabaseSnapshot(after_db)
954
954
 
955
955
  # Should fail because status change is not allowed
956
- with pytest.raises(AssertionError, match="Unexpected database changes"):
956
+ with pytest.raises(AssertionError):
957
957
  before.diff(after).expect_only(
958
958
  [
959
959
  {
@@ -1272,7 +1272,7 @@ def test_fields_spec_missing_field_fails():
1272
1272
  )
1273
1273
 
1274
1274
  assert "status" in str(exc_info.value)
1275
- assert "NOT_IN_FIELDS_SPEC" in str(exc_info.value)
1275
+ assert "NOT_IN_FIELDS_SPEC" in str(exc_info.value) or "not in insert spec" in str(exc_info.value)
1276
1276
 
1277
1277
  finally:
1278
1278
  os.unlink(before_db)
@@ -1473,7 +1473,7 @@ def test_security_field_level_specs_catch_wrong_role():
1473
1473
  after = DatabaseSnapshot(after_db)
1474
1474
 
1475
1475
  # expect_only_v2 correctly FAILS because role is 'admin' not 'user'
1476
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1476
+ with pytest.raises(AssertionError):
1477
1477
  before.diff(after).expect_only_v2(
1478
1478
  [
1479
1479
  {
@@ -1533,7 +1533,7 @@ def test_financial_data_validation():
1533
1533
  )
1534
1534
 
1535
1535
  # expect_only_v2 with bulk field specs catches unexpected discount
1536
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1536
+ with pytest.raises(AssertionError):
1537
1537
  before.diff(after).expect_only_v2(
1538
1538
  [
1539
1539
  {
@@ -1593,7 +1593,7 @@ def test_permissions_validation():
1593
1593
  )
1594
1594
 
1595
1595
  # expect_only_v2 with bulk field specs catches unexpected delete permission
1596
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1596
+ with pytest.raises(AssertionError):
1597
1597
  before.diff(after).expect_only_v2(
1598
1598
  [
1599
1599
  {
@@ -1661,7 +1661,7 @@ def test_json_field_validation():
1661
1661
  )
1662
1662
 
1663
1663
  # expect_only_v2 with bulk field specs catches unexpected settings
1664
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1664
+ with pytest.raises(AssertionError):
1665
1665
  before.diff(after).expect_only_v2(
1666
1666
  [
1667
1667
  {
@@ -1726,7 +1726,7 @@ def test_expect_only_ignores_field_specs_with_whole_row():
1726
1726
  )
1727
1727
 
1728
1728
  # expect_only_v2 with wrong field values fails
1729
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1729
+ with pytest.raises(AssertionError):
1730
1730
  before.diff(after).expect_only_v2(
1731
1731
  [
1732
1732
  {
@@ -1786,7 +1786,7 @@ def test_expect_only_v2_validates_field_values():
1786
1786
  )
1787
1787
 
1788
1788
  # expect_only_v2 with wrong field values fails
1789
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1789
+ with pytest.raises(AssertionError):
1790
1790
  before.diff(after).expect_only_v2(
1791
1791
  [
1792
1792
  {
@@ -1846,7 +1846,7 @@ def test_expect_only_v2_validates_is_public():
1846
1846
  )
1847
1847
 
1848
1848
  # expect_only_v2 with wrong is_public value fails
1849
- with pytest.raises(AssertionError, match="Unexpected database changes"):
1849
+ with pytest.raises(AssertionError):
1850
1850
  before.diff(after).expect_only_v2(
1851
1851
  [
1852
1852
  {
File without changes
File without changes
File without changes