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.
- {fleet_python-0.2.100/fleet_python.egg-info → fleet_python-0.2.102}/PKG-INFO +1 -1
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/__init__.py +1 -1
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/__init__.py +1 -1
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/base.py +1 -1
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/instance/client.py +6 -2
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/sqlite.py +89 -2
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/base.py +1 -1
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/client.py +6 -2
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/sqlite.py +89 -2
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/db.py +483 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/SOURCES.txt +1 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/pyproject.toml +1 -1
- fleet_python-0.2.102/tests/test_expect_exactly.py +4148 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/LICENSE +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/README.md +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/diff_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_account.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_sync.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_task.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/iterate_verifiers.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/openai_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/quickstart.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/api.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/Dockerfile +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/agent.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp/main.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/requirements.txt +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/gemini_cua/start.sh +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/orchestrator.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/types.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/agent/utils.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/cli.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/config.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/env/client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/eval/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/eval/uploader.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/global_client.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/models.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/proxy/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/proxy/proxy.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/proxy/whitelist.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/api.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/tasks.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/types.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/http_logging.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/logging.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/utils/playwright.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/entry_points.txt +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/scripts/unasync.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/setup.cfg +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/__init__.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_expect_only.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.100 → fleet_python-0.2.102}/tests/test_verifier_from_string.py +0 -0
|
@@ -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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|