fleet-python 0.2.117__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.117/fleet_python.egg-info → fleet_python-0.2.119}/PKG-INFO +1 -1
  2. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/__init__.py +3 -1
  3. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/__init__.py +1 -1
  4. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/base.py +1 -1
  5. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/client.py +29 -0
  6. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/base.py +1 -1
  7. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/client.py +26 -0
  8. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/__init__.py +2 -1
  9. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/db.py +429 -0
  10. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/local_executor.py +127 -3
  11. {fleet_python-0.2.117 → fleet_python-0.2.119/fleet_python.egg-info}/PKG-INFO +1 -1
  12. {fleet_python-0.2.117 → fleet_python-0.2.119}/pyproject.toml +1 -1
  13. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_expect_only.py +15 -15
  14. {fleet_python-0.2.117 → fleet_python-0.2.119}/LICENSE +0 -0
  15. {fleet_python-0.2.117 → fleet_python-0.2.119}/README.md +0 -0
  16. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/diff_example.py +0 -0
  17. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/dsl_example.py +0 -0
  18. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example.py +0 -0
  19. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/exampleResume.py +0 -0
  20. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_account.py +0 -0
  21. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_action_log.py +0 -0
  22. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_client.py +0 -0
  23. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_mcp_anthropic.py +0 -0
  24. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_mcp_openai.py +0 -0
  25. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_sync.py +0 -0
  26. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_task.py +0 -0
  27. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_tasks.py +0 -0
  28. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_verifier.py +0 -0
  29. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/export_tasks.py +0 -0
  30. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/export_tasks_filtered.py +0 -0
  31. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/fetch_tasks.py +0 -0
  32. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/gemini_example.py +0 -0
  33. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/import_tasks.py +0 -0
  34. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/iterate_verifiers.py +0 -0
  35. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/json_tasks_example.py +0 -0
  36. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/nova_act_example.py +0 -0
  37. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/openai_example.py +0 -0
  38. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/openai_simple_example.py +0 -0
  39. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/query_builder_example.py +0 -0
  40. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/quickstart.py +0 -0
  41. {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/test_cdp_logging.py +0 -0
  42. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/env/__init__.py +0 -0
  43. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/env/client.py +0 -0
  44. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/exceptions.py +0 -0
  45. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/global_client.py +0 -0
  46. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/instance/__init__.py +0 -0
  47. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/instance/base.py +0 -0
  48. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/instance/client.py +0 -0
  49. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/judge.py +0 -0
  50. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/models.py +0 -0
  51. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/__init__.py +0 -0
  52. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/api.py +0 -0
  53. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/base.py +0 -0
  54. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/browser.py +0 -0
  55. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/filesystem.py +0 -0
  56. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/mcp.py +0 -0
  57. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/sqlite.py +0 -0
  58. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/tasks.py +0 -0
  59. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/verifiers/__init__.py +0 -0
  60. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/verifiers/bundler.py +0 -0
  61. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/verifiers/verifier.py +0 -0
  62. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/__init__.py +0 -0
  63. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/Dockerfile +0 -0
  64. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/__init__.py +0 -0
  65. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/agent.py +0 -0
  66. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp/main.py +0 -0
  67. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
  68. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
  69. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
  70. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/requirements.txt +0 -0
  71. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/start.sh +0 -0
  72. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/orchestrator.py +0 -0
  73. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/types.py +0 -0
  74. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/utils.py +0 -0
  75. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/cli.py +0 -0
  76. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/config.py +0 -0
  77. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/env/__init__.py +0 -0
  78. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/env/client.py +0 -0
  79. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/eval/__init__.py +0 -0
  80. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/eval/uploader.py +0 -0
  81. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/exceptions.py +0 -0
  82. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/global_client.py +0 -0
  83. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/__init__.py +0 -0
  84. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/base.py +0 -0
  85. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/client.py +0 -0
  86. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/models.py +0 -0
  87. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/judge.py +0 -0
  88. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/models.py +0 -0
  89. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/proxy/__init__.py +0 -0
  90. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/proxy/proxy.py +0 -0
  91. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/proxy/whitelist.py +0 -0
  92. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/__init__.py +0 -0
  93. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/api.py +0 -0
  94. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/base.py +0 -0
  95. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/browser.py +0 -0
  96. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/filesystem.py +0 -0
  97. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/mcp.py +0 -0
  98. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/sqlite.py +0 -0
  99. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/tasks.py +0 -0
  100. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/types.py +0 -0
  101. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/__init__.py +0 -0
  102. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/http_logging.py +0 -0
  103. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/logging.py +0 -0
  104. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/playwright.py +0 -0
  105. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/bundler.py +0 -0
  106. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/code.py +0 -0
  107. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/decorator.py +0 -0
  108. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/parse.py +0 -0
  109. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/sql_differ.py +0 -0
  110. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/verifier.py +0 -0
  111. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/SOURCES.txt +0 -0
  112. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/dependency_links.txt +0 -0
  113. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/entry_points.txt +0 -0
  114. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/requires.txt +0 -0
  115. {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/top_level.txt +0 -0
  116. {fleet_python-0.2.117 → fleet_python-0.2.119}/scripts/fix_sync_imports.py +0 -0
  117. {fleet_python-0.2.117 → fleet_python-0.2.119}/scripts/unasync.py +0 -0
  118. {fleet_python-0.2.117 → fleet_python-0.2.119}/setup.cfg +0 -0
  119. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/__init__.py +0 -0
  120. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_app_method.py +0 -0
  121. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_expect_exactly.py +0 -0
  122. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_instance_dispatch.py +0 -0
  123. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_judge_criteria_markers.py +0 -0
  124. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_sqlite_resource_dual_mode.py +0 -0
  125. {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  126. {fleet_python-0.2.117 → 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.117
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
@@ -40,6 +40,7 @@ from .verifiers import (
40
40
  TASK_SUCCESSFUL_SCORE,
41
41
  execute_verifier_local,
42
42
  LocalEnvironment,
43
+ diff_dbs,
43
44
  )
44
45
 
45
46
  # Import async verifiers (default verifier is async for modern usage)
@@ -78,7 +79,7 @@ from . import env
78
79
  from . import global_client as _global_client
79
80
  from ._async import global_client as _async_global_client
80
81
 
81
- __version__ = "0.2.117"
82
+ __version__ = "0.2.119"
82
83
 
83
84
  __all__ = [
84
85
  # Core classes
@@ -118,6 +119,7 @@ __all__ = [
118
119
  "TASK_SUCCESSFUL_SCORE",
119
120
  "execute_verifier_local",
120
121
  "LocalEnvironment",
122
+ "diff_dbs",
121
123
  # Environment module
122
124
  "env",
123
125
  # Global client helpers
@@ -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.117"
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.117"
29
+ __version__ = "0.2.119"
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -871,6 +871,35 @@ class AsyncFleet:
871
871
  execute_verifier_local, verifier_func, seed_db, current_db, final_answer
872
872
  )
873
873
 
874
+ @staticmethod
875
+ async def diff_dbs(
876
+ seed_db: str,
877
+ current_db: str,
878
+ ignore_tables: Optional[set] = None,
879
+ ignore_table_fields: Optional[Dict[str, set]] = None,
880
+ ) -> Dict[str, Any]:
881
+ """Compute a structured diff between two local SQLite databases.
882
+
883
+ Returns the same format as the runner's ``/diff/structured`` endpoint.
884
+ No authentication or network access required.
885
+
886
+ Args:
887
+ seed_db: Path to the seed (before) SQLite database file.
888
+ current_db: Path to the current (after) SQLite database file.
889
+ ignore_tables: Optional set of table names to skip entirely.
890
+ ignore_table_fields: Optional mapping of ``{table: {field, ...}}``
891
+ to strip from the output.
892
+
893
+ Returns:
894
+ Dict with keys ``success``, ``diff``, and ``message``.
895
+ """
896
+ import asyncio
897
+ from ..verifiers.local_executor import diff_dbs
898
+
899
+ return await asyncio.to_thread(
900
+ diff_dbs, seed_db, current_db, ignore_tables, ignore_table_fields
901
+ )
902
+
874
903
  async def list_runs(
875
904
  self, profile_id: Optional[str] = None, status: Optional[str] = "active"
876
905
  ) -> List[Run]:
@@ -27,7 +27,7 @@ from .exceptions import (
27
27
  try:
28
28
  from . import __version__
29
29
  except ImportError:
30
- __version__ = "0.2.117"
30
+ __version__ = "0.2.119"
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -880,6 +880,32 @@ class Fleet:
880
880
 
881
881
  return execute_verifier_local(verifier_func, seed_db, current_db, final_answer)
882
882
 
883
+ @staticmethod
884
+ def diff_dbs(
885
+ seed_db: str,
886
+ current_db: str,
887
+ ignore_tables: Optional[set] = None,
888
+ ignore_table_fields: Optional[Dict[str, set]] = None,
889
+ ) -> Dict[str, Any]:
890
+ """Compute a structured diff between two local SQLite databases.
891
+
892
+ Returns the same format as the runner's ``/diff/structured`` endpoint.
893
+ No authentication or network access required.
894
+
895
+ Args:
896
+ seed_db: Path to the seed (before) SQLite database file.
897
+ current_db: Path to the current (after) SQLite database file.
898
+ ignore_tables: Optional set of table names to skip entirely.
899
+ ignore_table_fields: Optional mapping of ``{table: {field, ...}}``
900
+ to strip from the output.
901
+
902
+ Returns:
903
+ Dict with keys ``success``, ``diff``, and ``message``.
904
+ """
905
+ from .verifiers.local_executor import diff_dbs
906
+
907
+ return diff_dbs(seed_db, current_db, ignore_tables, ignore_table_fields)
908
+
883
909
  def list_runs(
884
910
  self, profile_id: Optional[str] = None, status: Optional[str] = "active"
885
911
  ) -> List[Run]:
@@ -6,7 +6,7 @@ from .verifier import (
6
6
  verifier,
7
7
  SyncVerifierFunction,
8
8
  )
9
- from .local_executor import execute_verifier_local, LocalEnvironment
9
+ from .local_executor import execute_verifier_local, LocalEnvironment, diff_dbs
10
10
 
11
11
  __all__ = [
12
12
  "DatabaseSnapshot",
@@ -18,4 +18,5 @@ __all__ = [
18
18
  "SyncVerifierFunction",
19
19
  "execute_verifier_local",
20
20
  "LocalEnvironment",
21
+ "diff_dbs",
21
22
  ]
@@ -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(