fleet-python 0.2.95__tar.gz → 0.2.97__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.95/fleet_python.egg-info → fleet_python-0.2.97}/PKG-INFO +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/__init__.py +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/__init__.py +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/base.py +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/client.py +19 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/instance/client.py +26 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/models.py +1 -0
- fleet_python-0.2.97/fleet/_async/resources/api.py +200 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/resources/sqlite.py +330 -2
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/base.py +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/client.py +19 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/instance/client.py +26 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/instance/models.py +1 -0
- fleet_python-0.2.97/fleet/resources/api.py +200 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/resources/sqlite.py +356 -2
- {fleet_python-0.2.95 → fleet_python-0.2.97/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet_python.egg-info/SOURCES.txt +2 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/pyproject.toml +1 -1
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/test_expect_only.py +672 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/LICENSE +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/README.md +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/diff_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_account.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_client.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_sync.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_task.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/iterate_verifiers.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/openai_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/quickstart.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/Dockerfile +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/agent.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp/main.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/requirements.txt +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/gemini_cua/start.sh +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/orchestrator.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/types.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/agent/utils.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/cli.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/config.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/env/client.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/eval/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/eval/uploader.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/global_client.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/models.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/proxy/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/proxy/proxy.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/proxy/whitelist.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/tasks.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/types.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/utils/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/utils/http_logging.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/utils/logging.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/utils/playwright.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/db.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet_python.egg-info/entry_points.txt +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/scripts/unasync.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/setup.cfg +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/__init__.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.95 → fleet_python-0.2.97}/tests/test_verifier_from_string.py +0 -0
|
@@ -172,6 +172,7 @@ from .resources.base import Resource
|
|
|
172
172
|
from .resources.sqlite import AsyncSQLiteResource
|
|
173
173
|
from .resources.browser import AsyncBrowserResource
|
|
174
174
|
from .resources.mcp import AsyncMCPResource
|
|
175
|
+
from .resources.api import AsyncAPIResource
|
|
175
176
|
|
|
176
177
|
logger = logging.getLogger(__name__)
|
|
177
178
|
|
|
@@ -385,6 +386,24 @@ class AsyncEnv(EnvironmentBase):
|
|
|
385
386
|
def browser(self, name: str = "cdp") -> AsyncBrowserResource:
|
|
386
387
|
return self.instance.browser(name)
|
|
387
388
|
|
|
389
|
+
def api(self, name: str = "api") -> AsyncAPIResource:
|
|
390
|
+
"""Get an API resource for making HTTP requests to the app's API.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
name: Name for the API resource (default: "api")
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
AsyncAPIResource for making HTTP requests
|
|
397
|
+
"""
|
|
398
|
+
# Use urls.api if available, otherwise fall back to urls.root + "/raw"
|
|
399
|
+
if self.urls and self.urls.api:
|
|
400
|
+
base_url = self.urls.api
|
|
401
|
+
elif self.urls and self.urls.root:
|
|
402
|
+
base_url = f"{self.urls.root.rstrip('/')}/raw"
|
|
403
|
+
else:
|
|
404
|
+
raise ValueError("No API URL configured for this environment")
|
|
405
|
+
return self.instance.api(name, base_url)
|
|
406
|
+
|
|
388
407
|
@property
|
|
389
408
|
def mcp(self) -> AsyncMCPResource:
|
|
390
409
|
mcp_url = f"{self.urls.root}mcp"
|
|
@@ -9,6 +9,7 @@ from urllib.parse import urlparse
|
|
|
9
9
|
|
|
10
10
|
from ..resources.sqlite import AsyncSQLiteResource
|
|
11
11
|
from ..resources.browser import AsyncBrowserResource
|
|
12
|
+
from ..resources.api import AsyncAPIResource
|
|
12
13
|
from ..resources.base import Resource
|
|
13
14
|
|
|
14
15
|
from fleet.verifiers import DatabaseSnapshot
|
|
@@ -23,6 +24,7 @@ from ...instance.models import (
|
|
|
23
24
|
ResetResponse,
|
|
24
25
|
Resource as ResourceModel,
|
|
25
26
|
ResourceType,
|
|
27
|
+
ResourceMode,
|
|
26
28
|
HealthResponse,
|
|
27
29
|
ExecuteFunctionRequest,
|
|
28
30
|
ExecuteFunctionResponse,
|
|
@@ -35,6 +37,7 @@ logger = logging.getLogger(__name__)
|
|
|
35
37
|
RESOURCE_TYPES = {
|
|
36
38
|
ResourceType.db: AsyncSQLiteResource,
|
|
37
39
|
ResourceType.cdp: AsyncBrowserResource,
|
|
40
|
+
ResourceType.api: AsyncAPIResource,
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
ValidatorType = Callable[
|
|
@@ -102,6 +105,29 @@ class AsyncInstanceClient:
|
|
|
102
105
|
self._resources_state[ResourceType.cdp.value][name], self.client
|
|
103
106
|
)
|
|
104
107
|
|
|
108
|
+
def api(self, name: str, base_url: str) -> AsyncAPIResource:
|
|
109
|
+
"""
|
|
110
|
+
Returns an API resource for making HTTP requests.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
name: The name of the API resource
|
|
114
|
+
base_url: The base URL for API requests
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
An AsyncAPIResource for making HTTP requests
|
|
118
|
+
"""
|
|
119
|
+
# Create a minimal resource model for API
|
|
120
|
+
resource_model = ResourceModel(
|
|
121
|
+
name=name,
|
|
122
|
+
type=ResourceType.api,
|
|
123
|
+
mode=ResourceMode.rw,
|
|
124
|
+
)
|
|
125
|
+
return AsyncAPIResource(
|
|
126
|
+
resource_model,
|
|
127
|
+
base_url=base_url,
|
|
128
|
+
client=self.client.httpx_client if self.client else None,
|
|
129
|
+
)
|
|
130
|
+
|
|
105
131
|
async def resources(self) -> List[Resource]:
|
|
106
132
|
await self._load_resources()
|
|
107
133
|
return [
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Async API Resource for making HTTP requests to the app's API endpoint."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .base import Resource
|
|
7
|
+
from ...instance.models import Resource as ResourceModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncAPIResponse:
|
|
11
|
+
"""Simple wrapper around httpx.Response with a requests-like interface."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, response: httpx.Response):
|
|
14
|
+
self._response = response
|
|
15
|
+
self.status_code: int = response.status_code
|
|
16
|
+
self.headers: httpx.Headers = response.headers
|
|
17
|
+
self.text: str = response.text
|
|
18
|
+
self.content: bytes = response.content
|
|
19
|
+
self.url: str = str(response.url)
|
|
20
|
+
self.ok: bool = response.is_success
|
|
21
|
+
|
|
22
|
+
def json(self) -> Any:
|
|
23
|
+
"""Parse response body as JSON."""
|
|
24
|
+
return self._response.json()
|
|
25
|
+
|
|
26
|
+
def raise_for_status(self) -> "AsyncAPIResponse":
|
|
27
|
+
"""Raise an HTTPStatusError if the response has an error status code."""
|
|
28
|
+
self._response.raise_for_status()
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"<AsyncAPIResponse [{self.status_code}]>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncAPIResource(Resource):
|
|
36
|
+
"""Async HTTP client for making requests to the app's API endpoint.
|
|
37
|
+
|
|
38
|
+
Provides a requests-like interface for interacting with the app's REST API.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
api = env.api()
|
|
42
|
+
response = await api.get("/users/1")
|
|
43
|
+
print(response.status_code) # 200
|
|
44
|
+
print(response.json()) # {"id": 1, "name": "John"}
|
|
45
|
+
|
|
46
|
+
# With headers/auth
|
|
47
|
+
response = await api.post(
|
|
48
|
+
"/users",
|
|
49
|
+
json={"name": "Jane"},
|
|
50
|
+
headers={"Authorization": "Bearer xxx"}
|
|
51
|
+
)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
resource: ResourceModel,
|
|
57
|
+
base_url: str,
|
|
58
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
59
|
+
):
|
|
60
|
+
super().__init__(resource)
|
|
61
|
+
self.base_url = base_url.rstrip("/")
|
|
62
|
+
self._client = client or httpx.AsyncClient()
|
|
63
|
+
|
|
64
|
+
def _build_url(self, path: str) -> str:
|
|
65
|
+
"""Build full URL from base_url and path."""
|
|
66
|
+
if path.startswith("/"):
|
|
67
|
+
return f"{self.base_url}{path}"
|
|
68
|
+
return f"{self.base_url}/{path}"
|
|
69
|
+
|
|
70
|
+
async def request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
*,
|
|
75
|
+
params: Optional[Dict[str, Any]] = None,
|
|
76
|
+
json: Optional[Any] = None,
|
|
77
|
+
data: Optional[Dict[str, Any]] = None,
|
|
78
|
+
headers: Optional[Dict[str, str]] = None,
|
|
79
|
+
cookies: Optional[Dict[str, str]] = None,
|
|
80
|
+
timeout: Optional[float] = None,
|
|
81
|
+
**kwargs: Any,
|
|
82
|
+
) -> AsyncAPIResponse:
|
|
83
|
+
"""Make an HTTP request.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
method: HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)
|
|
87
|
+
path: URL path (relative to base_url)
|
|
88
|
+
params: Query parameters
|
|
89
|
+
json: JSON body (will be serialized)
|
|
90
|
+
data: Form data
|
|
91
|
+
headers: Request headers
|
|
92
|
+
cookies: Request cookies
|
|
93
|
+
timeout: Request timeout in seconds
|
|
94
|
+
**kwargs: Additional arguments passed to httpx
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
AsyncAPIResponse with status_code, headers, text, content, json() method
|
|
98
|
+
"""
|
|
99
|
+
url = self._build_url(path)
|
|
100
|
+
response = await self._client.request(
|
|
101
|
+
method,
|
|
102
|
+
url,
|
|
103
|
+
params=params,
|
|
104
|
+
json=json,
|
|
105
|
+
data=data,
|
|
106
|
+
headers=headers,
|
|
107
|
+
cookies=cookies,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
**kwargs,
|
|
110
|
+
)
|
|
111
|
+
return AsyncAPIResponse(response)
|
|
112
|
+
|
|
113
|
+
async def get(
|
|
114
|
+
self,
|
|
115
|
+
path: str,
|
|
116
|
+
*,
|
|
117
|
+
params: Optional[Dict[str, Any]] = None,
|
|
118
|
+
headers: Optional[Dict[str, str]] = None,
|
|
119
|
+
**kwargs: Any,
|
|
120
|
+
) -> AsyncAPIResponse:
|
|
121
|
+
"""Make a GET request."""
|
|
122
|
+
return await self.request("GET", path, params=params, headers=headers, **kwargs)
|
|
123
|
+
|
|
124
|
+
async def post(
|
|
125
|
+
self,
|
|
126
|
+
path: str,
|
|
127
|
+
*,
|
|
128
|
+
json: Optional[Any] = None,
|
|
129
|
+
data: Optional[Dict[str, Any]] = None,
|
|
130
|
+
params: Optional[Dict[str, Any]] = None,
|
|
131
|
+
headers: Optional[Dict[str, str]] = None,
|
|
132
|
+
**kwargs: Any,
|
|
133
|
+
) -> AsyncAPIResponse:
|
|
134
|
+
"""Make a POST request."""
|
|
135
|
+
return await self.request(
|
|
136
|
+
"POST", path, json=json, data=data, params=params, headers=headers, **kwargs
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def put(
|
|
140
|
+
self,
|
|
141
|
+
path: str,
|
|
142
|
+
*,
|
|
143
|
+
json: Optional[Any] = None,
|
|
144
|
+
data: Optional[Dict[str, Any]] = None,
|
|
145
|
+
params: Optional[Dict[str, Any]] = None,
|
|
146
|
+
headers: Optional[Dict[str, str]] = None,
|
|
147
|
+
**kwargs: Any,
|
|
148
|
+
) -> AsyncAPIResponse:
|
|
149
|
+
"""Make a PUT request."""
|
|
150
|
+
return await self.request(
|
|
151
|
+
"PUT", path, json=json, data=data, params=params, headers=headers, **kwargs
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def patch(
|
|
155
|
+
self,
|
|
156
|
+
path: str,
|
|
157
|
+
*,
|
|
158
|
+
json: Optional[Any] = None,
|
|
159
|
+
data: Optional[Dict[str, Any]] = None,
|
|
160
|
+
params: Optional[Dict[str, Any]] = None,
|
|
161
|
+
headers: Optional[Dict[str, str]] = None,
|
|
162
|
+
**kwargs: Any,
|
|
163
|
+
) -> AsyncAPIResponse:
|
|
164
|
+
"""Make a PATCH request."""
|
|
165
|
+
return await self.request(
|
|
166
|
+
"PATCH", path, json=json, data=data, params=params, headers=headers, **kwargs
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def delete(
|
|
170
|
+
self,
|
|
171
|
+
path: str,
|
|
172
|
+
*,
|
|
173
|
+
params: Optional[Dict[str, Any]] = None,
|
|
174
|
+
headers: Optional[Dict[str, str]] = None,
|
|
175
|
+
**kwargs: Any,
|
|
176
|
+
) -> AsyncAPIResponse:
|
|
177
|
+
"""Make a DELETE request."""
|
|
178
|
+
return await self.request("DELETE", path, params=params, headers=headers, **kwargs)
|
|
179
|
+
|
|
180
|
+
async def head(
|
|
181
|
+
self,
|
|
182
|
+
path: str,
|
|
183
|
+
*,
|
|
184
|
+
params: Optional[Dict[str, Any]] = None,
|
|
185
|
+
headers: Optional[Dict[str, str]] = None,
|
|
186
|
+
**kwargs: Any,
|
|
187
|
+
) -> AsyncAPIResponse:
|
|
188
|
+
"""Make a HEAD request."""
|
|
189
|
+
return await self.request("HEAD", path, params=params, headers=headers, **kwargs)
|
|
190
|
+
|
|
191
|
+
async def options(
|
|
192
|
+
self,
|
|
193
|
+
path: str,
|
|
194
|
+
*,
|
|
195
|
+
params: Optional[Dict[str, Any]] = None,
|
|
196
|
+
headers: Optional[Dict[str, str]] = None,
|
|
197
|
+
**kwargs: Any,
|
|
198
|
+
) -> AsyncAPIResponse:
|
|
199
|
+
"""Make an OPTIONS request."""
|
|
200
|
+
return await self.request("OPTIONS", path, params=params, headers=headers, **kwargs)
|
|
@@ -891,6 +891,326 @@ class AsyncSnapshotDiff:
|
|
|
891
891
|
|
|
892
892
|
return self
|
|
893
893
|
|
|
894
|
+
async def _expect_only_targeted_v2(self, allowed_changes: List[Dict[str, Any]]):
|
|
895
|
+
"""Optimized version that only queries specific rows mentioned in allowed_changes.
|
|
896
|
+
|
|
897
|
+
Supports v2 spec formats:
|
|
898
|
+
- {"table": "t", "pk": 1, "type": "insert", "fields": [...]}
|
|
899
|
+
- {"table": "t", "pk": 1, "type": "modify", "resulting_fields": [...], "no_other_changes": bool}
|
|
900
|
+
- {"table": "t", "pk": 1, "type": "delete", "fields": [...]}
|
|
901
|
+
- Legacy single-field specs: {"table": "t", "pk": 1, "field": "x", "after": val}
|
|
902
|
+
"""
|
|
903
|
+
import asyncio
|
|
904
|
+
|
|
905
|
+
# Helper functions for v2 spec validation
|
|
906
|
+
def _parse_fields_spec(
|
|
907
|
+
fields_spec: List[Tuple[str, Any]]
|
|
908
|
+
) -> Dict[str, Tuple[bool, Any]]:
|
|
909
|
+
"""Parse a fields spec into a mapping of field_name -> (should_check_value, expected_value)."""
|
|
910
|
+
spec_map: Dict[str, Tuple[bool, Any]] = {}
|
|
911
|
+
for spec_tuple in fields_spec:
|
|
912
|
+
if len(spec_tuple) != 2:
|
|
913
|
+
raise ValueError(
|
|
914
|
+
f"Invalid field spec tuple: {spec_tuple}. "
|
|
915
|
+
f"Expected 2-tuple like ('field', value), ('field', None), or ('field', ...)"
|
|
916
|
+
)
|
|
917
|
+
field_name, expected_value = spec_tuple
|
|
918
|
+
if expected_value is ...:
|
|
919
|
+
spec_map[field_name] = (False, None)
|
|
920
|
+
else:
|
|
921
|
+
spec_map[field_name] = (True, expected_value)
|
|
922
|
+
return spec_map
|
|
923
|
+
|
|
924
|
+
def _get_all_specs_for_pk(table: str, pk: Any) -> List[Dict[str, Any]]:
|
|
925
|
+
"""Get all specs for a given table/pk (for legacy multi-field specs)."""
|
|
926
|
+
specs = []
|
|
927
|
+
for allowed in allowed_changes:
|
|
928
|
+
if (
|
|
929
|
+
allowed["table"] == table
|
|
930
|
+
and str(allowed.get("pk")) == str(pk)
|
|
931
|
+
):
|
|
932
|
+
specs.append(allowed)
|
|
933
|
+
return specs
|
|
934
|
+
|
|
935
|
+
def _validate_insert_row(
|
|
936
|
+
table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
|
|
937
|
+
) -> Optional[str]:
|
|
938
|
+
"""Validate an inserted row against specs. Returns error message or None."""
|
|
939
|
+
# Check for type: "insert" spec with fields
|
|
940
|
+
for spec in specs:
|
|
941
|
+
if spec.get("type") == "insert":
|
|
942
|
+
fields_spec = spec.get("fields")
|
|
943
|
+
if fields_spec is not None:
|
|
944
|
+
# Validate each field
|
|
945
|
+
spec_map = _parse_fields_spec(fields_spec)
|
|
946
|
+
for field_name, field_value in row_data.items():
|
|
947
|
+
if field_name == "rowid":
|
|
948
|
+
continue
|
|
949
|
+
if self.ignore_config.should_ignore_field(table, field_name):
|
|
950
|
+
continue
|
|
951
|
+
if field_name not in spec_map:
|
|
952
|
+
return f"Field '{field_name}' not in insert spec for table '{table}' pk={pk}"
|
|
953
|
+
should_check, expected_value = spec_map[field_name]
|
|
954
|
+
if should_check and not _values_equivalent(expected_value, field_value):
|
|
955
|
+
return (
|
|
956
|
+
f"Insert mismatch in table '{table}' pk={pk}, "
|
|
957
|
+
f"field '{field_name}': expected {repr(expected_value)}, got {repr(field_value)}"
|
|
958
|
+
)
|
|
959
|
+
# type: "insert" found (with or without fields) - allowed
|
|
960
|
+
return None
|
|
961
|
+
|
|
962
|
+
# Check for legacy whole-row spec
|
|
963
|
+
for spec in specs:
|
|
964
|
+
if spec.get("fields") is None and spec.get("after") == "__added__":
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
return f"Unexpected row added in table '{table}': pk={pk}"
|
|
968
|
+
|
|
969
|
+
def _validate_delete_row(
|
|
970
|
+
table: str, pk: Any, row_data: Dict[str, Any], specs: List[Dict[str, Any]]
|
|
971
|
+
) -> Optional[str]:
|
|
972
|
+
"""Validate a deleted row against specs. Returns error message or None."""
|
|
973
|
+
# Check for type: "delete" spec with optional fields
|
|
974
|
+
for spec in specs:
|
|
975
|
+
if spec.get("type") == "delete":
|
|
976
|
+
fields_spec = spec.get("fields")
|
|
977
|
+
if fields_spec is not None:
|
|
978
|
+
# Validate each field against the deleted row
|
|
979
|
+
spec_map = _parse_fields_spec(fields_spec)
|
|
980
|
+
for field_name, (should_check, expected_value) in spec_map.items():
|
|
981
|
+
if field_name not in row_data:
|
|
982
|
+
return f"Field '{field_name}' in delete spec not found in row for table '{table}' pk={pk}"
|
|
983
|
+
if should_check and not _values_equivalent(expected_value, row_data[field_name]):
|
|
984
|
+
return (
|
|
985
|
+
f"Delete mismatch in table '{table}' pk={pk}, "
|
|
986
|
+
f"field '{field_name}': expected {repr(expected_value)}, got {repr(row_data[field_name])}"
|
|
987
|
+
)
|
|
988
|
+
# type: "delete" found (with or without fields) - allowed
|
|
989
|
+
return None
|
|
990
|
+
|
|
991
|
+
# Check for legacy whole-row spec
|
|
992
|
+
for spec in specs:
|
|
993
|
+
if spec.get("fields") is None and spec.get("after") == "__removed__":
|
|
994
|
+
return None
|
|
995
|
+
|
|
996
|
+
return f"Unexpected row removed from table '{table}': pk={pk}"
|
|
997
|
+
|
|
998
|
+
def _validate_modify_row(
|
|
999
|
+
table: str,
|
|
1000
|
+
pk: Any,
|
|
1001
|
+
before_row: Dict[str, Any],
|
|
1002
|
+
after_row: Dict[str, Any],
|
|
1003
|
+
specs: List[Dict[str, Any]],
|
|
1004
|
+
) -> Optional[str]:
|
|
1005
|
+
"""Validate a modified row against specs. Returns error message or None."""
|
|
1006
|
+
# Collect actual changes
|
|
1007
|
+
changed_fields: Dict[str, Dict[str, Any]] = {}
|
|
1008
|
+
for field in set(before_row.keys()) | set(after_row.keys()):
|
|
1009
|
+
if self.ignore_config.should_ignore_field(table, field):
|
|
1010
|
+
continue
|
|
1011
|
+
before_val = before_row.get(field)
|
|
1012
|
+
after_val = after_row.get(field)
|
|
1013
|
+
if not _values_equivalent(before_val, after_val):
|
|
1014
|
+
changed_fields[field] = {"before": before_val, "after": after_val}
|
|
1015
|
+
|
|
1016
|
+
if not changed_fields:
|
|
1017
|
+
return None # No changes
|
|
1018
|
+
|
|
1019
|
+
# Check for type: "modify" spec with resulting_fields
|
|
1020
|
+
for spec in specs:
|
|
1021
|
+
if spec.get("type") == "modify":
|
|
1022
|
+
resulting_fields = spec.get("resulting_fields")
|
|
1023
|
+
if resulting_fields is not None:
|
|
1024
|
+
# Validate no_other_changes is provided
|
|
1025
|
+
if "no_other_changes" not in spec:
|
|
1026
|
+
raise ValueError(
|
|
1027
|
+
f"Modify spec for table '{table}' pk={pk} "
|
|
1028
|
+
f"has 'resulting_fields' but missing required 'no_other_changes' field."
|
|
1029
|
+
)
|
|
1030
|
+
no_other_changes = spec["no_other_changes"]
|
|
1031
|
+
if not isinstance(no_other_changes, bool):
|
|
1032
|
+
raise ValueError(
|
|
1033
|
+
f"Modify spec for table '{table}' pk={pk} "
|
|
1034
|
+
f"'no_other_changes' must be boolean, got {type(no_other_changes).__name__}"
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
spec_map = _parse_fields_spec(resulting_fields)
|
|
1038
|
+
|
|
1039
|
+
# Validate changed fields
|
|
1040
|
+
for field_name, vals in changed_fields.items():
|
|
1041
|
+
after_val = vals["after"]
|
|
1042
|
+
if field_name not in spec_map:
|
|
1043
|
+
if no_other_changes:
|
|
1044
|
+
return (
|
|
1045
|
+
f"Unexpected field change in table '{table}' pk={pk}: "
|
|
1046
|
+
f"field '{field_name}' not in resulting_fields"
|
|
1047
|
+
)
|
|
1048
|
+
# no_other_changes=False: ignore this field
|
|
1049
|
+
else:
|
|
1050
|
+
should_check, expected_value = spec_map[field_name]
|
|
1051
|
+
if should_check and not _values_equivalent(expected_value, after_val):
|
|
1052
|
+
return (
|
|
1053
|
+
f"Modify mismatch in table '{table}' pk={pk}, "
|
|
1054
|
+
f"field '{field_name}': expected {repr(expected_value)}, got {repr(after_val)}"
|
|
1055
|
+
)
|
|
1056
|
+
return None # Validation passed
|
|
1057
|
+
else:
|
|
1058
|
+
# type: "modify" without resulting_fields - allow any modification
|
|
1059
|
+
return None
|
|
1060
|
+
|
|
1061
|
+
# Check for legacy single-field specs
|
|
1062
|
+
for field_name, vals in changed_fields.items():
|
|
1063
|
+
after_val = vals["after"]
|
|
1064
|
+
field_allowed = False
|
|
1065
|
+
for spec in specs:
|
|
1066
|
+
if (
|
|
1067
|
+
spec.get("field") == field_name
|
|
1068
|
+
and _values_equivalent(spec.get("after"), after_val)
|
|
1069
|
+
):
|
|
1070
|
+
field_allowed = True
|
|
1071
|
+
break
|
|
1072
|
+
if not field_allowed:
|
|
1073
|
+
return (
|
|
1074
|
+
f"Unexpected change in table '{table}' pk={pk}, "
|
|
1075
|
+
f"field '{field_name}': {repr(vals['before'])} -> {repr(after_val)}"
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
return None
|
|
1079
|
+
|
|
1080
|
+
# Group allowed changes by table
|
|
1081
|
+
changes_by_table: Dict[str, List[Dict[str, Any]]] = {}
|
|
1082
|
+
for change in allowed_changes:
|
|
1083
|
+
table = change["table"]
|
|
1084
|
+
if table not in changes_by_table:
|
|
1085
|
+
changes_by_table[table] = []
|
|
1086
|
+
changes_by_table[table].append(change)
|
|
1087
|
+
|
|
1088
|
+
errors: List[Exception] = []
|
|
1089
|
+
|
|
1090
|
+
# Async function to check a single row
|
|
1091
|
+
async def check_row(
|
|
1092
|
+
table: str,
|
|
1093
|
+
pk: Any,
|
|
1094
|
+
pk_columns: List[str],
|
|
1095
|
+
):
|
|
1096
|
+
try:
|
|
1097
|
+
# Build WHERE clause for this PK
|
|
1098
|
+
where_sql = self._build_pk_where_clause(pk_columns, pk)
|
|
1099
|
+
|
|
1100
|
+
# Query before snapshot
|
|
1101
|
+
before_query = f"SELECT * FROM {table} WHERE {where_sql}"
|
|
1102
|
+
before_response = await self.before.resource.query(before_query)
|
|
1103
|
+
before_row = (
|
|
1104
|
+
dict(zip(before_response.columns, before_response.rows[0]))
|
|
1105
|
+
if before_response.rows
|
|
1106
|
+
else None
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# Query after snapshot
|
|
1110
|
+
after_response = await self.after.resource.query(before_query)
|
|
1111
|
+
after_row = (
|
|
1112
|
+
dict(zip(after_response.columns, after_response.rows[0]))
|
|
1113
|
+
if after_response.rows
|
|
1114
|
+
else None
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
# Get all specs for this table/pk
|
|
1118
|
+
specs = _get_all_specs_for_pk(table, pk)
|
|
1119
|
+
|
|
1120
|
+
# Check changes for this row
|
|
1121
|
+
if before_row and after_row:
|
|
1122
|
+
# Modified row
|
|
1123
|
+
error = _validate_modify_row(table, pk, before_row, after_row, specs)
|
|
1124
|
+
if error:
|
|
1125
|
+
errors.append(AssertionError(error))
|
|
1126
|
+
elif not before_row and after_row:
|
|
1127
|
+
# Added row
|
|
1128
|
+
error = _validate_insert_row(table, pk, after_row, specs)
|
|
1129
|
+
if error:
|
|
1130
|
+
errors.append(AssertionError(error))
|
|
1131
|
+
elif before_row and not after_row:
|
|
1132
|
+
# Removed row
|
|
1133
|
+
error = _validate_delete_row(table, pk, before_row, specs)
|
|
1134
|
+
if error:
|
|
1135
|
+
errors.append(AssertionError(error))
|
|
1136
|
+
|
|
1137
|
+
except Exception as e:
|
|
1138
|
+
errors.append(e)
|
|
1139
|
+
|
|
1140
|
+
# Prepare all row checks
|
|
1141
|
+
row_tasks = []
|
|
1142
|
+
for table, table_changes in changes_by_table.items():
|
|
1143
|
+
if self.ignore_config.should_ignore_table(table):
|
|
1144
|
+
continue
|
|
1145
|
+
|
|
1146
|
+
# Get primary key columns once per table
|
|
1147
|
+
pk_columns = self._get_primary_key_columns(table)
|
|
1148
|
+
|
|
1149
|
+
# Extract unique PKs to check
|
|
1150
|
+
pks_to_check = {change["pk"] for change in table_changes}
|
|
1151
|
+
|
|
1152
|
+
for pk in pks_to_check:
|
|
1153
|
+
row_tasks.append(check_row(table, pk, pk_columns))
|
|
1154
|
+
|
|
1155
|
+
# Execute row checks concurrently
|
|
1156
|
+
if row_tasks:
|
|
1157
|
+
await asyncio.gather(*row_tasks)
|
|
1158
|
+
|
|
1159
|
+
# Check for errors from row checks
|
|
1160
|
+
if errors:
|
|
1161
|
+
raise errors[0]
|
|
1162
|
+
|
|
1163
|
+
# Now check tables not mentioned in allowed_changes to ensure no changes
|
|
1164
|
+
all_tables = set(await self.before.tables()) | set(await self.after.tables())
|
|
1165
|
+
tables_to_verify = []
|
|
1166
|
+
|
|
1167
|
+
for table in all_tables:
|
|
1168
|
+
if (
|
|
1169
|
+
table not in changes_by_table
|
|
1170
|
+
and not self.ignore_config.should_ignore_table(table)
|
|
1171
|
+
):
|
|
1172
|
+
tables_to_verify.append(table)
|
|
1173
|
+
|
|
1174
|
+
# Async function to verify no changes in a table
|
|
1175
|
+
async def verify_no_changes(table: str):
|
|
1176
|
+
try:
|
|
1177
|
+
# For tables with no allowed changes, just check row counts
|
|
1178
|
+
before_count_response = await self.before.resource.query(
|
|
1179
|
+
f"SELECT COUNT(*) FROM {table}"
|
|
1180
|
+
)
|
|
1181
|
+
before_count = (
|
|
1182
|
+
before_count_response.rows[0][0]
|
|
1183
|
+
if before_count_response.rows
|
|
1184
|
+
else 0
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
after_count_response = await self.after.resource.query(
|
|
1188
|
+
f"SELECT COUNT(*) FROM {table}"
|
|
1189
|
+
)
|
|
1190
|
+
after_count = (
|
|
1191
|
+
after_count_response.rows[0][0] if after_count_response.rows else 0
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
if before_count != after_count:
|
|
1195
|
+
error_msg = (
|
|
1196
|
+
f"Unexpected change in table '{table}': "
|
|
1197
|
+
f"row count changed from {before_count} to {after_count}"
|
|
1198
|
+
)
|
|
1199
|
+
errors.append(AssertionError(error_msg))
|
|
1200
|
+
except Exception as e:
|
|
1201
|
+
errors.append(e)
|
|
1202
|
+
|
|
1203
|
+
# Execute table verification concurrently
|
|
1204
|
+
if tables_to_verify:
|
|
1205
|
+
verify_tasks = [verify_no_changes(table) for table in tables_to_verify]
|
|
1206
|
+
await asyncio.gather(*verify_tasks)
|
|
1207
|
+
|
|
1208
|
+
# Final error check
|
|
1209
|
+
if errors:
|
|
1210
|
+
raise errors[0]
|
|
1211
|
+
|
|
1212
|
+
return self
|
|
1213
|
+
|
|
894
1214
|
async def _validate_diff_against_allowed_changes_v2(
|
|
895
1215
|
self, diff: Dict[str, Any], allowed_changes: List[Dict[str, Any]]
|
|
896
1216
|
):
|
|
@@ -1417,6 +1737,10 @@ class AsyncSnapshotDiff:
|
|
|
1417
1737
|
This version supports field-level specifications for added/removed rows,
|
|
1418
1738
|
allowing users to specify expected field values instead of just whole-row specs.
|
|
1419
1739
|
"""
|
|
1740
|
+
# Special case: empty allowed_changes means no changes should have occurred
|
|
1741
|
+
if not allowed_changes:
|
|
1742
|
+
return await self._expect_no_changes()
|
|
1743
|
+
|
|
1420
1744
|
resource = self.after.resource
|
|
1421
1745
|
if resource.client is not None and resource._mode == "http":
|
|
1422
1746
|
api_diff = None
|
|
@@ -1445,9 +1769,13 @@ class AsyncSnapshotDiff:
|
|
|
1445
1769
|
|
|
1446
1770
|
# Validate outside try block so AssertionError propagates
|
|
1447
1771
|
if api_diff is not None:
|
|
1448
|
-
return await self.
|
|
1772
|
+
return await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes)
|
|
1449
1773
|
|
|
1450
|
-
#
|
|
1774
|
+
# For expect_only_v2, we can optimize by only checking the specific rows mentioned
|
|
1775
|
+
if self._can_use_targeted_queries(allowed_changes):
|
|
1776
|
+
return await self._expect_only_targeted_v2(allowed_changes)
|
|
1777
|
+
|
|
1778
|
+
# Fall back to full diff for complex cases
|
|
1451
1779
|
diff = await self._collect()
|
|
1452
1780
|
return await self._validate_diff_against_allowed_changes_v2(
|
|
1453
1781
|
diff, allowed_changes
|