fleet-python 0.2.99__tar.gz → 0.2.101__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 (119) hide show
  1. {fleet_python-0.2.99/fleet_python.egg-info → fleet_python-0.2.101}/PKG-INFO +1 -1
  2. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/__init__.py +1 -1
  3. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/__init__.py +1 -1
  4. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/base.py +1 -1
  5. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/instance/client.py +6 -2
  6. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/resources/sqlite.py +88 -3
  7. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/base.py +1 -1
  8. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/instance/client.py +6 -2
  9. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/resources/sqlite.py +88 -3
  10. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/db.py +483 -0
  11. {fleet_python-0.2.99 → fleet_python-0.2.101/fleet_python.egg-info}/PKG-INFO +1 -1
  12. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet_python.egg-info/SOURCES.txt +1 -0
  13. {fleet_python-0.2.99 → fleet_python-0.2.101}/pyproject.toml +1 -1
  14. fleet_python-0.2.101/tests/test_expect_exactly.py +4148 -0
  15. {fleet_python-0.2.99 → fleet_python-0.2.101}/LICENSE +0 -0
  16. {fleet_python-0.2.99 → fleet_python-0.2.101}/README.md +0 -0
  17. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/diff_example.py +0 -0
  18. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/dsl_example.py +0 -0
  19. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example.py +0 -0
  20. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/exampleResume.py +0 -0
  21. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_account.py +0 -0
  22. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_action_log.py +0 -0
  23. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_client.py +0 -0
  24. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_mcp_anthropic.py +0 -0
  25. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_mcp_openai.py +0 -0
  26. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_sync.py +0 -0
  27. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_task.py +0 -0
  28. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_tasks.py +0 -0
  29. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/example_verifier.py +0 -0
  30. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/export_tasks.py +0 -0
  31. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/fetch_tasks.py +0 -0
  32. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/gemini_example.py +0 -0
  33. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/import_tasks.py +0 -0
  34. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/iterate_verifiers.py +0 -0
  35. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/json_tasks_example.py +0 -0
  36. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/nova_act_example.py +0 -0
  37. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/openai_example.py +0 -0
  38. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/openai_simple_example.py +0 -0
  39. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/query_builder_example.py +0 -0
  40. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/quickstart.py +0 -0
  41. {fleet_python-0.2.99 → fleet_python-0.2.101}/examples/test_cdp_logging.py +0 -0
  42. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/client.py +0 -0
  43. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/env/__init__.py +0 -0
  44. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/env/client.py +0 -0
  45. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/exceptions.py +0 -0
  46. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/global_client.py +0 -0
  47. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/instance/__init__.py +0 -0
  48. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/instance/base.py +0 -0
  49. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/models.py +0 -0
  50. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/resources/__init__.py +0 -0
  51. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/resources/api.py +0 -0
  52. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/resources/base.py +0 -0
  53. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/resources/browser.py +0 -0
  54. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/resources/mcp.py +0 -0
  55. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/tasks.py +0 -0
  56. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/verifiers/__init__.py +0 -0
  57. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/verifiers/bundler.py +0 -0
  58. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/_async/verifiers/verifier.py +0 -0
  59. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/__init__.py +0 -0
  60. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/Dockerfile +0 -0
  61. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/__init__.py +0 -0
  62. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/agent.py +0 -0
  63. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/mcp/main.py +0 -0
  64. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
  65. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
  66. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
  67. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/requirements.txt +0 -0
  68. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/gemini_cua/start.sh +0 -0
  69. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/orchestrator.py +0 -0
  70. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/types.py +0 -0
  71. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/agent/utils.py +0 -0
  72. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/cli.py +0 -0
  73. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/client.py +0 -0
  74. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/config.py +0 -0
  75. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/env/__init__.py +0 -0
  76. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/env/client.py +0 -0
  77. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/eval/__init__.py +0 -0
  78. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/eval/uploader.py +0 -0
  79. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/exceptions.py +0 -0
  80. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/global_client.py +0 -0
  81. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/instance/__init__.py +0 -0
  82. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/instance/base.py +0 -0
  83. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/instance/models.py +0 -0
  84. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/models.py +0 -0
  85. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/proxy/__init__.py +0 -0
  86. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/proxy/proxy.py +0 -0
  87. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/proxy/whitelist.py +0 -0
  88. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/resources/__init__.py +0 -0
  89. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/resources/api.py +0 -0
  90. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/resources/base.py +0 -0
  91. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/resources/browser.py +0 -0
  92. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/resources/mcp.py +0 -0
  93. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/tasks.py +0 -0
  94. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/types.py +0 -0
  95. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/utils/__init__.py +0 -0
  96. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/utils/http_logging.py +0 -0
  97. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/utils/logging.py +0 -0
  98. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/utils/playwright.py +0 -0
  99. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/__init__.py +0 -0
  100. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/bundler.py +0 -0
  101. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/code.py +0 -0
  102. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/decorator.py +0 -0
  103. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/parse.py +0 -0
  104. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/sql_differ.py +0 -0
  105. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet/verifiers/verifier.py +0 -0
  106. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet_python.egg-info/dependency_links.txt +0 -0
  107. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet_python.egg-info/entry_points.txt +0 -0
  108. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet_python.egg-info/requires.txt +0 -0
  109. {fleet_python-0.2.99 → fleet_python-0.2.101}/fleet_python.egg-info/top_level.txt +0 -0
  110. {fleet_python-0.2.99 → fleet_python-0.2.101}/scripts/fix_sync_imports.py +0 -0
  111. {fleet_python-0.2.99 → fleet_python-0.2.101}/scripts/unasync.py +0 -0
  112. {fleet_python-0.2.99 → fleet_python-0.2.101}/setup.cfg +0 -0
  113. {fleet_python-0.2.99 → fleet_python-0.2.101}/tests/__init__.py +0 -0
  114. {fleet_python-0.2.99 → fleet_python-0.2.101}/tests/test_app_method.py +0 -0
  115. {fleet_python-0.2.99 → fleet_python-0.2.101}/tests/test_expect_only.py +0 -0
  116. {fleet_python-0.2.99 → fleet_python-0.2.101}/tests/test_instance_dispatch.py +0 -0
  117. {fleet_python-0.2.99 → fleet_python-0.2.101}/tests/test_sqlite_resource_dual_mode.py +0 -0
  118. {fleet_python-0.2.99 → fleet_python-0.2.101}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  119. {fleet_python-0.2.99 → fleet_python-0.2.101}/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.99
3
+ Version: 0.2.101
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -73,7 +73,7 @@ from . import env
73
73
  from . import global_client as _global_client
74
74
  from ._async import global_client as _async_global_client
75
75
 
76
- __version__ = "0.2.99"
76
+ __version__ = "0.2.101"
77
77
 
78
78
  __all__ = [
79
79
  # Core classes
@@ -44,7 +44,7 @@ from ..types import VerifierFunction
44
44
  from .. import env
45
45
  from . import global_client as _async_global_client
46
46
 
47
- __version__ = "0.2.99"
47
+ __version__ = "0.2.101"
48
48
 
49
49
  __all__ = [
50
50
  # Core classes
@@ -26,7 +26,7 @@ from .exceptions import (
26
26
  try:
27
27
  from .. import __version__
28
28
  except ImportError:
29
- __version__ = "0.2.99"
29
+ __version__ = "0.2.101"
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -166,8 +166,12 @@ class AsyncInstanceClient:
166
166
  if self._resources is None:
167
167
  response = await self.client.request("GET", "/resources")
168
168
  if response.status_code != 200:
169
- self._resources = []
170
- return
169
+ response_body = response.text[:500] if response.text else "empty"
170
+ self._resources = [] # Mark as loaded (empty) to prevent retries
171
+ raise FleetEnvironmentError(
172
+ f"Failed to load instance resources: status_code={response.status_code} "
173
+ f"(url={self.base_url}, response={response_body})"
174
+ )
171
175
 
172
176
  # Handle both old and new response formats
173
177
  response_data = response.json()
@@ -8,6 +8,7 @@ import sqlite3
8
8
  import os
9
9
  import asyncio
10
10
  import re
11
+ import json
11
12
 
12
13
  from typing import TYPE_CHECKING
13
14
 
@@ -21,6 +22,7 @@ from fleet.verifiers.db import (
21
22
  _get_row_identifier,
22
23
  _format_row_for_error,
23
24
  _values_equivalent,
25
+ validate_diff_expect_exactly,
24
26
  )
25
27
 
26
28
 
@@ -1754,7 +1756,8 @@ class AsyncSnapshotDiff:
1754
1756
  return await self._expect_no_changes()
1755
1757
 
1756
1758
  resource = self.after.resource
1757
- if resource.client is not None and resource._mode == "http":
1759
+ # Disabled: structured diff endpoint not yet available
1760
+ if False and resource.client is not None and resource._mode == "http":
1758
1761
  api_diff = None
1759
1762
  try:
1760
1763
  payload = {}
@@ -1778,7 +1781,7 @@ class AsyncSnapshotDiff:
1778
1781
  # Fall back to local diff if API call fails
1779
1782
  print(f"Warning: Failed to fetch structured diff from API: {e}")
1780
1783
  print("Falling back to local diff computation...")
1781
-
1784
+
1782
1785
  # Validate outside try block so AssertionError propagates
1783
1786
  if api_diff is not None:
1784
1787
  return await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
@@ -1793,6 +1796,81 @@ class AsyncSnapshotDiff:
1793
1796
  diff, allowed_changes
1794
1797
  )
1795
1798
 
1799
+ async def expect_exactly(self, expected_changes: List[Dict[str, Any]]):
1800
+ """Verify that EXACTLY the specified changes occurred.
1801
+
1802
+ This is stricter than expect_only_v2:
1803
+ 1. All changes in diff must match a spec (no unexpected changes)
1804
+ 2. All specs must have a matching change in diff (no missing expected changes)
1805
+
1806
+ This method is ideal for verifying that an agent performed exactly what was expected -
1807
+ not more, not less.
1808
+
1809
+ Args:
1810
+ expected_changes: List of expected change specs. Each spec requires:
1811
+ - "type": "insert", "modify", or "delete" (required)
1812
+ - "table": table name (required)
1813
+ - "pk": primary key value (required)
1814
+
1815
+ Spec formats by type:
1816
+ - Insert: {"type": "insert", "table": "t", "pk": 1, "fields": [...]}
1817
+ - Modify: {"type": "modify", "table": "t", "pk": 1, "resulting_fields": [...], "no_other_changes": True/False}
1818
+ - Delete: {"type": "delete", "table": "t", "pk": 1}
1819
+
1820
+ Field specs are 2-tuples: (field_name, expected_value)
1821
+ - ("name", "Alice"): check field equals "Alice"
1822
+ - ("name", ...): accept any value (ellipsis)
1823
+ - ("name", None): check field is SQL NULL
1824
+
1825
+ Note: Legacy specs without explicit "type" are not supported.
1826
+
1827
+ Returns:
1828
+ self for method chaining
1829
+
1830
+ Raises:
1831
+ AssertionError: If there are unexpected changes OR if expected changes are missing
1832
+ ValueError: If specs are missing required fields or have invalid format
1833
+ """
1834
+ # Get the diff (using HTTP if available, otherwise local)
1835
+ resource = self.after.resource
1836
+ diff = None
1837
+
1838
+ if resource.client is not None and resource._mode == "http":
1839
+ try:
1840
+ payload = {}
1841
+ if self.ignore_config:
1842
+ payload["ignore_config"] = {
1843
+ "tables": list(self.ignore_config.tables),
1844
+ "fields": list(self.ignore_config.fields),
1845
+ "table_fields": {
1846
+ table: list(fields) for table, fields in self.ignore_config.table_fields.items()
1847
+ }
1848
+ }
1849
+ response = await resource.client.request(
1850
+ "POST",
1851
+ "/diff/structured",
1852
+ json=payload,
1853
+ )
1854
+ result = response.json()
1855
+ if result.get("success") and "diff" in result:
1856
+ diff = result["diff"]
1857
+ except Exception as e:
1858
+ print(f"Warning: Failed to fetch structured diff from API: {e}")
1859
+ print("Falling back to local diff computation...")
1860
+
1861
+ if diff is None:
1862
+ diff = await self._collect()
1863
+
1864
+ # Use shared validation logic
1865
+ success, error_msg, _ = validate_diff_expect_exactly(
1866
+ diff, expected_changes, self.ignore_config
1867
+ )
1868
+
1869
+ if not success:
1870
+ raise AssertionError(error_msg)
1871
+
1872
+ return self
1873
+
1796
1874
  async def _ensure_all_fetched(self):
1797
1875
  """Fetch ALL data from ALL tables upfront (non-lazy loading).
1798
1876
 
@@ -2114,7 +2192,14 @@ class AsyncSQLiteResource(Resource):
2114
2192
  response = await self.client.request(
2115
2193
  "GET", f"/resources/sqlite/{self.resource.name}/describe"
2116
2194
  )
2117
- return DescribeResponse(**response.json())
2195
+ try:
2196
+ return DescribeResponse(**response.json())
2197
+ except json.JSONDecodeError as e:
2198
+ raise ValueError(
2199
+ f"Failed to parse JSON response from SQLite describe endpoint. "
2200
+ f"Status: {response.status_code}, "
2201
+ f"Response text: {response.text[:500]}"
2202
+ ) from e
2118
2203
 
2119
2204
  async def _describe_direct(self) -> DescribeResponse:
2120
2205
  """Describe database schema from local file or in-memory database."""
@@ -27,7 +27,7 @@ from .exceptions import (
27
27
  try:
28
28
  from . import __version__
29
29
  except ImportError:
30
- __version__ = "0.2.99"
30
+ __version__ = "0.2.101"
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -164,8 +164,12 @@ class InstanceClient:
164
164
  if self._resources is None:
165
165
  response = self.client.request("GET", "/resources")
166
166
  if response.status_code != 200:
167
- self._resources = []
168
- return
167
+ response_body = response.text[:500] if response.text else "empty"
168
+ self._resources = [] # Mark as loaded (empty) to prevent retries
169
+ raise FleetEnvironmentError(
170
+ f"Failed to load instance resources: status_code={response.status_code} "
171
+ f"(url={self.base_url}, response={response_body})"
172
+ )
169
173
 
170
174
  # Handle both old and new response formats
171
175
  response_data = response.json()
@@ -7,6 +7,7 @@ import tempfile
7
7
  import sqlite3
8
8
  import os
9
9
  import re
10
+ import json
10
11
 
11
12
  from typing import TYPE_CHECKING
12
13
 
@@ -20,6 +21,7 @@ from fleet.verifiers.db import (
20
21
  _get_row_identifier,
21
22
  _format_row_for_error,
22
23
  _values_equivalent,
24
+ validate_diff_expect_exactly,
23
25
  )
24
26
 
25
27
 
@@ -1802,7 +1804,8 @@ class SyncSnapshotDiff:
1802
1804
  return self._expect_no_changes()
1803
1805
 
1804
1806
  resource = self.after.resource
1805
- if resource.client is not None and resource._mode == "http":
1807
+ # Disabled: structured diff endpoint not yet available
1808
+ if False and resource.client is not None and resource._mode == "http":
1806
1809
  api_diff = None
1807
1810
  try:
1808
1811
  payload = {}
@@ -1826,7 +1829,7 @@ class SyncSnapshotDiff:
1826
1829
  # Fall back to local diff if API call fails
1827
1830
  print(f"Warning: Failed to fetch structured diff from API: {e}")
1828
1831
  print("Falling back to local diff computation...")
1829
-
1832
+
1830
1833
  # Validate outside try block so AssertionError propagates
1831
1834
  if api_diff is not None:
1832
1835
  return self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
@@ -1839,6 +1842,81 @@ class SyncSnapshotDiff:
1839
1842
  diff = self._collect()
1840
1843
  return self._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
1841
1844
 
1845
+ def expect_exactly(self, expected_changes: List[Dict[str, Any]]):
1846
+ """Verify that EXACTLY the specified changes occurred.
1847
+
1848
+ This is stricter than expect_only_v2:
1849
+ 1. All changes in diff must match a spec (no unexpected changes)
1850
+ 2. All specs must have a matching change in diff (no missing expected changes)
1851
+
1852
+ This method is ideal for verifying that an agent performed exactly what was expected -
1853
+ not more, not less.
1854
+
1855
+ Args:
1856
+ expected_changes: List of expected change specs. Each spec requires:
1857
+ - "type": "insert", "modify", or "delete" (required)
1858
+ - "table": table name (required)
1859
+ - "pk": primary key value (required)
1860
+
1861
+ Spec formats by type:
1862
+ - Insert: {"type": "insert", "table": "t", "pk": 1, "fields": [...]}
1863
+ - Modify: {"type": "modify", "table": "t", "pk": 1, "resulting_fields": [...], "no_other_changes": True/False}
1864
+ - Delete: {"type": "delete", "table": "t", "pk": 1}
1865
+
1866
+ Field specs are 2-tuples: (field_name, expected_value)
1867
+ - ("name", "Alice"): check field equals "Alice"
1868
+ - ("name", ...): accept any value (ellipsis)
1869
+ - ("name", None): check field is SQL NULL
1870
+
1871
+ Note: Legacy specs without explicit "type" are not supported.
1872
+
1873
+ Returns:
1874
+ self for method chaining
1875
+
1876
+ Raises:
1877
+ AssertionError: If there are unexpected changes OR if expected changes are missing
1878
+ ValueError: If specs are missing required fields or have invalid format
1879
+ """
1880
+ # Get the diff (using HTTP if available, otherwise local)
1881
+ resource = self.after.resource
1882
+ diff = None
1883
+
1884
+ if resource.client is not None and resource._mode == "http":
1885
+ try:
1886
+ payload = {}
1887
+ if self.ignore_config:
1888
+ payload["ignore_config"] = {
1889
+ "tables": list(self.ignore_config.tables),
1890
+ "fields": list(self.ignore_config.fields),
1891
+ "table_fields": {
1892
+ table: list(fields) for table, fields in self.ignore_config.table_fields.items()
1893
+ }
1894
+ }
1895
+ response = resource.client.request(
1896
+ "POST",
1897
+ "/diff/structured",
1898
+ json=payload,
1899
+ )
1900
+ result = response.json()
1901
+ if result.get("success") and "diff" in result:
1902
+ diff = result["diff"]
1903
+ except Exception as e:
1904
+ print(f"Warning: Failed to fetch structured diff from API: {e}")
1905
+ print("Falling back to local diff computation...")
1906
+
1907
+ if diff is None:
1908
+ diff = self._collect()
1909
+
1910
+ # Use shared validation logic
1911
+ success, error_msg, _ = validate_diff_expect_exactly(
1912
+ diff, expected_changes, self.ignore_config
1913
+ )
1914
+
1915
+ if not success:
1916
+ raise AssertionError(error_msg)
1917
+
1918
+ return self
1919
+
1842
1920
  def _ensure_all_fetched(self):
1843
1921
  """Fetch ALL data from ALL tables upfront (non-lazy loading).
1844
1922
 
@@ -2160,7 +2238,14 @@ class SQLiteResource(Resource):
2160
2238
  response = self.client.request(
2161
2239
  "GET", f"/resources/sqlite/{self.resource.name}/describe"
2162
2240
  )
2163
- return DescribeResponse(**response.json())
2241
+ try:
2242
+ return DescribeResponse(**response.json())
2243
+ except json.JSONDecodeError as e:
2244
+ raise ValueError(
2245
+ f"Failed to parse JSON response from SQLite describe endpoint. "
2246
+ f"Status: {response.status_code}, "
2247
+ f"Response text: {response.text[:500]}"
2248
+ ) from e
2164
2249
 
2165
2250
  def _describe_direct(self) -> DescribeResponse:
2166
2251
  """Describe database schema from local file or in-memory database."""