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.
- {fleet_python-0.2.117/fleet_python.egg-info → fleet_python-0.2.119}/PKG-INFO +1 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/__init__.py +3 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/__init__.py +1 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/base.py +1 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/client.py +29 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/base.py +1 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/client.py +26 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/__init__.py +2 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/db.py +429 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/local_executor.py +127 -3
- {fleet_python-0.2.117 → fleet_python-0.2.119/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/pyproject.toml +1 -1
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_expect_only.py +15 -15
- {fleet_python-0.2.117 → fleet_python-0.2.119}/LICENSE +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/README.md +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/diff_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_account.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_sync.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_task.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/export_tasks_filtered.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/iterate_verifiers.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/openai_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/quickstart.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/instance/client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/judge.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/api.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/filesystem.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/resources/sqlite.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/Dockerfile +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/agent.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp/main.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/requirements.txt +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/gemini_cua/start.sh +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/orchestrator.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/types.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/agent/utils.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/cli.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/config.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/env/client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/eval/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/eval/uploader.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/global_client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/client.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/judge.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/models.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/proxy/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/proxy/proxy.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/proxy/whitelist.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/api.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/filesystem.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/resources/sqlite.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/tasks.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/types.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/http_logging.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/logging.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/utils/playwright.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/SOURCES.txt +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/entry_points.txt +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/scripts/unasync.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/setup.cfg +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/__init__.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_expect_exactly.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_judge_criteria_markers.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.117 → fleet_python-0.2.119}/tests/test_verifier_from_string.py +0 -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.
|
|
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
|
|
@@ -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]:
|
|
@@ -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(
|