fleet-python 0.2.72__tar.gz → 0.2.72b2__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.72/fleet_python.egg-info → fleet_python-0.2.72b2}/PKG-INFO +1 -1
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/sqlite.py +483 -37
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/sqlite.py +510 -37
- {fleet_python-0.2.72 → fleet_python-0.2.72b2/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/pyproject.toml +1 -1
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/LICENSE +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/README.md +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/diff_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_account.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_sync.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_task.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/openai_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/quickstart.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/base.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/instance/client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/base.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/config.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/env/client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/global_client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/client.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/models.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/tasks.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/types.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/db.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/SOURCES.txt +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/scripts/unasync.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/setup.cfg +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/__init__.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_verifier_from_string.py +0 -0
|
@@ -24,7 +24,7 @@ from fleet.verifiers.db import (
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class AsyncDatabaseSnapshot:
|
|
27
|
-
"""
|
|
27
|
+
"""Lazy database snapshot that fetches data on-demand through API."""
|
|
28
28
|
|
|
29
29
|
def __init__(self, resource: "AsyncSQLiteResource", name: Optional[str] = None):
|
|
30
30
|
self.resource = resource
|
|
@@ -32,11 +32,12 @@ class AsyncDatabaseSnapshot:
|
|
|
32
32
|
self.created_at = datetime.utcnow()
|
|
33
33
|
self._data: Dict[str, List[Dict[str, Any]]] = {}
|
|
34
34
|
self._schemas: Dict[str, List[str]] = {}
|
|
35
|
-
self.
|
|
35
|
+
self._table_names: Optional[List[str]] = None
|
|
36
|
+
self._fetched_tables: set = set()
|
|
36
37
|
|
|
37
|
-
async def
|
|
38
|
-
"""Fetch
|
|
39
|
-
if self.
|
|
38
|
+
async def _ensure_tables_list(self):
|
|
39
|
+
"""Fetch just the list of table names if not already fetched."""
|
|
40
|
+
if self._table_names is not None:
|
|
40
41
|
return
|
|
41
42
|
|
|
42
43
|
# Get all tables
|
|
@@ -45,35 +46,36 @@ class AsyncDatabaseSnapshot:
|
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
if not tables_response.rows:
|
|
48
|
-
self.
|
|
49
|
+
self._table_names = []
|
|
49
50
|
return
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
for table
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
self._table_names = [row[0] for row in tables_response.rows]
|
|
53
|
+
|
|
54
|
+
async def _ensure_table_data(self, table: str):
|
|
55
|
+
"""Fetch data for a specific table on demand."""
|
|
56
|
+
if table in self._fetched_tables:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Get table schema
|
|
60
|
+
schema_response = await self.resource.query(f"PRAGMA table_info({table})")
|
|
61
|
+
if schema_response.rows:
|
|
62
|
+
self._schemas[table] = [row[1] for row in schema_response.rows] # Column names
|
|
63
|
+
|
|
64
|
+
# Get all data for this table
|
|
65
|
+
data_response = await self.resource.query(f"SELECT * FROM {table}")
|
|
66
|
+
if data_response.rows and data_response.columns:
|
|
67
|
+
self._data[table] = [
|
|
68
|
+
dict(zip(data_response.columns, row)) for row in data_response.rows
|
|
69
|
+
]
|
|
70
|
+
else:
|
|
71
|
+
self._data[table] = []
|
|
70
72
|
|
|
71
|
-
self.
|
|
73
|
+
self._fetched_tables.add(table)
|
|
72
74
|
|
|
73
75
|
async def tables(self) -> List[str]:
|
|
74
76
|
"""Get list of all tables in the snapshot."""
|
|
75
|
-
await self.
|
|
76
|
-
return list(self.
|
|
77
|
+
await self._ensure_tables_list()
|
|
78
|
+
return list(self._table_names) if self._table_names else []
|
|
77
79
|
|
|
78
80
|
def table(self, table_name: str) -> "AsyncSnapshotQueryBuilder":
|
|
79
81
|
"""Create a query builder for snapshot data."""
|
|
@@ -85,13 +87,12 @@ class AsyncDatabaseSnapshot:
|
|
|
85
87
|
ignore_config: Optional[IgnoreConfig] = None,
|
|
86
88
|
) -> "AsyncSnapshotDiff":
|
|
87
89
|
"""Compare this snapshot with another."""
|
|
88
|
-
|
|
89
|
-
await other._ensure_fetched()
|
|
90
|
+
# No need to fetch all data upfront - diff will fetch on demand
|
|
90
91
|
return AsyncSnapshotDiff(self, other, ignore_config)
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
class AsyncSnapshotQueryBuilder:
|
|
94
|
-
"""Query builder that works on
|
|
95
|
+
"""Query builder that works on snapshot data - can use targeted queries when possible."""
|
|
95
96
|
|
|
96
97
|
def __init__(self, snapshot: AsyncDatabaseSnapshot, table: str):
|
|
97
98
|
self._snapshot = snapshot
|
|
@@ -101,10 +102,63 @@ class AsyncSnapshotQueryBuilder:
|
|
|
101
102
|
self._limit: Optional[int] = None
|
|
102
103
|
self._order_by: Optional[str] = None
|
|
103
104
|
self._order_desc: bool = False
|
|
105
|
+
self._use_targeted_query = True # Try to use targeted queries when possible
|
|
106
|
+
|
|
107
|
+
def _can_use_targeted_query(self) -> bool:
|
|
108
|
+
"""Check if we can use a targeted query instead of loading all data."""
|
|
109
|
+
# We can use targeted query if:
|
|
110
|
+
# 1. We have simple equality conditions
|
|
111
|
+
# 2. No complex operations like joins
|
|
112
|
+
# 3. The query is selective (has conditions)
|
|
113
|
+
if not self._conditions:
|
|
114
|
+
return False
|
|
115
|
+
for col, op, val in self._conditions:
|
|
116
|
+
if op not in ["=", "IS", "IS NOT"]:
|
|
117
|
+
return False
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
async def _execute_targeted_query(self) -> List[Dict[str, Any]]:
|
|
121
|
+
"""Execute a targeted query directly instead of loading all data."""
|
|
122
|
+
# Build WHERE clause
|
|
123
|
+
where_parts = []
|
|
124
|
+
for col, op, val in self._conditions:
|
|
125
|
+
if op == "=" and val is None:
|
|
126
|
+
where_parts.append(f"{col} IS NULL")
|
|
127
|
+
elif op == "IS":
|
|
128
|
+
where_parts.append(f"{col} IS NULL")
|
|
129
|
+
elif op == "IS NOT":
|
|
130
|
+
where_parts.append(f"{col} IS NOT NULL")
|
|
131
|
+
elif op == "=":
|
|
132
|
+
if isinstance(val, str):
|
|
133
|
+
escaped_val = val.replace("'", "''")
|
|
134
|
+
where_parts.append(f"{col} = '{escaped_val}'")
|
|
135
|
+
else:
|
|
136
|
+
where_parts.append(f"{col} = '{val}'")
|
|
137
|
+
|
|
138
|
+
where_clause = " AND ".join(where_parts)
|
|
139
|
+
|
|
140
|
+
# Build full query
|
|
141
|
+
cols = ", ".join(self._select_cols)
|
|
142
|
+
query = f"SELECT {cols} FROM {self._table} WHERE {where_clause}"
|
|
143
|
+
|
|
144
|
+
if self._order_by:
|
|
145
|
+
query += f" ORDER BY {self._order_by}"
|
|
146
|
+
if self._limit is not None:
|
|
147
|
+
query += f" LIMIT {self._limit}"
|
|
148
|
+
|
|
149
|
+
# Execute query
|
|
150
|
+
response = await self._snapshot.resource.query(query)
|
|
151
|
+
if response.rows and response.columns:
|
|
152
|
+
return [dict(zip(response.columns, row)) for row in response.rows]
|
|
153
|
+
return []
|
|
104
154
|
|
|
105
155
|
async def _get_data(self) -> List[Dict[str, Any]]:
|
|
106
|
-
"""Get table data
|
|
107
|
-
|
|
156
|
+
"""Get table data - use targeted query if possible, otherwise load all data."""
|
|
157
|
+
if self._use_targeted_query and self._can_use_targeted_query():
|
|
158
|
+
return await self._execute_targeted_query()
|
|
159
|
+
|
|
160
|
+
# Fall back to loading all data
|
|
161
|
+
await self._snapshot._ensure_table_data(self._table)
|
|
108
162
|
return self._snapshot._data.get(self._table, [])
|
|
109
163
|
|
|
110
164
|
def eq(self, column: str, value: Any) -> "AsyncSnapshotQueryBuilder":
|
|
@@ -143,6 +197,11 @@ class AsyncSnapshotQueryBuilder:
|
|
|
143
197
|
return rows[0] if rows else None
|
|
144
198
|
|
|
145
199
|
async def all(self) -> List[Dict[str, Any]]:
|
|
200
|
+
# If we can use targeted query, _get_data already applies filters
|
|
201
|
+
if self._use_targeted_query and self._can_use_targeted_query():
|
|
202
|
+
return await self._get_data()
|
|
203
|
+
|
|
204
|
+
# Otherwise, get all data and apply filters manually
|
|
146
205
|
data = await self._get_data()
|
|
147
206
|
|
|
148
207
|
# Apply filters
|
|
@@ -207,6 +266,7 @@ class AsyncSnapshotDiff:
|
|
|
207
266
|
self.after = after
|
|
208
267
|
self.ignore_config = ignore_config or IgnoreConfig()
|
|
209
268
|
self._cached: Optional[Dict[str, Any]] = None
|
|
269
|
+
self._targeted_mode = False # Flag to use targeted queries
|
|
210
270
|
|
|
211
271
|
async def _get_primary_key_columns(self, table: str) -> List[str]:
|
|
212
272
|
"""Get primary key columns for a table."""
|
|
@@ -247,6 +307,10 @@ class AsyncSnapshotDiff:
|
|
|
247
307
|
# Get primary key columns
|
|
248
308
|
pk_columns = await self._get_primary_key_columns(tbl)
|
|
249
309
|
|
|
310
|
+
# Ensure data is fetched for this table
|
|
311
|
+
await self.before._ensure_table_data(tbl)
|
|
312
|
+
await self.after._ensure_table_data(tbl)
|
|
313
|
+
|
|
250
314
|
# Get data from both snapshots
|
|
251
315
|
before_data = self.before._data.get(tbl, [])
|
|
252
316
|
after_data = self.after._data.get(tbl, [])
|
|
@@ -329,9 +393,378 @@ class AsyncSnapshotDiff:
|
|
|
329
393
|
)
|
|
330
394
|
return self._cached
|
|
331
395
|
|
|
332
|
-
|
|
333
|
-
"""
|
|
334
|
-
|
|
396
|
+
def _can_use_targeted_queries(self, allowed_changes: List[Dict[str, Any]]) -> bool:
|
|
397
|
+
"""Check if we can use targeted queries for optimization."""
|
|
398
|
+
# We can use targeted queries if all allowed changes specify table and pk
|
|
399
|
+
for change in allowed_changes:
|
|
400
|
+
if "table" not in change or "pk" not in change:
|
|
401
|
+
return False
|
|
402
|
+
return True
|
|
403
|
+
|
|
404
|
+
def _build_pk_where_clause(self, pk_columns: List[str], pk_value: Any) -> str:
|
|
405
|
+
"""Build WHERE clause for primary key lookup."""
|
|
406
|
+
# Escape single quotes in values to prevent SQL injection
|
|
407
|
+
def escape_value(val: Any) -> str:
|
|
408
|
+
if val is None:
|
|
409
|
+
return "NULL"
|
|
410
|
+
elif isinstance(val, str):
|
|
411
|
+
escaped = str(val).replace("'", "''")
|
|
412
|
+
return f"'{escaped}'"
|
|
413
|
+
else:
|
|
414
|
+
return f"'{val}'"
|
|
415
|
+
|
|
416
|
+
if len(pk_columns) == 1:
|
|
417
|
+
return f"{pk_columns[0]} = {escape_value(pk_value)}"
|
|
418
|
+
else:
|
|
419
|
+
# Composite key
|
|
420
|
+
if isinstance(pk_value, tuple):
|
|
421
|
+
conditions = [
|
|
422
|
+
f"{col} = {escape_value(val)}"
|
|
423
|
+
for col, val in zip(pk_columns, pk_value)
|
|
424
|
+
]
|
|
425
|
+
return " AND ".join(conditions)
|
|
426
|
+
else:
|
|
427
|
+
# Shouldn't happen if data is consistent
|
|
428
|
+
return f"{pk_columns[0]} = {escape_value(pk_value)}"
|
|
429
|
+
|
|
430
|
+
async def _expect_no_changes(self):
|
|
431
|
+
"""Efficiently verify that no changes occurred between snapshots using row counts."""
|
|
432
|
+
try:
|
|
433
|
+
import asyncio
|
|
434
|
+
|
|
435
|
+
# Get all tables from both snapshots
|
|
436
|
+
before_tables = set(await self.before.tables())
|
|
437
|
+
after_tables = set(await self.after.tables())
|
|
438
|
+
|
|
439
|
+
# Check for added/removed tables (excluding ignored ones)
|
|
440
|
+
added_tables = after_tables - before_tables
|
|
441
|
+
removed_tables = before_tables - after_tables
|
|
442
|
+
|
|
443
|
+
for table in added_tables:
|
|
444
|
+
if not self.ignore_config.should_ignore_table(table):
|
|
445
|
+
raise AssertionError(f"Unexpected table added: {table}")
|
|
446
|
+
|
|
447
|
+
for table in removed_tables:
|
|
448
|
+
if not self.ignore_config.should_ignore_table(table):
|
|
449
|
+
raise AssertionError(f"Unexpected table removed: {table}")
|
|
450
|
+
|
|
451
|
+
# Prepare tables to check
|
|
452
|
+
tables_to_check = []
|
|
453
|
+
all_tables = before_tables | after_tables
|
|
454
|
+
for table in all_tables:
|
|
455
|
+
if not self.ignore_config.should_ignore_table(table):
|
|
456
|
+
tables_to_check.append(table)
|
|
457
|
+
|
|
458
|
+
# If no tables to check, we're done
|
|
459
|
+
if not tables_to_check:
|
|
460
|
+
return self
|
|
461
|
+
|
|
462
|
+
# Track errors and tables needing verification
|
|
463
|
+
errors = []
|
|
464
|
+
tables_needing_verification = []
|
|
465
|
+
|
|
466
|
+
async def check_table_counts(table: str):
|
|
467
|
+
"""Check row counts for a single table."""
|
|
468
|
+
try:
|
|
469
|
+
# Get row counts from both snapshots
|
|
470
|
+
before_count = 0
|
|
471
|
+
after_count = 0
|
|
472
|
+
|
|
473
|
+
if table in before_tables:
|
|
474
|
+
before_count_response = await self.before.resource.query(
|
|
475
|
+
f"SELECT COUNT(*) FROM {table}"
|
|
476
|
+
)
|
|
477
|
+
before_count = (
|
|
478
|
+
before_count_response.rows[0][0]
|
|
479
|
+
if before_count_response.rows
|
|
480
|
+
else 0
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if table in after_tables:
|
|
484
|
+
after_count_response = await self.after.resource.query(
|
|
485
|
+
f"SELECT COUNT(*) FROM {table}"
|
|
486
|
+
)
|
|
487
|
+
after_count = (
|
|
488
|
+
after_count_response.rows[0][0]
|
|
489
|
+
if after_count_response.rows
|
|
490
|
+
else 0
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if before_count != after_count:
|
|
494
|
+
error_msg = (
|
|
495
|
+
f"Unexpected change in table '{table}': "
|
|
496
|
+
f"row count changed from {before_count} to {after_count}"
|
|
497
|
+
)
|
|
498
|
+
errors.append(AssertionError(error_msg))
|
|
499
|
+
elif before_count > 0 and before_count <= 1000:
|
|
500
|
+
# Mark for detailed verification
|
|
501
|
+
tables_needing_verification.append(table)
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
errors.append(e)
|
|
505
|
+
|
|
506
|
+
# Execute count checks in parallel
|
|
507
|
+
await asyncio.gather(*[check_table_counts(table) for table in tables_to_check])
|
|
508
|
+
|
|
509
|
+
# Check if any errors occurred during count checking
|
|
510
|
+
if errors:
|
|
511
|
+
raise errors[0]
|
|
512
|
+
|
|
513
|
+
# Now verify small tables for data changes (also in parallel)
|
|
514
|
+
if tables_needing_verification:
|
|
515
|
+
verification_errors = []
|
|
516
|
+
|
|
517
|
+
async def verify_table(table: str):
|
|
518
|
+
"""Verify a single table's data hasn't changed."""
|
|
519
|
+
try:
|
|
520
|
+
await self._verify_table_unchanged(table)
|
|
521
|
+
except AssertionError as e:
|
|
522
|
+
verification_errors.append(e)
|
|
523
|
+
|
|
524
|
+
await asyncio.gather(*[verify_table(table) for table in tables_needing_verification])
|
|
525
|
+
|
|
526
|
+
# Check if any errors occurred during verification
|
|
527
|
+
if verification_errors:
|
|
528
|
+
raise verification_errors[0]
|
|
529
|
+
|
|
530
|
+
return self
|
|
531
|
+
|
|
532
|
+
except AssertionError:
|
|
533
|
+
# Re-raise assertion errors (these are expected failures)
|
|
534
|
+
raise
|
|
535
|
+
except Exception as e:
|
|
536
|
+
# If the optimized check fails for other reasons, fall back to full diff
|
|
537
|
+
print(f"Warning: Optimized no-changes check failed: {e}")
|
|
538
|
+
print("Falling back to full diff...")
|
|
539
|
+
return await self._validate_diff_against_allowed_changes(
|
|
540
|
+
await self._collect(), []
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
async def _verify_table_unchanged(self, table: str):
|
|
544
|
+
"""Verify that a table's data hasn't changed (for small tables)."""
|
|
545
|
+
# Get primary key columns
|
|
546
|
+
pk_columns = await self._get_primary_key_columns(table)
|
|
547
|
+
|
|
548
|
+
# Get sorted data from both snapshots
|
|
549
|
+
order_by = ", ".join(pk_columns) if pk_columns else "rowid"
|
|
550
|
+
|
|
551
|
+
before_response = await self.before.resource.query(
|
|
552
|
+
f"SELECT * FROM {table} ORDER BY {order_by}"
|
|
553
|
+
)
|
|
554
|
+
after_response = await self.after.resource.query(
|
|
555
|
+
f"SELECT * FROM {table} ORDER BY {order_by}"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Quick check: if column counts differ, there's a schema change
|
|
559
|
+
if before_response.columns != after_response.columns:
|
|
560
|
+
raise AssertionError(f"Schema changed in table '{table}'")
|
|
561
|
+
|
|
562
|
+
# Compare row by row
|
|
563
|
+
if len(before_response.rows) != len(after_response.rows):
|
|
564
|
+
raise AssertionError(
|
|
565
|
+
f"Row count mismatch in table '{table}': "
|
|
566
|
+
f"{len(before_response.rows)} vs {len(after_response.rows)}"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
for i, (before_row, after_row) in enumerate(
|
|
570
|
+
zip(before_response.rows, after_response.rows)
|
|
571
|
+
):
|
|
572
|
+
before_dict = dict(zip(before_response.columns, before_row))
|
|
573
|
+
after_dict = dict(zip(after_response.columns, after_row))
|
|
574
|
+
|
|
575
|
+
# Compare fields, ignoring those in ignore config
|
|
576
|
+
for field in before_response.columns:
|
|
577
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
if not _values_equivalent(
|
|
581
|
+
before_dict.get(field), after_dict.get(field)
|
|
582
|
+
):
|
|
583
|
+
pk_val = before_dict.get(pk_columns[0]) if pk_columns else i
|
|
584
|
+
raise AssertionError(
|
|
585
|
+
f"Unexpected change in table '{table}', row {pk_val}, "
|
|
586
|
+
f"field '{field}': {repr(before_dict.get(field))} -> {repr(after_dict.get(field))}"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
def _is_field_change_allowed(
|
|
590
|
+
self, table_changes: List[Dict[str, Any]], pk: Any, field: str, after_val: Any
|
|
591
|
+
) -> bool:
|
|
592
|
+
"""Check if a specific field change is allowed."""
|
|
593
|
+
for change in table_changes:
|
|
594
|
+
if (
|
|
595
|
+
str(change.get("pk")) == str(pk)
|
|
596
|
+
and change.get("field") == field
|
|
597
|
+
and _values_equivalent(change.get("after"), after_val)
|
|
598
|
+
):
|
|
599
|
+
return True
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
def _is_row_change_allowed(
|
|
603
|
+
self, table_changes: List[Dict[str, Any]], pk: Any, change_type: str
|
|
604
|
+
) -> bool:
|
|
605
|
+
"""Check if a row addition/deletion is allowed."""
|
|
606
|
+
for change in table_changes:
|
|
607
|
+
if str(change.get("pk")) == str(pk) and change.get("after") == change_type:
|
|
608
|
+
return True
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
async def _expect_only_targeted(self, allowed_changes: List[Dict[str, Any]]):
|
|
612
|
+
"""Optimized version that only queries specific rows mentioned in allowed_changes."""
|
|
613
|
+
import asyncio
|
|
614
|
+
|
|
615
|
+
# Group allowed changes by table
|
|
616
|
+
changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
|
|
617
|
+
for change in allowed_changes:
|
|
618
|
+
table = change["table"]
|
|
619
|
+
if table not in changes_by_table:
|
|
620
|
+
changes_by_table[table] = []
|
|
621
|
+
changes_by_table[table].append(change)
|
|
622
|
+
|
|
623
|
+
errors = []
|
|
624
|
+
|
|
625
|
+
# Function to check a single row
|
|
626
|
+
async def check_row(
|
|
627
|
+
table: str,
|
|
628
|
+
pk: Any,
|
|
629
|
+
table_changes: List[Dict[str, Any]],
|
|
630
|
+
pk_columns: List[str],
|
|
631
|
+
):
|
|
632
|
+
try:
|
|
633
|
+
# Build WHERE clause for this PK
|
|
634
|
+
where_sql = self._build_pk_where_clause(pk_columns, pk)
|
|
635
|
+
|
|
636
|
+
# Query before snapshot
|
|
637
|
+
before_query = f"SELECT * FROM {table} WHERE {where_sql}"
|
|
638
|
+
before_response = await self.before.resource.query(before_query)
|
|
639
|
+
before_row = (
|
|
640
|
+
dict(zip(before_response.columns, before_response.rows[0]))
|
|
641
|
+
if before_response.rows
|
|
642
|
+
else None
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Query after snapshot
|
|
646
|
+
after_response = await self.after.resource.query(before_query)
|
|
647
|
+
after_row = (
|
|
648
|
+
dict(zip(after_response.columns, after_response.rows[0]))
|
|
649
|
+
if after_response.rows
|
|
650
|
+
else None
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Check changes for this row
|
|
654
|
+
if before_row and after_row:
|
|
655
|
+
# Modified row - check fields
|
|
656
|
+
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
657
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
658
|
+
continue
|
|
659
|
+
before_val = before_row.get(field)
|
|
660
|
+
after_val = after_row.get(field)
|
|
661
|
+
if not _values_equivalent(before_val, after_val):
|
|
662
|
+
# Check if this change is allowed
|
|
663
|
+
if not self._is_field_change_allowed(
|
|
664
|
+
table_changes, pk, field, after_val
|
|
665
|
+
):
|
|
666
|
+
error_msg = (
|
|
667
|
+
f"Unexpected change in table '{table}', "
|
|
668
|
+
f"row {pk}, field '{field}': "
|
|
669
|
+
f"{repr(before_val)} -> {repr(after_val)}"
|
|
670
|
+
)
|
|
671
|
+
errors.append(AssertionError(error_msg))
|
|
672
|
+
return # Stop checking this row
|
|
673
|
+
elif not before_row and after_row:
|
|
674
|
+
# Added row
|
|
675
|
+
if not self._is_row_change_allowed(table_changes, pk, "__added__"):
|
|
676
|
+
error_msg = f"Unexpected row added in table '{table}': {pk}"
|
|
677
|
+
errors.append(AssertionError(error_msg))
|
|
678
|
+
elif before_row and not after_row:
|
|
679
|
+
# Removed row
|
|
680
|
+
if not self._is_row_change_allowed(table_changes, pk, "__removed__"):
|
|
681
|
+
error_msg = f"Unexpected row removed from table '{table}': {pk}"
|
|
682
|
+
errors.append(AssertionError(error_msg))
|
|
683
|
+
except Exception as e:
|
|
684
|
+
errors.append(e)
|
|
685
|
+
|
|
686
|
+
# Prepare all row checks
|
|
687
|
+
row_checks = []
|
|
688
|
+
for table, table_changes in changes_by_table.items():
|
|
689
|
+
if self.ignore_config.should_ignore_table(table):
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
# Get primary key columns once per table
|
|
693
|
+
pk_columns = await self._get_primary_key_columns(table)
|
|
694
|
+
|
|
695
|
+
# Extract unique PKs to check
|
|
696
|
+
pks_to_check = {change["pk"] for change in table_changes}
|
|
697
|
+
|
|
698
|
+
for pk in pks_to_check:
|
|
699
|
+
row_checks.append((table, pk, table_changes, pk_columns))
|
|
700
|
+
|
|
701
|
+
# Execute row checks in parallel
|
|
702
|
+
if row_checks:
|
|
703
|
+
await asyncio.gather(
|
|
704
|
+
*[
|
|
705
|
+
check_row(table, pk, table_changes, pk_columns)
|
|
706
|
+
for table, pk, table_changes, pk_columns in row_checks
|
|
707
|
+
]
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# Check for errors from row checks
|
|
711
|
+
if errors:
|
|
712
|
+
raise errors[0]
|
|
713
|
+
|
|
714
|
+
# Now check tables not mentioned in allowed_changes to ensure no changes
|
|
715
|
+
all_tables = set(await self.before.tables()) | set(await self.after.tables())
|
|
716
|
+
tables_to_verify = []
|
|
717
|
+
|
|
718
|
+
for table in all_tables:
|
|
719
|
+
if (
|
|
720
|
+
table not in changes_by_table
|
|
721
|
+
and not self.ignore_config.should_ignore_table(table)
|
|
722
|
+
):
|
|
723
|
+
tables_to_verify.append(table)
|
|
724
|
+
|
|
725
|
+
# Function to verify no changes in a table
|
|
726
|
+
async def verify_no_changes(table: str):
|
|
727
|
+
try:
|
|
728
|
+
# For tables with no allowed changes, just check row counts
|
|
729
|
+
before_count_response = await self.before.resource.query(
|
|
730
|
+
f"SELECT COUNT(*) FROM {table}"
|
|
731
|
+
)
|
|
732
|
+
before_count = (
|
|
733
|
+
before_count_response.rows[0][0]
|
|
734
|
+
if before_count_response.rows
|
|
735
|
+
else 0
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
after_count_response = await self.after.resource.query(
|
|
739
|
+
f"SELECT COUNT(*) FROM {table}"
|
|
740
|
+
)
|
|
741
|
+
after_count = (
|
|
742
|
+
after_count_response.rows[0][0] if after_count_response.rows else 0
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
if before_count != after_count:
|
|
746
|
+
error_msg = (
|
|
747
|
+
f"Unexpected change in table '{table}': "
|
|
748
|
+
f"row count changed from {before_count} to {after_count}"
|
|
749
|
+
)
|
|
750
|
+
errors.append(AssertionError(error_msg))
|
|
751
|
+
except Exception as e:
|
|
752
|
+
errors.append(e)
|
|
753
|
+
|
|
754
|
+
# Execute table verification in parallel
|
|
755
|
+
if tables_to_verify:
|
|
756
|
+
await asyncio.gather(*[verify_no_changes(table) for table in tables_to_verify])
|
|
757
|
+
|
|
758
|
+
# Final error check
|
|
759
|
+
if errors:
|
|
760
|
+
raise errors[0]
|
|
761
|
+
|
|
762
|
+
return self
|
|
763
|
+
|
|
764
|
+
async def _validate_diff_against_allowed_changes(
|
|
765
|
+
self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
|
|
766
|
+
):
|
|
767
|
+
"""Validate a collected diff against allowed changes."""
|
|
335
768
|
|
|
336
769
|
def _is_change_allowed(
|
|
337
770
|
table: str, row_id: Any, field: Optional[str], after_value: Any
|
|
@@ -458,6 +891,20 @@ class AsyncSnapshotDiff:
|
|
|
458
891
|
|
|
459
892
|
return self
|
|
460
893
|
|
|
894
|
+
async def expect_only(self, allowed_changes: List[Dict[str, Any]]):
|
|
895
|
+
"""Ensure only specified changes occurred."""
|
|
896
|
+
# Special case: empty allowed_changes means no changes should have occurred
|
|
897
|
+
if not allowed_changes:
|
|
898
|
+
return await self._expect_no_changes()
|
|
899
|
+
|
|
900
|
+
# For expect_only, we can optimize by only checking the specific rows mentioned
|
|
901
|
+
if self._can_use_targeted_queries(allowed_changes):
|
|
902
|
+
return await self._expect_only_targeted(allowed_changes)
|
|
903
|
+
|
|
904
|
+
# Fall back to full diff for complex cases
|
|
905
|
+
diff = await self._collect()
|
|
906
|
+
return await self._validate_diff_against_allowed_changes(diff, allowed_changes)
|
|
907
|
+
|
|
461
908
|
|
|
462
909
|
class AsyncQueryBuilder:
|
|
463
910
|
"""Async query builder that translates DSL to SQL and executes through the API."""
|
|
@@ -865,7 +1312,6 @@ class AsyncSQLiteResource(Resource):
|
|
|
865
1312
|
async def snapshot(self, name: Optional[str] = None) -> AsyncDatabaseSnapshot:
|
|
866
1313
|
"""Create a snapshot of the current database state."""
|
|
867
1314
|
snapshot = AsyncDatabaseSnapshot(self, name)
|
|
868
|
-
await snapshot._ensure_fetched()
|
|
869
1315
|
return snapshot
|
|
870
1316
|
|
|
871
1317
|
async def diff(
|