fleet-python 0.2.18__tar.gz → 0.2.20__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.

Potentially problematic release.


This version of fleet-python might be problematic. Click here for more details.

Files changed (72) hide show
  1. {fleet_python-0.2.18 → fleet_python-0.2.20}/PKG-INFO +1 -1
  2. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/resources/mcp.py +1 -1
  3. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/resources/sqlite.py +455 -38
  4. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet_python.egg-info/PKG-INFO +1 -1
  5. {fleet_python-0.2.18 → fleet_python-0.2.20}/pyproject.toml +1 -1
  6. {fleet_python-0.2.18 → fleet_python-0.2.20}/LICENSE +0 -0
  7. {fleet_python-0.2.18 → fleet_python-0.2.20}/README.md +0 -0
  8. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/diff_example.py +0 -0
  9. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/dsl_example.py +0 -0
  10. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example.py +0 -0
  11. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_action_log.py +0 -0
  12. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_client.py +0 -0
  13. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_mcp_anthropic.py +0 -0
  14. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_mcp_openai.py +0 -0
  15. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_sync.py +0 -0
  16. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_task.py +0 -0
  17. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/example_verifier.py +0 -0
  18. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/gemini_example.py +0 -0
  19. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/json_tasks_example.py +0 -0
  20. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/nova_act_example.py +0 -0
  21. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/openai_example.py +0 -0
  22. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/openai_simple_example.py +0 -0
  23. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/query_builder_example.py +0 -0
  24. {fleet_python-0.2.18 → fleet_python-0.2.20}/examples/quickstart.py +0 -0
  25. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/__init__.py +0 -0
  26. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/__init__.py +0 -0
  27. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/base.py +0 -0
  28. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/client.py +0 -0
  29. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/env/__init__.py +0 -0
  30. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/env/client.py +0 -0
  31. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/exceptions.py +0 -0
  32. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/instance/__init__.py +0 -0
  33. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/instance/base.py +0 -0
  34. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/instance/client.py +0 -0
  35. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/resources/__init__.py +0 -0
  36. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/resources/base.py +0 -0
  37. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/resources/browser.py +0 -0
  38. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/resources/sqlite.py +0 -0
  39. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/tasks.py +0 -0
  40. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/verifiers/__init__.py +0 -0
  41. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/verifiers/bundler.py +0 -0
  42. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/_async/verifiers/verifier.py +0 -0
  43. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/base.py +0 -0
  44. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/client.py +0 -0
  45. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/config.py +0 -0
  46. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/env/__init__.py +0 -0
  47. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/env/client.py +0 -0
  48. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/exceptions.py +0 -0
  49. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/instance/__init__.py +0 -0
  50. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/instance/base.py +0 -0
  51. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/instance/client.py +0 -0
  52. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/instance/models.py +0 -0
  53. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/models.py +0 -0
  54. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/resources/__init__.py +0 -0
  55. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/resources/base.py +0 -0
  56. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/resources/browser.py +0 -0
  57. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/tasks.py +0 -0
  58. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/types.py +0 -0
  59. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/__init__.py +0 -0
  60. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/bundler.py +0 -0
  61. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/code.py +0 -0
  62. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/db.py +0 -0
  63. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/decorator.py +0 -0
  64. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/sql_differ.py +0 -0
  65. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet/verifiers/verifier.py +0 -0
  66. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet_python.egg-info/SOURCES.txt +0 -0
  67. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet_python.egg-info/dependency_links.txt +0 -0
  68. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet_python.egg-info/requires.txt +0 -0
  69. {fleet_python-0.2.18 → fleet_python-0.2.20}/fleet_python.egg-info/top_level.txt +0 -0
  70. {fleet_python-0.2.18 → fleet_python-0.2.20}/scripts/fix_sync_imports.py +0 -0
  71. {fleet_python-0.2.18 → fleet_python-0.2.20}/scripts/unasync.py +0 -0
  72. {fleet_python-0.2.18 → fleet_python-0.2.20}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.18
3
+ Version: 0.2.20
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -1,5 +1,4 @@
1
1
  from typing import Dict
2
- import aiohttp
3
2
  import json
4
3
 
5
4
 
@@ -24,6 +23,7 @@ class MCPResource:
24
23
  }
25
24
 
26
25
  async def list_tools(self):
26
+ import aiohttp
27
27
  """
28
28
  Make an async request to list available tools from the MCP endpoint.
29
29
 
@@ -18,7 +18,7 @@ from ..verifiers.db import IgnoreConfig, _get_row_identifier, _format_row_for_er
18
18
 
19
19
 
20
20
  class SyncDatabaseSnapshot:
21
- """Async database snapshot that fetches data through API and stores locally for diffing."""
21
+ """Lazy database snapshot that fetches data on-demand through API."""
22
22
 
23
23
  def __init__(self, resource: "SQLiteResource", name: str | None = None):
24
24
  self.resource = resource
@@ -26,11 +26,12 @@ class SyncDatabaseSnapshot:
26
26
  self.created_at = datetime.utcnow()
27
27
  self._data: dict[str, list[dict[str, Any]]] = {}
28
28
  self._schemas: dict[str, list[str]] = {}
29
- self._fetched = False
29
+ self._table_names: list[str] | None = None
30
+ self._fetched_tables: set[str] = set()
30
31
 
31
- def _ensure_fetched(self):
32
- """Fetch all data from remote database if not already fetched."""
33
- if self._fetched:
32
+ def _ensure_tables_list(self):
33
+ """Fetch just the list of table names if not already fetched."""
34
+ if self._table_names is not None:
34
35
  return
35
36
 
36
37
  # Get all tables
@@ -39,34 +40,37 @@ class SyncDatabaseSnapshot:
39
40
  )
40
41
 
41
42
  if not tables_response.rows:
42
- self._fetched = True
43
+ self._table_names = []
43
44
  return
44
45
 
45
- table_names = [row[0] for row in tables_response.rows]
46
-
47
- # Fetch data from each table
48
- for table in table_names:
49
- # Get table schema
50
- schema_response = self.resource.query(f"PRAGMA table_info({table})")
51
- if schema_response.rows:
52
- self._schemas[table] = [row[1] for row in schema_response.rows] # Column names
53
-
54
- # Get all data
55
- data_response = self.resource.query(f"SELECT * FROM {table}")
56
- if data_response.rows and data_response.columns:
57
- self._data[table] = [
58
- dict(zip(data_response.columns, row))
59
- for row in data_response.rows
60
- ]
61
- else:
62
- self._data[table] = []
63
-
64
- self._fetched = True
46
+ self._table_names = [row[0] for row in tables_response.rows]
47
+
48
+ def _ensure_table_data(self, table: str):
49
+ """Fetch data for a specific table on demand."""
50
+ if table in self._fetched_tables:
51
+ return
52
+
53
+ # Get table schema
54
+ schema_response = self.resource.query(f"PRAGMA table_info({table})")
55
+ if schema_response.rows:
56
+ self._schemas[table] = [row[1] for row in schema_response.rows] # Column names
57
+
58
+ # Get all data for this table
59
+ data_response = self.resource.query(f"SELECT * FROM {table}")
60
+ if data_response.rows and data_response.columns:
61
+ self._data[table] = [
62
+ dict(zip(data_response.columns, row))
63
+ for row in data_response.rows
64
+ ]
65
+ else:
66
+ self._data[table] = []
67
+
68
+ self._fetched_tables.add(table)
65
69
 
66
70
  def tables(self) -> list[str]:
67
71
  """Get list of all tables in the snapshot."""
68
- self._ensure_fetched()
69
- return list(self._data.keys())
72
+ self._ensure_tables_list()
73
+ return list(self._table_names) if self._table_names else []
70
74
 
71
75
  def table(self, table_name: str) -> "SyncSnapshotQueryBuilder":
72
76
  """Create a query builder for snapshot data."""
@@ -78,13 +82,12 @@ class SyncDatabaseSnapshot:
78
82
  ignore_config: IgnoreConfig | None = None,
79
83
  ) -> "SyncSnapshotDiff":
80
84
  """Compare this snapshot with another."""
81
- self._ensure_fetched()
82
- other._ensure_fetched()
85
+ # No need to fetch all data upfront - diff will fetch on demand
83
86
  return SyncSnapshotDiff(self, other, ignore_config)
84
87
 
85
88
 
86
89
  class SyncSnapshotQueryBuilder:
87
- """Query builder that works on local snapshot data."""
90
+ """Query builder that works on snapshot data - can use targeted queries when possible."""
88
91
 
89
92
  def __init__(self, snapshot: SyncDatabaseSnapshot, table: str):
90
93
  self._snapshot = snapshot
@@ -94,10 +97,63 @@ class SyncSnapshotQueryBuilder:
94
97
  self._limit: int | None = None
95
98
  self._order_by: str | None = None
96
99
  self._order_desc: bool = False
100
+ self._use_targeted_query = True # Try to use targeted queries when possible
101
+
102
+ def _can_use_targeted_query(self) -> bool:
103
+ """Check if we can use a targeted query instead of loading all data."""
104
+ # We can use targeted query if:
105
+ # 1. We have simple equality conditions
106
+ # 2. No complex operations like joins
107
+ # 3. The query is selective (has conditions)
108
+ if not self._conditions:
109
+ return False
110
+ for col, op, val in self._conditions:
111
+ if op not in ["=", "IS", "IS NOT"]:
112
+ return False
113
+ return True
114
+
115
+ def _execute_targeted_query(self) -> list[dict[str, Any]]:
116
+ """Execute a targeted query directly instead of loading all data."""
117
+ # Build WHERE clause
118
+ where_parts = []
119
+ for col, op, val in self._conditions:
120
+ if op == "=" and val is None:
121
+ where_parts.append(f"{col} IS NULL")
122
+ elif op == "IS":
123
+ where_parts.append(f"{col} IS NULL")
124
+ elif op == "IS NOT":
125
+ where_parts.append(f"{col} IS NOT NULL")
126
+ elif op == "=":
127
+ if isinstance(val, str):
128
+ escaped_val = val.replace("'", "''")
129
+ where_parts.append(f"{col} = '{escaped_val}'")
130
+ else:
131
+ where_parts.append(f"{col} = '{val}'")
132
+
133
+ where_clause = " AND ".join(where_parts)
134
+
135
+ # Build full query
136
+ cols = ", ".join(self._select_cols)
137
+ query = f"SELECT {cols} FROM {self._table} WHERE {where_clause}"
138
+
139
+ if self._order_by:
140
+ query += f" ORDER BY {self._order_by}"
141
+ if self._limit is not None:
142
+ query += f" LIMIT {self._limit}"
143
+
144
+ # Execute query
145
+ response = self._snapshot.resource.query(query)
146
+ if response.rows and response.columns:
147
+ return [dict(zip(response.columns, row)) for row in response.rows]
148
+ return []
97
149
 
98
150
  def _get_data(self) -> list[dict[str, Any]]:
99
- """Get table data from snapshot."""
100
- self._snapshot._ensure_fetched()
151
+ """Get table data - use targeted query if possible, otherwise load all data."""
152
+ if self._use_targeted_query and self._can_use_targeted_query():
153
+ return self._execute_targeted_query()
154
+
155
+ # Fall back to loading all data
156
+ self._snapshot._ensure_table_data(self._table)
101
157
  return self._snapshot._data.get(self._table, [])
102
158
 
103
159
  def eq(self, column: str, value: Any) -> "SyncSnapshotQueryBuilder":
@@ -121,6 +177,11 @@ class SyncSnapshotQueryBuilder:
121
177
  return rows[0] if rows else None
122
178
 
123
179
  def all(self) -> list[dict[str, Any]]:
180
+ # If we can use targeted query, _get_data already applies filters
181
+ if self._use_targeted_query and self._can_use_targeted_query():
182
+ return self._get_data()
183
+
184
+ # Otherwise, get all data and apply filters manually
124
185
  data = self._get_data()
125
186
 
126
187
  # Apply filters
@@ -188,6 +249,7 @@ class SyncSnapshotDiff:
188
249
  self.after = after
189
250
  self.ignore_config = ignore_config or IgnoreConfig()
190
251
  self._cached: dict[str, Any] | None = None
252
+ self._targeted_mode = False # Flag to use targeted queries
191
253
 
192
254
  def _get_primary_key_columns(self, table: str) -> list[str]:
193
255
  """Get primary key columns for a table."""
@@ -228,6 +290,10 @@ class SyncSnapshotDiff:
228
290
  # Get primary key columns
229
291
  pk_columns = self._get_primary_key_columns(tbl)
230
292
 
293
+ # Ensure data is fetched for this table
294
+ self.before._ensure_table_data(tbl)
295
+ self.after._ensure_table_data(tbl)
296
+
231
297
  # Get data from both snapshots
232
298
  before_data = self.before._data.get(tbl, [])
233
299
  after_data = self.after._data.get(tbl, [])
@@ -303,10 +369,348 @@ class SyncSnapshotDiff:
303
369
  self._cached = diff
304
370
  return diff
305
371
 
306
- def expect_only(self, allowed_changes: list[dict[str, Any]]):
307
- """Ensure only specified changes occurred."""
372
+ def _can_use_targeted_queries(self, allowed_changes: list[dict[str, Any]]) -> bool:
373
+ """Check if we can use targeted queries for optimization."""
374
+ # We can use targeted queries if all allowed changes specify table and pk
375
+ for change in allowed_changes:
376
+ if "table" not in change or "pk" not in change:
377
+ return False
378
+ return True
379
+
380
+ def _expect_only_targeted(self, allowed_changes: list[dict[str, Any]]):
381
+ """Optimized version that only queries specific rows mentioned in allowed_changes."""
382
+ import concurrent.futures
383
+ from threading import Lock
384
+
385
+ # Group allowed changes by table
386
+ changes_by_table: dict[str, list[dict[str, Any]]] = {}
387
+ for change in allowed_changes:
388
+ table = change["table"]
389
+ if table not in changes_by_table:
390
+ changes_by_table[table] = []
391
+ changes_by_table[table].append(change)
392
+
393
+ errors = []
394
+ errors_lock = Lock()
395
+
396
+ # Function to check a single row
397
+ def check_row(table: str, pk: Any, table_changes: list[dict[str, Any]], pk_columns: list[str]):
398
+ try:
399
+ # Build WHERE clause for this PK
400
+ where_sql = self._build_pk_where_clause(pk_columns, pk)
401
+
402
+ # Query before snapshot
403
+ before_query = f"SELECT * FROM {table} WHERE {where_sql}"
404
+ before_response = self.before.resource.query(before_query)
405
+ before_row = dict(zip(before_response.columns, before_response.rows[0])) if before_response.rows else None
406
+
407
+ # Query after snapshot
408
+ after_response = self.after.resource.query(before_query)
409
+ after_row = dict(zip(after_response.columns, after_response.rows[0])) if after_response.rows else None
410
+
411
+ # Check changes for this row
412
+ if before_row and after_row:
413
+ # Modified row - check fields
414
+ for field in set(before_row.keys()) | set(after_row.keys()):
415
+ if self.ignore_config.should_ignore_field(table, field):
416
+ continue
417
+ before_val = before_row.get(field)
418
+ after_val = after_row.get(field)
419
+ if not _values_equivalent(before_val, after_val):
420
+ # Check if this change is allowed
421
+ if not self._is_field_change_allowed(table_changes, pk, field, after_val):
422
+ error_msg = (
423
+ f"Unexpected change in table '{table}', "
424
+ f"row {pk}, field '{field}': "
425
+ f"{repr(before_val)} -> {repr(after_val)}"
426
+ )
427
+ with errors_lock:
428
+ errors.append(AssertionError(error_msg))
429
+ return # Stop checking this row
430
+ elif not before_row and after_row:
431
+ # Added row
432
+ if not self._is_row_change_allowed(table_changes, pk, "__added__"):
433
+ error_msg = f"Unexpected row added in table '{table}': {pk}"
434
+ with errors_lock:
435
+ errors.append(AssertionError(error_msg))
436
+ elif before_row and not after_row:
437
+ # Removed row
438
+ if not self._is_row_change_allowed(table_changes, pk, "__removed__"):
439
+ error_msg = f"Unexpected row removed from table '{table}': {pk}"
440
+ with errors_lock:
441
+ errors.append(AssertionError(error_msg))
442
+ except Exception as e:
443
+ with errors_lock:
444
+ errors.append(e)
445
+
446
+ # Prepare all row checks
447
+ row_checks = []
448
+ for table, table_changes in changes_by_table.items():
449
+ if self.ignore_config.should_ignore_table(table):
450
+ continue
451
+
452
+ # Get primary key columns once per table
453
+ pk_columns = self._get_primary_key_columns(table)
454
+
455
+ # Extract unique PKs to check
456
+ pks_to_check = {change["pk"] for change in table_changes}
457
+
458
+ for pk in pks_to_check:
459
+ row_checks.append((table, pk, table_changes, pk_columns))
460
+
461
+ # Execute row checks in parallel
462
+ if row_checks:
463
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
464
+ futures = [
465
+ executor.submit(check_row, table, pk, table_changes, pk_columns)
466
+ for table, pk, table_changes, pk_columns in row_checks
467
+ ]
468
+ concurrent.futures.wait(futures)
469
+
470
+ # Check for errors from row checks
471
+ if errors:
472
+ raise errors[0]
473
+
474
+ # Now check tables not mentioned in allowed_changes to ensure no changes
475
+ all_tables = set(self.before.tables()) | set(self.after.tables())
476
+ tables_to_verify = []
477
+
478
+ for table in all_tables:
479
+ if table not in changes_by_table and not self.ignore_config.should_ignore_table(table):
480
+ tables_to_verify.append(table)
481
+
482
+ # Function to verify no changes in a table
483
+ def verify_no_changes(table: str):
484
+ try:
485
+ # For tables with no allowed changes, just check row counts
486
+ before_count_response = self.before.resource.query(f"SELECT COUNT(*) FROM {table}")
487
+ before_count = before_count_response.rows[0][0] if before_count_response.rows else 0
488
+
489
+ after_count_response = self.after.resource.query(f"SELECT COUNT(*) FROM {table}")
490
+ after_count = after_count_response.rows[0][0] if after_count_response.rows else 0
491
+
492
+ if before_count != after_count:
493
+ error_msg = (
494
+ f"Unexpected change in table '{table}': "
495
+ f"row count changed from {before_count} to {after_count}"
496
+ )
497
+ with errors_lock:
498
+ errors.append(AssertionError(error_msg))
499
+ except Exception as e:
500
+ with errors_lock:
501
+ errors.append(e)
502
+
503
+ # Execute table verification in parallel
504
+ if tables_to_verify:
505
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
506
+ futures = [
507
+ executor.submit(verify_no_changes, table)
508
+ for table in tables_to_verify
509
+ ]
510
+ concurrent.futures.wait(futures)
511
+
512
+ # Final error check
513
+ if errors:
514
+ raise errors[0]
515
+
516
+ return self
517
+
518
+ def _build_pk_where_clause(self, pk_columns: list[str], pk_value: Any) -> str:
519
+ """Build WHERE clause for primary key lookup."""
520
+ # Escape single quotes in values to prevent SQL injection
521
+ def escape_value(val: Any) -> str:
522
+ if val is None:
523
+ return "NULL"
524
+ elif isinstance(val, str):
525
+ escaped = str(val).replace("'", "''")
526
+ return f"'{escaped}'"
527
+ else:
528
+ return f"'{val}'"
529
+
530
+ if len(pk_columns) == 1:
531
+ return f"{pk_columns[0]} = {escape_value(pk_value)}"
532
+ else:
533
+ # Composite key
534
+ if isinstance(pk_value, tuple):
535
+ conditions = [f"{col} = {escape_value(val)}" for col, val in zip(pk_columns, pk_value)]
536
+ return " AND ".join(conditions)
537
+ else:
538
+ # Shouldn't happen if data is consistent
539
+ return f"{pk_columns[0]} = {escape_value(pk_value)}"
540
+
541
+ def _is_field_change_allowed(self, table_changes: list[dict[str, Any]], pk: Any, field: str, after_val: Any) -> bool:
542
+ """Check if a specific field change is allowed."""
543
+ for change in table_changes:
544
+ if (str(change.get("pk")) == str(pk) and
545
+ change.get("field") == field and
546
+ _values_equivalent(change.get("after"), after_val)):
547
+ return True
548
+ return False
549
+
550
+ def _is_row_change_allowed(self, table_changes: list[dict[str, Any]], pk: Any, change_type: str) -> bool:
551
+ """Check if a row addition/deletion is allowed."""
552
+ for change in table_changes:
553
+ if str(change.get("pk")) == str(pk) and change.get("after") == change_type:
554
+ return True
555
+ return False
556
+
557
+ def _expect_no_changes(self):
558
+ """Efficiently verify that no changes occurred between snapshots using row counts."""
559
+ try:
560
+ import concurrent.futures
561
+ from threading import Lock
562
+
563
+ # Get all tables from both snapshots
564
+ before_tables = set(self.before.tables())
565
+ after_tables = set(self.after.tables())
566
+
567
+ # Check for added/removed tables (excluding ignored ones)
568
+ added_tables = after_tables - before_tables
569
+ removed_tables = before_tables - after_tables
570
+
571
+ for table in added_tables:
572
+ if not self.ignore_config.should_ignore_table(table):
573
+ raise AssertionError(f"Unexpected table added: {table}")
574
+
575
+ for table in removed_tables:
576
+ if not self.ignore_config.should_ignore_table(table):
577
+ raise AssertionError(f"Unexpected table removed: {table}")
578
+
579
+ # Prepare tables to check
580
+ tables_to_check = []
581
+ all_tables = before_tables | after_tables
582
+ for table in all_tables:
583
+ if not self.ignore_config.should_ignore_table(table):
584
+ tables_to_check.append(table)
585
+
586
+ # If no tables to check, we're done
587
+ if not tables_to_check:
588
+ return self
589
+
590
+ # Use ThreadPoolExecutor to parallelize count queries
591
+ # We use threads instead of processes since the queries are I/O bound
592
+ errors = []
593
+ errors_lock = Lock()
594
+ tables_needing_verification = []
595
+ verification_lock = Lock()
596
+
597
+ def check_table_counts(table: str):
598
+ """Check row counts for a single table."""
599
+ try:
600
+ # Get row counts from both snapshots
601
+ before_count = 0
602
+ after_count = 0
603
+
604
+ if table in before_tables:
605
+ before_count_response = self.before.resource.query(f"SELECT COUNT(*) FROM {table}")
606
+ before_count = before_count_response.rows[0][0] if before_count_response.rows else 0
607
+
608
+ if table in after_tables:
609
+ after_count_response = self.after.resource.query(f"SELECT COUNT(*) FROM {table}")
610
+ after_count = after_count_response.rows[0][0] if after_count_response.rows else 0
611
+
612
+ if before_count != after_count:
613
+ error_msg = (
614
+ f"Unexpected change in table '{table}': "
615
+ f"row count changed from {before_count} to {after_count}"
616
+ )
617
+ with errors_lock:
618
+ errors.append(AssertionError(error_msg))
619
+ elif before_count > 0 and before_count <= 1000:
620
+ # Mark for detailed verification
621
+ with verification_lock:
622
+ tables_needing_verification.append(table)
623
+
624
+ except Exception as e:
625
+ with errors_lock:
626
+ errors.append(e)
627
+
628
+ # Execute count checks in parallel
629
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
630
+ futures = [executor.submit(check_table_counts, table) for table in tables_to_check]
631
+ concurrent.futures.wait(futures)
632
+
633
+ # Check if any errors occurred during count checking
634
+ if errors:
635
+ # Raise the first error
636
+ raise errors[0]
637
+
638
+ # Now verify small tables for data changes (also in parallel)
639
+ if tables_needing_verification:
640
+ verification_errors = []
641
+
642
+ def verify_table(table: str):
643
+ """Verify a single table's data hasn't changed."""
644
+ try:
645
+ self._verify_table_unchanged(table)
646
+ except AssertionError as e:
647
+ with errors_lock:
648
+ verification_errors.append(e)
649
+
650
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
651
+ futures = [executor.submit(verify_table, table) for table in tables_needing_verification]
652
+ concurrent.futures.wait(futures)
653
+
654
+ # Check if any errors occurred during verification
655
+ if verification_errors:
656
+ raise verification_errors[0]
657
+
658
+ return self
659
+
660
+ except AssertionError:
661
+ # Re-raise assertion errors (these are expected failures)
662
+ raise
663
+ except Exception as e:
664
+ # If the optimized check fails for other reasons, fall back to full diff
665
+ print(f"Warning: Optimized no-changes check failed: {e}")
666
+ print("Falling back to full diff...")
667
+ return self._expect_only_fallback([])
668
+
669
+ def _verify_table_unchanged(self, table: str):
670
+ """Verify that a table's data hasn't changed (for small tables)."""
671
+ # Get primary key columns
672
+ pk_columns = self._get_primary_key_columns(table)
673
+
674
+ # Get sorted data from both snapshots
675
+ order_by = ", ".join(pk_columns) if pk_columns else "rowid"
676
+
677
+ before_response = self.before.resource.query(f"SELECT * FROM {table} ORDER BY {order_by}")
678
+ after_response = self.after.resource.query(f"SELECT * FROM {table} ORDER BY {order_by}")
679
+
680
+ # Quick check: if column counts differ, there's a schema change
681
+ if before_response.columns != after_response.columns:
682
+ raise AssertionError(f"Schema changed in table '{table}'")
683
+
684
+ # Compare row by row
685
+ if len(before_response.rows) != len(after_response.rows):
686
+ raise AssertionError(
687
+ f"Row count mismatch in table '{table}': "
688
+ f"{len(before_response.rows)} vs {len(after_response.rows)}"
689
+ )
690
+
691
+ for i, (before_row, after_row) in enumerate(zip(before_response.rows, after_response.rows)):
692
+ before_dict = dict(zip(before_response.columns, before_row))
693
+ after_dict = dict(zip(after_response.columns, after_row))
694
+
695
+ # Compare fields, ignoring those in ignore config
696
+ for field in before_response.columns:
697
+ if self.ignore_config.should_ignore_field(table, field):
698
+ continue
699
+
700
+ if not _values_equivalent(before_dict.get(field), after_dict.get(field)):
701
+ pk_val = before_dict.get(pk_columns[0]) if pk_columns else i
702
+ raise AssertionError(
703
+ f"Unexpected change in table '{table}', row {pk_val}, "
704
+ f"field '{field}': {repr(before_dict.get(field))} -> {repr(after_dict.get(field))}"
705
+ )
706
+
707
+ def _expect_only_fallback(self, allowed_changes: list[dict[str, Any]]):
708
+ """Fallback to full diff collection when optimized methods fail."""
308
709
  diff = self._collect()
710
+ return self._validate_diff_against_allowed_changes(diff, allowed_changes)
309
711
 
712
+ def _validate_diff_against_allowed_changes(self, diff: dict[str, Any], allowed_changes: list[dict[str, Any]]):
713
+ """Validate a collected diff against allowed changes."""
310
714
  def _is_change_allowed(
311
715
  table: str, row_id: Any, field: str | None, after_value: Any
312
716
  ) -> bool:
@@ -419,6 +823,20 @@ class SyncSnapshotDiff:
419
823
  raise AssertionError("\n".join(error_lines))
420
824
 
421
825
  return self
826
+
827
+ def expect_only(self, allowed_changes: list[dict[str, Any]]):
828
+ """Ensure only specified changes occurred."""
829
+ # Special case: empty allowed_changes means no changes should have occurred
830
+ if not allowed_changes:
831
+ return self._expect_no_changes()
832
+
833
+ # For expect_only, we can optimize by only checking the specific rows mentioned
834
+ if self._can_use_targeted_queries(allowed_changes):
835
+ return self._expect_only_targeted(allowed_changes)
836
+
837
+ # Fall back to full diff for complex cases
838
+ diff = self._collect()
839
+ return self._validate_diff_against_allowed_changes(diff, allowed_changes)
422
840
 
423
841
 
424
842
  class SyncQueryBuilder:
@@ -669,9 +1087,8 @@ class SQLiteResource(Resource):
669
1087
 
670
1088
  def snapshot(self, name: str | None = None) -> SyncDatabaseSnapshot:
671
1089
  """Create a snapshot of the current database state."""
672
- snapshot = SyncDatabaseSnapshot(self, name)
673
- snapshot._ensure_fetched()
674
- return snapshot
1090
+ # No longer fetch all data upfront - let it be lazy
1091
+ return SyncDatabaseSnapshot(self, name)
675
1092
 
676
1093
  def diff(
677
1094
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.18
3
+ Version: 0.2.20
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fleet-python"
7
- version = "0.2.18"
7
+ version = "0.2.20"
8
8
  description = "Python SDK for Fleet environments"
9
9
  authors = [
10
10
  {name = "Fleet AI", email = "nic@fleet.so"},
File without changes
File without changes
File without changes