fleet-python 0.2.100__tar.gz → 0.2.102__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.100/fleet_python.egg-info → fleet_python-0.2.102}/PKG-INFO +1 -1
  2. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/__init__.py +1 -1
  3. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/__init__.py +1 -1
  4. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/base.py +1 -1
  5. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/instance/client.py +6 -2
  6. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/sqlite.py +89 -2
  7. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/base.py +1 -1
  8. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/client.py +6 -2
  9. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/sqlite.py +89 -2
  10. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/db.py +483 -0
  11. {fleet_python-0.2.100 → fleet_python-0.2.102/fleet_python.egg-info}/PKG-INFO +1 -1
  12. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/SOURCES.txt +1 -0
  13. {fleet_python-0.2.100 → fleet_python-0.2.102}/pyproject.toml +1 -1
  14. fleet_python-0.2.102/tests/test_expect_exactly.py +4148 -0
  15. {fleet_python-0.2.100 → fleet_python-0.2.102}/LICENSE +0 -0
  16. {fleet_python-0.2.100 → fleet_python-0.2.102}/README.md +0 -0
  17. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/diff_example.py +0 -0
  18. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/dsl_example.py +0 -0
  19. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example.py +0 -0
  20. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/exampleResume.py +0 -0
  21. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_account.py +0 -0
  22. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_action_log.py +0 -0
  23. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_client.py +0 -0
  24. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_mcp_anthropic.py +0 -0
  25. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_mcp_openai.py +0 -0
  26. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_sync.py +0 -0
  27. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_task.py +0 -0
  28. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_tasks.py +0 -0
  29. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_verifier.py +0 -0
  30. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/export_tasks.py +0 -0
  31. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/fetch_tasks.py +0 -0
  32. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/gemini_example.py +0 -0
  33. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/import_tasks.py +0 -0
  34. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/iterate_verifiers.py +0 -0
  35. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/json_tasks_example.py +0 -0
  36. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/nova_act_example.py +0 -0
  37. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/openai_example.py +0 -0
  38. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/openai_simple_example.py +0 -0
  39. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/query_builder_example.py +0 -0
  40. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/quickstart.py +0 -0
  41. {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/test_cdp_logging.py +0 -0
  42. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/client.py +0 -0
  43. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/env/__init__.py +0 -0
  44. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/env/client.py +0 -0
  45. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/exceptions.py +0 -0
  46. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/global_client.py +0 -0
  47. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/instance/__init__.py +0 -0
  48. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/instance/base.py +0 -0
  49. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/models.py +0 -0
  50. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/__init__.py +0 -0
  51. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/api.py +0 -0
  52. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/base.py +0 -0
  53. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/browser.py +0 -0
  54. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/mcp.py +0 -0
  55. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/tasks.py +0 -0
  56. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/verifiers/__init__.py +0 -0
  57. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/verifiers/bundler.py +0 -0
  58. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/verifiers/verifier.py +0 -0
  59. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/__init__.py +0 -0
  60. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/Dockerfile +0 -0
  61. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/__init__.py +0 -0
  62. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/agent.py +0 -0
  63. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp/main.py +0 -0
  64. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
  65. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
  66. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
  67. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/requirements.txt +0 -0
  68. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/start.sh +0 -0
  69. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/orchestrator.py +0 -0
  70. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/types.py +0 -0
  71. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/utils.py +0 -0
  72. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/cli.py +0 -0
  73. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/client.py +0 -0
  74. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/config.py +0 -0
  75. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/env/__init__.py +0 -0
  76. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/env/client.py +0 -0
  77. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/eval/__init__.py +0 -0
  78. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/eval/uploader.py +0 -0
  79. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/exceptions.py +0 -0
  80. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/global_client.py +0 -0
  81. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/__init__.py +0 -0
  82. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/base.py +0 -0
  83. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/models.py +0 -0
  84. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/models.py +0 -0
  85. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/proxy/__init__.py +0 -0
  86. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/proxy/proxy.py +0 -0
  87. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/proxy/whitelist.py +0 -0
  88. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/__init__.py +0 -0
  89. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/api.py +0 -0
  90. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/base.py +0 -0
  91. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/browser.py +0 -0
  92. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/mcp.py +0 -0
  93. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/tasks.py +0 -0
  94. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/types.py +0 -0
  95. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/__init__.py +0 -0
  96. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/http_logging.py +0 -0
  97. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/logging.py +0 -0
  98. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/playwright.py +0 -0
  99. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/__init__.py +0 -0
  100. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/bundler.py +0 -0
  101. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/code.py +0 -0
  102. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/decorator.py +0 -0
  103. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/parse.py +0 -0
  104. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/sql_differ.py +0 -0
  105. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/verifier.py +0 -0
  106. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/dependency_links.txt +0 -0
  107. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/entry_points.txt +0 -0
  108. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/requires.txt +0 -0
  109. {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/top_level.txt +0 -0
  110. {fleet_python-0.2.100 → fleet_python-0.2.102}/scripts/fix_sync_imports.py +0 -0
  111. {fleet_python-0.2.100 → fleet_python-0.2.102}/scripts/unasync.py +0 -0
  112. {fleet_python-0.2.100 → fleet_python-0.2.102}/setup.cfg +0 -0
  113. {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/__init__.py +0 -0
  114. {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_app_method.py +0 -0
  115. {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_expect_only.py +0 -0
  116. {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_instance_dispatch.py +0 -0
  117. {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_sqlite_resource_dual_mode.py +0 -0
  118. {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  119. {fleet_python-0.2.100 → fleet_python-0.2.102}/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.100
3
+ Version: 0.2.102
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.100"
76
+ __version__ = "0.2.102"
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.100"
47
+ __version__ = "0.2.102"
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.100"
29
+ __version__ = "0.2.102"
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()
@@ -22,6 +22,7 @@ from fleet.verifiers.db import (
22
22
  _get_row_identifier,
23
23
  _format_row_for_error,
24
24
  _values_equivalent,
25
+ validate_diff_expect_exactly,
25
26
  )
26
27
 
27
28
 
@@ -1732,6 +1733,11 @@ class AsyncSnapshotDiff:
1732
1733
 
1733
1734
  async def expect_only(self, allowed_changes: List[Dict[str, Any]]):
1734
1735
  """Ensure only specified changes occurred."""
1736
+ # Normalize pk values: convert lists to tuples for hashability and consistency
1737
+ for change in allowed_changes:
1738
+ if "pk" in change and isinstance(change["pk"], list):
1739
+ change["pk"] = tuple(change["pk"])
1740
+
1735
1741
  # Special case: empty allowed_changes means no changes should have occurred
1736
1742
  if not allowed_changes:
1737
1743
  return await self._expect_no_changes()
@@ -1750,12 +1756,18 @@ class AsyncSnapshotDiff:
1750
1756
  This version supports field-level specifications for added/removed rows,
1751
1757
  allowing users to specify expected field values instead of just whole-row specs.
1752
1758
  """
1759
+ # Normalize pk values: convert lists to tuples for hashability and consistency
1760
+ for change in allowed_changes:
1761
+ if "pk" in change and isinstance(change["pk"], list):
1762
+ change["pk"] = tuple(change["pk"])
1763
+
1753
1764
  # Special case: empty allowed_changes means no changes should have occurred
1754
1765
  if not allowed_changes:
1755
1766
  return await self._expect_no_changes()
1756
1767
 
1757
1768
  resource = self.after.resource
1758
- if resource.client is not None and resource._mode == "http":
1769
+ # Disabled: structured diff endpoint not yet available
1770
+ if False and resource.client is not None and resource._mode == "http":
1759
1771
  api_diff = None
1760
1772
  try:
1761
1773
  payload = {}
@@ -1779,7 +1791,7 @@ class AsyncSnapshotDiff:
1779
1791
  # Fall back to local diff if API call fails
1780
1792
  print(f"Warning: Failed to fetch structured diff from API: {e}")
1781
1793
  print("Falling back to local diff computation...")
1782
-
1794
+
1783
1795
  # Validate outside try block so AssertionError propagates
1784
1796
  if api_diff is not None:
1785
1797
  return await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
@@ -1794,6 +1806,81 @@ class AsyncSnapshotDiff:
1794
1806
  diff, allowed_changes
1795
1807
  )
1796
1808
 
1809
+ async def expect_exactly(self, expected_changes: List[Dict[str, Any]]):
1810
+ """Verify that EXACTLY the specified changes occurred.
1811
+
1812
+ This is stricter than expect_only_v2:
1813
+ 1. All changes in diff must match a spec (no unexpected changes)
1814
+ 2. All specs must have a matching change in diff (no missing expected changes)
1815
+
1816
+ This method is ideal for verifying that an agent performed exactly what was expected -
1817
+ not more, not less.
1818
+
1819
+ Args:
1820
+ expected_changes: List of expected change specs. Each spec requires:
1821
+ - "type": "insert", "modify", or "delete" (required)
1822
+ - "table": table name (required)
1823
+ - "pk": primary key value (required)
1824
+
1825
+ Spec formats by type:
1826
+ - Insert: {"type": "insert", "table": "t", "pk": 1, "fields": [...]}
1827
+ - Modify: {"type": "modify", "table": "t", "pk": 1, "resulting_fields": [...], "no_other_changes": True/False}
1828
+ - Delete: {"type": "delete", "table": "t", "pk": 1}
1829
+
1830
+ Field specs are 2-tuples: (field_name, expected_value)
1831
+ - ("name", "Alice"): check field equals "Alice"
1832
+ - ("name", ...): accept any value (ellipsis)
1833
+ - ("name", None): check field is SQL NULL
1834
+
1835
+ Note: Legacy specs without explicit "type" are not supported.
1836
+
1837
+ Returns:
1838
+ self for method chaining
1839
+
1840
+ Raises:
1841
+ AssertionError: If there are unexpected changes OR if expected changes are missing
1842
+ ValueError: If specs are missing required fields or have invalid format
1843
+ """
1844
+ # Get the diff (using HTTP if available, otherwise local)
1845
+ resource = self.after.resource
1846
+ diff = None
1847
+
1848
+ if resource.client is not None and resource._mode == "http":
1849
+ try:
1850
+ payload = {}
1851
+ if self.ignore_config:
1852
+ payload["ignore_config"] = {
1853
+ "tables": list(self.ignore_config.tables),
1854
+ "fields": list(self.ignore_config.fields),
1855
+ "table_fields": {
1856
+ table: list(fields) for table, fields in self.ignore_config.table_fields.items()
1857
+ }
1858
+ }
1859
+ response = await resource.client.request(
1860
+ "POST",
1861
+ "/diff/structured",
1862
+ json=payload,
1863
+ )
1864
+ result = response.json()
1865
+ if result.get("success") and "diff" in result:
1866
+ diff = result["diff"]
1867
+ except Exception as e:
1868
+ print(f"Warning: Failed to fetch structured diff from API: {e}")
1869
+ print("Falling back to local diff computation...")
1870
+
1871
+ if diff is None:
1872
+ diff = await self._collect()
1873
+
1874
+ # Use shared validation logic
1875
+ success, error_msg, _ = validate_diff_expect_exactly(
1876
+ diff, expected_changes, self.ignore_config
1877
+ )
1878
+
1879
+ if not success:
1880
+ raise AssertionError(error_msg)
1881
+
1882
+ return self
1883
+
1797
1884
  async def _ensure_all_fetched(self):
1798
1885
  """Fetch ALL data from ALL tables upfront (non-lazy loading).
1799
1886
 
@@ -27,7 +27,7 @@ from .exceptions import (
27
27
  try:
28
28
  from . import __version__
29
29
  except ImportError:
30
- __version__ = "0.2.100"
30
+ __version__ = "0.2.102"
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()
@@ -21,6 +21,7 @@ from fleet.verifiers.db import (
21
21
  _get_row_identifier,
22
22
  _format_row_for_error,
23
23
  _values_equivalent,
24
+ validate_diff_expect_exactly,
24
25
  )
25
26
 
26
27
 
@@ -1780,6 +1781,11 @@ class SyncSnapshotDiff:
1780
1781
 
1781
1782
  def expect_only(self, allowed_changes: List[Dict[str, Any]]):
1782
1783
  """Ensure only specified changes occurred."""
1784
+ # Normalize pk values: convert lists to tuples for hashability and consistency
1785
+ for change in allowed_changes:
1786
+ if "pk" in change and isinstance(change["pk"], list):
1787
+ change["pk"] = tuple(change["pk"])
1788
+
1783
1789
  # Special case: empty allowed_changes means no changes should have occurred
1784
1790
  if not allowed_changes:
1785
1791
  return self._expect_no_changes()
@@ -1798,12 +1804,18 @@ class SyncSnapshotDiff:
1798
1804
  This version supports field-level specifications for added/removed rows,
1799
1805
  allowing users to specify expected field values instead of just whole-row specs.
1800
1806
  """
1807
+ # Normalize pk values: convert lists to tuples for hashability and consistency
1808
+ for change in allowed_changes:
1809
+ if "pk" in change and isinstance(change["pk"], list):
1810
+ change["pk"] = tuple(change["pk"])
1811
+
1801
1812
  # Special case: empty allowed_changes means no changes should have occurred
1802
1813
  if not allowed_changes:
1803
1814
  return self._expect_no_changes()
1804
1815
 
1805
1816
  resource = self.after.resource
1806
- if resource.client is not None and resource._mode == "http":
1817
+ # Disabled: structured diff endpoint not yet available
1818
+ if False and resource.client is not None and resource._mode == "http":
1807
1819
  api_diff = None
1808
1820
  try:
1809
1821
  payload = {}
@@ -1827,7 +1839,7 @@ class SyncSnapshotDiff:
1827
1839
  # Fall back to local diff if API call fails
1828
1840
  print(f"Warning: Failed to fetch structured diff from API: {e}")
1829
1841
  print("Falling back to local diff computation...")
1830
-
1842
+
1831
1843
  # Validate outside try block so AssertionError propagates
1832
1844
  if api_diff is not None:
1833
1845
  return self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
@@ -1840,6 +1852,81 @@ class SyncSnapshotDiff:
1840
1852
  diff = self._collect()
1841
1853
  return self._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
1842
1854
 
1855
+ def expect_exactly(self, expected_changes: List[Dict[str, Any]]):
1856
+ """Verify that EXACTLY the specified changes occurred.
1857
+
1858
+ This is stricter than expect_only_v2:
1859
+ 1. All changes in diff must match a spec (no unexpected changes)
1860
+ 2. All specs must have a matching change in diff (no missing expected changes)
1861
+
1862
+ This method is ideal for verifying that an agent performed exactly what was expected -
1863
+ not more, not less.
1864
+
1865
+ Args:
1866
+ expected_changes: List of expected change specs. Each spec requires:
1867
+ - "type": "insert", "modify", or "delete" (required)
1868
+ - "table": table name (required)
1869
+ - "pk": primary key value (required)
1870
+
1871
+ Spec formats by type:
1872
+ - Insert: {"type": "insert", "table": "t", "pk": 1, "fields": [...]}
1873
+ - Modify: {"type": "modify", "table": "t", "pk": 1, "resulting_fields": [...], "no_other_changes": True/False}
1874
+ - Delete: {"type": "delete", "table": "t", "pk": 1}
1875
+
1876
+ Field specs are 2-tuples: (field_name, expected_value)
1877
+ - ("name", "Alice"): check field equals "Alice"
1878
+ - ("name", ...): accept any value (ellipsis)
1879
+ - ("name", None): check field is SQL NULL
1880
+
1881
+ Note: Legacy specs without explicit "type" are not supported.
1882
+
1883
+ Returns:
1884
+ self for method chaining
1885
+
1886
+ Raises:
1887
+ AssertionError: If there are unexpected changes OR if expected changes are missing
1888
+ ValueError: If specs are missing required fields or have invalid format
1889
+ """
1890
+ # Get the diff (using HTTP if available, otherwise local)
1891
+ resource = self.after.resource
1892
+ diff = None
1893
+
1894
+ if resource.client is not None and resource._mode == "http":
1895
+ try:
1896
+ payload = {}
1897
+ if self.ignore_config:
1898
+ payload["ignore_config"] = {
1899
+ "tables": list(self.ignore_config.tables),
1900
+ "fields": list(self.ignore_config.fields),
1901
+ "table_fields": {
1902
+ table: list(fields) for table, fields in self.ignore_config.table_fields.items()
1903
+ }
1904
+ }
1905
+ response = resource.client.request(
1906
+ "POST",
1907
+ "/diff/structured",
1908
+ json=payload,
1909
+ )
1910
+ result = response.json()
1911
+ if result.get("success") and "diff" in result:
1912
+ diff = result["diff"]
1913
+ except Exception as e:
1914
+ print(f"Warning: Failed to fetch structured diff from API: {e}")
1915
+ print("Falling back to local diff computation...")
1916
+
1917
+ if diff is None:
1918
+ diff = self._collect()
1919
+
1920
+ # Use shared validation logic
1921
+ success, error_msg, _ = validate_diff_expect_exactly(
1922
+ diff, expected_changes, self.ignore_config
1923
+ )
1924
+
1925
+ if not success:
1926
+ raise AssertionError(error_msg)
1927
+
1928
+ return self
1929
+
1843
1930
  def _ensure_all_fetched(self):
1844
1931
  """Fetch ALL data from ALL tables upfront (non-lazy loading).
1845
1932