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.
Files changed (89) hide show
  1. {fleet_python-0.2.72/fleet_python.egg-info → fleet_python-0.2.72b2}/PKG-INFO +1 -1
  2. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/sqlite.py +483 -37
  3. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/sqlite.py +510 -37
  4. {fleet_python-0.2.72 → fleet_python-0.2.72b2/fleet_python.egg-info}/PKG-INFO +1 -1
  5. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/pyproject.toml +1 -1
  6. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/LICENSE +0 -0
  7. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/README.md +0 -0
  8. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/diff_example.py +0 -0
  9. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/dsl_example.py +0 -0
  10. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example.py +0 -0
  11. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/exampleResume.py +0 -0
  12. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_account.py +0 -0
  13. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_action_log.py +0 -0
  14. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_client.py +0 -0
  15. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_mcp_anthropic.py +0 -0
  16. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_mcp_openai.py +0 -0
  17. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_sync.py +0 -0
  18. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_task.py +0 -0
  19. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_tasks.py +0 -0
  20. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/example_verifier.py +0 -0
  21. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/export_tasks.py +0 -0
  22. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/gemini_example.py +0 -0
  23. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/import_tasks.py +0 -0
  24. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/json_tasks_example.py +0 -0
  25. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/nova_act_example.py +0 -0
  26. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/openai_example.py +0 -0
  27. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/openai_simple_example.py +0 -0
  28. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/query_builder_example.py +0 -0
  29. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/quickstart.py +0 -0
  30. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/examples/test_cdp_logging.py +0 -0
  31. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/__init__.py +0 -0
  32. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/__init__.py +0 -0
  33. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/base.py +0 -0
  34. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/client.py +0 -0
  35. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/env/__init__.py +0 -0
  36. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/env/client.py +0 -0
  37. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/exceptions.py +0 -0
  38. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/global_client.py +0 -0
  39. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/instance/__init__.py +0 -0
  40. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/instance/base.py +0 -0
  41. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/instance/client.py +0 -0
  42. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/models.py +0 -0
  43. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/__init__.py +0 -0
  44. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/base.py +0 -0
  45. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/browser.py +0 -0
  46. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/resources/mcp.py +0 -0
  47. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/tasks.py +0 -0
  48. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/verifiers/__init__.py +0 -0
  49. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/verifiers/bundler.py +0 -0
  50. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/_async/verifiers/verifier.py +0 -0
  51. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/base.py +0 -0
  52. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/client.py +0 -0
  53. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/config.py +0 -0
  54. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/env/__init__.py +0 -0
  55. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/env/client.py +0 -0
  56. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/exceptions.py +0 -0
  57. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/global_client.py +0 -0
  58. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/__init__.py +0 -0
  59. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/base.py +0 -0
  60. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/client.py +0 -0
  61. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/instance/models.py +0 -0
  62. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/models.py +0 -0
  63. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/__init__.py +0 -0
  64. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/base.py +0 -0
  65. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/browser.py +0 -0
  66. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/resources/mcp.py +0 -0
  67. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/tasks.py +0 -0
  68. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/types.py +0 -0
  69. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/__init__.py +0 -0
  70. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/bundler.py +0 -0
  71. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/code.py +0 -0
  72. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/db.py +0 -0
  73. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/decorator.py +0 -0
  74. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/parse.py +0 -0
  75. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/sql_differ.py +0 -0
  76. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet/verifiers/verifier.py +0 -0
  77. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/SOURCES.txt +0 -0
  78. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/dependency_links.txt +0 -0
  79. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/requires.txt +0 -0
  80. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/fleet_python.egg-info/top_level.txt +0 -0
  81. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/scripts/fix_sync_imports.py +0 -0
  82. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/scripts/unasync.py +0 -0
  83. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/setup.cfg +0 -0
  84. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/__init__.py +0 -0
  85. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_app_method.py +0 -0
  86. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_instance_dispatch.py +0 -0
  87. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_sqlite_resource_dual_mode.py +0 -0
  88. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  89. {fleet_python-0.2.72 → fleet_python-0.2.72b2}/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.72
3
+ Version: 0.2.72b2
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -24,7 +24,7 @@ from fleet.verifiers.db import (
24
24
 
25
25
 
26
26
  class AsyncDatabaseSnapshot:
27
- """Async database snapshot that fetches data through API and stores locally for diffing."""
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._fetched = False
35
+ self._table_names: Optional[List[str]] = None
36
+ self._fetched_tables: set = set()
36
37
 
37
- async def _ensure_fetched(self):
38
- """Fetch all data from remote database if not already fetched."""
39
- if self._fetched:
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._fetched = True
49
+ self._table_names = []
49
50
  return
50
51
 
51
- table_names = [row[0] for row in tables_response.rows]
52
-
53
- # Fetch data from each table
54
- for table in table_names:
55
- # Get table schema
56
- schema_response = await self.resource.query(f"PRAGMA table_info({table})")
57
- if schema_response.rows:
58
- self._schemas[table] = [
59
- row[1] for row in schema_response.rows
60
- ] # Column names
61
-
62
- # Get all data
63
- data_response = await self.resource.query(f"SELECT * FROM {table}")
64
- if data_response.rows and data_response.columns:
65
- self._data[table] = [
66
- dict(zip(data_response.columns, row)) for row in data_response.rows
67
- ]
68
- else:
69
- self._data[table] = []
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._fetched = True
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._ensure_fetched()
76
- return list(self._data.keys())
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
- await self._ensure_fetched()
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 local snapshot data."""
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 from snapshot."""
107
- await self._snapshot._ensure_fetched()
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
- async def expect_only(self, allowed_changes: List[Dict[str, Any]]):
333
- """Ensure only specified changes occurred."""
334
- diff = await self._collect()
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(