intent-base-sdk 0.4.0__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.
@@ -0,0 +1,54 @@
1
+ # Rust
2
+ /target
3
+ **/target
4
+ # Cargo.lock is committed for reproducible application builds
5
+
6
+ # Node
7
+ node_modules
8
+ dist
9
+ .pnpm-store
10
+ *.tsbuildinfo
11
+
12
+ # Tauri
13
+ apps/desktop/src-tauri/target
14
+ apps/desktop/src-tauri/gen
15
+
16
+ # Runtime data (local only)
17
+ runtime/*
18
+ !runtime/.gitkeep
19
+
20
+ # Validation evidence (local only)
21
+ reports/*
22
+ !reports/.gitkeep
23
+ !reports/samples/
24
+ !reports/samples/**
25
+
26
+ # Internal validation (artifacts local; scripts + ci config committed for monorepo)
27
+ validation/*
28
+ !validation/scripts/
29
+ !validation/scripts/**
30
+ !validation/ci/
31
+ !validation/ci/**
32
+ validation/artifacts/
33
+ validation/**/node_modules/
34
+
35
+ # Env
36
+ .env
37
+
38
+ # Local tooling bootstrap (vendored pnpm, icon generator, etc.)
39
+ .tooling
40
+
41
+ # Python
42
+ __pycache__/
43
+ *.py[cod]
44
+ .venv
45
+ *.egg-info
46
+
47
+ # Chromium patch island (local only, not published)
48
+ # Kept in monorepo at chromium/src/intentbase — do not commit to the public repo.
49
+ kernel/
50
+
51
+ # Editor / OS
52
+ .DS_Store
53
+ .idea
54
+ .vscode
@@ -0,0 +1,11 @@
1
+ # Changelog — intent-base-sdk
2
+
3
+ ## 0.4.0 — 2026-07-06
4
+
5
+ - First public PyPI release as `intent-base-sdk` (import: `intent_base`).
6
+ - Sync and async `launch_profile` over Profile Service + Playwright CDP.
7
+ - Typed exceptions mirroring profile-service error codes.
8
+
9
+ ## 0.1.0
10
+
11
+ - Monorepo editable-install preview (`intent-base` package name).
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: intent-base-sdk
3
+ Version: 0.4.0
4
+ Summary: intent-base Python SDK: launch isolated Profiles via the local Profile Service and drive them with Playwright over CDP.
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: httpx>=0.27
8
+ Provides-Extra: dev
9
+ Requires-Dist: build>=1.2; extra == 'dev'
10
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Requires-Dist: respx>=0.21; extra == 'dev'
13
+ Requires-Dist: twine>=5; extra == 'dev'
14
+ Provides-Extra: playwright
15
+ Requires-Dist: playwright>=1.45; extra == 'playwright'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # intent-base-sdk
19
+
20
+ Python SDK for [intent-base](https://github.com/YOUR_ORG/intent-base): launch an
21
+ isolated Profile through the local Profile Service and drive it with Playwright
22
+ over CDP. The SDK never launches Chromium itself — only the Profile Service
23
+ does.
24
+
25
+ ## Install (PyPI)
26
+
27
+ Requires Profile Service running (default `http://127.0.0.1:17390`).
28
+
29
+ ```bash
30
+ pip install intent-base-sdk==0.4.0
31
+ pip install "intent-base-sdk[playwright]==0.4.0"
32
+ playwright install chromium
33
+ ```
34
+
35
+ The base package only needs `httpx`. Playwright is an optional extra.
36
+
37
+ ## Configuration
38
+
39
+ | Option | Default | Environment variable |
40
+ | ------------- | --------------------------- | --------------------------- |
41
+ | `service_url` | `http://127.0.0.1:17390` | `INTENT_BASE_SERVICE_URL` |
42
+ | `timeout_ms` | `30000` | `INTENT_BASE_TIMEOUT_MS` |
43
+
44
+ ## Sync usage
45
+
46
+ ```python
47
+ from intent_base import launch_profile
48
+
49
+ result = launch_profile("profile_123")
50
+ result.page.goto("https://example.com")
51
+ print(result.page.title())
52
+ result.close()
53
+ ```
54
+
55
+ Pass `auto_stop=True` to also stop the Profile on `close()`.
56
+
57
+ ## Async usage
58
+
59
+ ```python
60
+ import asyncio
61
+ from intent_base import launch_profile_async
62
+
63
+ async def main():
64
+ result = await launch_profile_async("profile_123")
65
+ await result.page.goto("https://example.com")
66
+ print(await result.page.title())
67
+ await result.aclose()
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ ## Errors
73
+
74
+ ```python
75
+ from intent_base import launch_profile, ProfileAlreadyRunningError, IntentBaseError
76
+
77
+ try:
78
+ result = launch_profile("profile_123")
79
+ except ProfileAlreadyRunningError as err:
80
+ print("already running:", err.code)
81
+ except IntentBaseError as err:
82
+ print(err.code, err.message, err.status)
83
+ ```
84
+
85
+ ## Monorepo development
86
+
87
+ ```bash
88
+ pip install -e "sdks/python[dev,playwright]"
89
+ python -m pytest sdks/python/tests
90
+ ```
91
+
92
+ See [docs/PUBLISH.md](../../docs/PUBLISH.md) for PyPI release steps.
@@ -0,0 +1,75 @@
1
+ # intent-base-sdk
2
+
3
+ Python SDK for [intent-base](https://github.com/YOUR_ORG/intent-base): launch an
4
+ isolated Profile through the local Profile Service and drive it with Playwright
5
+ over CDP. The SDK never launches Chromium itself — only the Profile Service
6
+ does.
7
+
8
+ ## Install (PyPI)
9
+
10
+ Requires Profile Service running (default `http://127.0.0.1:17390`).
11
+
12
+ ```bash
13
+ pip install intent-base-sdk==0.4.0
14
+ pip install "intent-base-sdk[playwright]==0.4.0"
15
+ playwright install chromium
16
+ ```
17
+
18
+ The base package only needs `httpx`. Playwright is an optional extra.
19
+
20
+ ## Configuration
21
+
22
+ | Option | Default | Environment variable |
23
+ | ------------- | --------------------------- | --------------------------- |
24
+ | `service_url` | `http://127.0.0.1:17390` | `INTENT_BASE_SERVICE_URL` |
25
+ | `timeout_ms` | `30000` | `INTENT_BASE_TIMEOUT_MS` |
26
+
27
+ ## Sync usage
28
+
29
+ ```python
30
+ from intent_base import launch_profile
31
+
32
+ result = launch_profile("profile_123")
33
+ result.page.goto("https://example.com")
34
+ print(result.page.title())
35
+ result.close()
36
+ ```
37
+
38
+ Pass `auto_stop=True` to also stop the Profile on `close()`.
39
+
40
+ ## Async usage
41
+
42
+ ```python
43
+ import asyncio
44
+ from intent_base import launch_profile_async
45
+
46
+ async def main():
47
+ result = await launch_profile_async("profile_123")
48
+ await result.page.goto("https://example.com")
49
+ print(await result.page.title())
50
+ await result.aclose()
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## Errors
56
+
57
+ ```python
58
+ from intent_base import launch_profile, ProfileAlreadyRunningError, IntentBaseError
59
+
60
+ try:
61
+ result = launch_profile("profile_123")
62
+ except ProfileAlreadyRunningError as err:
63
+ print("already running:", err.code)
64
+ except IntentBaseError as err:
65
+ print(err.code, err.message, err.status)
66
+ ```
67
+
68
+ ## Monorepo development
69
+
70
+ ```bash
71
+ pip install -e "sdks/python[dev,playwright]"
72
+ python -m pytest sdks/python/tests
73
+ ```
74
+
75
+ See [docs/PUBLISH.md](../../docs/PUBLISH.md) for PyPI release steps.
@@ -0,0 +1,74 @@
1
+ """intent-base Python SDK.
2
+
3
+ Launch an isolated Profile through the local Profile Service and drive it with
4
+ Playwright over CDP::
5
+
6
+ from intent_base import launch_profile
7
+
8
+ result = launch_profile("profile_123")
9
+ result.page.goto("https://example.com")
10
+ print(result.page.title())
11
+ result.close()
12
+ """
13
+
14
+ from ._http import (
15
+ DEFAULT_SERVICE_URL,
16
+ DEFAULT_TIMEOUT_MS,
17
+ IntentBaseAsyncHttpClient,
18
+ IntentBaseHttpClient,
19
+ )
20
+ from .async_client import (
21
+ AsyncLaunchProfileResult,
22
+ launch_profile_async,
23
+ profile_status_async,
24
+ stop_profile_async,
25
+ )
26
+ from .client import launch_profile, profile_status, stop_profile
27
+ from .errors import (
28
+ CdpConnectError,
29
+ CdpEndpointTimeoutError,
30
+ ChromiumLaunchFailedError,
31
+ ChromiumNotFoundError,
32
+ IntentBaseError,
33
+ ProfileAlreadyRunningError,
34
+ ProfileNotFoundError,
35
+ ProfileValidationError,
36
+ ProxyCheckFailedError,
37
+ RequestTimeoutError,
38
+ ServiceBadResponseError,
39
+ ServiceUnavailableError,
40
+ map_api_error,
41
+ )
42
+ from .models import LaunchProfileResponse, LaunchProfileResult
43
+
44
+ __all__ = [
45
+ "launch_profile",
46
+ "stop_profile",
47
+ "profile_status",
48
+ "launch_profile_async",
49
+ "stop_profile_async",
50
+ "profile_status_async",
51
+ "AsyncLaunchProfileResult",
52
+ "LaunchProfileResponse",
53
+ "LaunchProfileResult",
54
+ "IntentBaseHttpClient",
55
+ "IntentBaseAsyncHttpClient",
56
+ "DEFAULT_SERVICE_URL",
57
+ "DEFAULT_TIMEOUT_MS",
58
+ "map_api_error",
59
+ "IntentBaseError",
60
+ "ProfileNotFoundError",
61
+ "ProfileAlreadyRunningError",
62
+ "ProfileValidationError",
63
+ "ProxyCheckFailedError",
64
+ "ChromiumNotFoundError",
65
+ "ChromiumLaunchFailedError",
66
+ "CdpEndpointTimeoutError",
67
+ "CdpConnectError",
68
+ "ServiceUnavailableError",
69
+ "ServiceBadResponseError",
70
+ "RequestTimeoutError",
71
+ "__version__",
72
+ ]
73
+
74
+ __version__ = "0.4.0"
@@ -0,0 +1,43 @@
1
+ """CDP endpoint normalization helpers.
2
+
3
+ The Profile Service returns a websocket browser endpoint
4
+ (``ws://127.0.0.1:<port>/devtools/browser/<uuid>``). Playwright's
5
+ ``connect_over_cdp`` accepts either that websocket URL or an ``http://`` base
6
+ URL, so normalization is mostly validation plus stripping a trailing slash.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .errors import CdpConnectError
12
+
13
+
14
+ def is_websocket_endpoint(endpoint: str) -> bool:
15
+ return endpoint.startswith("ws://") or endpoint.startswith("wss://")
16
+
17
+
18
+ def normalize_cdp_endpoint(endpoint: str, *, profile_id: str | None = None) -> str:
19
+ """Validate and normalize a CDP endpoint for Playwright/Puppeteer.
20
+
21
+ Raises :class:`CdpConnectError` (code ``CDP_CONNECT_FAILED``) when the
22
+ endpoint is empty or uses an unsupported scheme.
23
+ """
24
+
25
+ value = (endpoint or "").strip()
26
+ if not value:
27
+ raise CdpConnectError(
28
+ "CDP_CONNECT_FAILED",
29
+ "service returned an empty CDP endpoint",
30
+ profile_id=profile_id,
31
+ )
32
+
33
+ if is_websocket_endpoint(value):
34
+ return value
35
+
36
+ if value.startswith("http://") or value.startswith("https://"):
37
+ return value.rstrip("/")
38
+
39
+ raise CdpConnectError(
40
+ "CDP_CONNECT_FAILED",
41
+ f"unsupported CDP endpoint scheme: {value}",
42
+ profile_id=profile_id,
43
+ )
@@ -0,0 +1,294 @@
1
+ """HTTP client for the local Profile Service.
2
+
3
+ This layer has no Playwright dependency: it only speaks the Profile Service
4
+ REST API and maps every failure to a typed :class:`IntentBaseError`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import httpx
14
+
15
+ from .errors import (
16
+ IntentBaseError,
17
+ RequestTimeoutError,
18
+ ServiceBadResponseError,
19
+ ServiceUnavailableError,
20
+ map_api_error,
21
+ )
22
+ from .models import LaunchProfileResponse
23
+
24
+ DEFAULT_SERVICE_URL = "http://127.0.0.1:17390"
25
+ DEFAULT_TIMEOUT_MS = 30000
26
+
27
+
28
+ def resolve_service_url(service_url: Optional[str]) -> str:
29
+ if service_url:
30
+ return service_url.rstrip("/")
31
+ env = os.environ.get("INTENT_BASE_SERVICE_URL")
32
+ if env:
33
+ return env.rstrip("/")
34
+ return DEFAULT_SERVICE_URL
35
+
36
+
37
+ def resolve_timeout_ms(timeout_ms: Optional[int]) -> int:
38
+ if timeout_ms is not None:
39
+ return timeout_ms
40
+ env = os.environ.get("INTENT_BASE_TIMEOUT_MS")
41
+ if env:
42
+ try:
43
+ return int(env)
44
+ except ValueError:
45
+ pass
46
+ return DEFAULT_TIMEOUT_MS
47
+
48
+
49
+ class IntentBaseHttpClient:
50
+ """Synchronous Profile Service client built on httpx."""
51
+
52
+ def __init__(
53
+ self,
54
+ service_url: Optional[str] = None,
55
+ timeout_ms: Optional[int] = None,
56
+ ) -> None:
57
+ self.service_url = resolve_service_url(service_url)
58
+ self.timeout_ms = resolve_timeout_ms(timeout_ms)
59
+
60
+ def _url(self, profile_id: str, suffix: str) -> str:
61
+ return f"{self.service_url}/api/v1/profiles/{profile_id}/{suffix}"
62
+
63
+ def _request(
64
+ self,
65
+ method: str,
66
+ url: str,
67
+ *,
68
+ profile_id: str,
69
+ json_body: Optional[Dict[str, Any]] = None,
70
+ ) -> Any:
71
+ timeout = self.timeout_ms / 1000.0
72
+ try:
73
+ response = httpx.request(method, url, json=json_body, timeout=timeout)
74
+ except httpx.TimeoutException as exc:
75
+ raise RequestTimeoutError(
76
+ "REQUEST_TIMEOUT",
77
+ f"request to {url} timed out after {self.timeout_ms}ms",
78
+ profile_id=profile_id,
79
+ cause=exc,
80
+ ) from exc
81
+ except httpx.HTTPError as exc:
82
+ raise ServiceUnavailableError(
83
+ "SERVICE_UNAVAILABLE",
84
+ f"cannot reach Profile Service at {self.service_url}: {exc}",
85
+ profile_id=profile_id,
86
+ cause=exc,
87
+ ) from exc
88
+
89
+ return self._parse(response, profile_id=profile_id)
90
+
91
+ def _parse(self, response: httpx.Response, *, profile_id: str) -> Any:
92
+ status = response.status_code
93
+ text = response.text
94
+ body: Any = None
95
+ if text:
96
+ try:
97
+ body = json.loads(text)
98
+ except json.JSONDecodeError as exc:
99
+ if 200 <= status < 300:
100
+ raise ServiceBadResponseError(
101
+ "SERVICE_BAD_RESPONSE",
102
+ f"Profile Service returned invalid JSON ({status})",
103
+ status=status,
104
+ profile_id=profile_id,
105
+ cause=exc,
106
+ ) from exc
107
+ body = None
108
+
109
+ if 200 <= status < 300:
110
+ return body
111
+
112
+ if isinstance(body, dict) and "code" in body:
113
+ raise map_api_error(
114
+ str(body.get("code")),
115
+ str(body.get("message", "")),
116
+ status,
117
+ profile_id=profile_id,
118
+ )
119
+
120
+ raise ServiceBadResponseError(
121
+ "SERVICE_BAD_RESPONSE",
122
+ f"Profile Service returned HTTP {status}",
123
+ status=status,
124
+ profile_id=profile_id,
125
+ )
126
+
127
+ def launch_profile(
128
+ self,
129
+ profile_id: str,
130
+ extra_args: Optional[List[str]] = None,
131
+ ) -> LaunchProfileResponse:
132
+ body: Dict[str, Any] = {}
133
+ if extra_args:
134
+ body["extraArgs"] = extra_args
135
+ result = self._request(
136
+ "POST",
137
+ self._url(profile_id, "launch"),
138
+ profile_id=profile_id,
139
+ json_body=body,
140
+ )
141
+ if not isinstance(result, dict):
142
+ raise ServiceBadResponseError(
143
+ "SERVICE_BAD_RESPONSE",
144
+ "launch response was not a JSON object",
145
+ profile_id=profile_id,
146
+ )
147
+ return LaunchProfileResponse.from_api(result)
148
+
149
+ def stop_profile(self, profile_id: str) -> Dict[str, Any]:
150
+ result = self._request(
151
+ "POST",
152
+ self._url(profile_id, "stop"),
153
+ profile_id=profile_id,
154
+ )
155
+ return result if isinstance(result, dict) else {}
156
+
157
+ def status(self, profile_id: str) -> Dict[str, Any]:
158
+ result = self._request(
159
+ "GET",
160
+ self._url(profile_id, "status"),
161
+ profile_id=profile_id,
162
+ )
163
+ return result if isinstance(result, dict) else {}
164
+
165
+
166
+ class IntentBaseAsyncHttpClient:
167
+ """Asynchronous Profile Service client built on httpx.AsyncClient."""
168
+
169
+ def __init__(
170
+ self,
171
+ service_url: Optional[str] = None,
172
+ timeout_ms: Optional[int] = None,
173
+ ) -> None:
174
+ self.service_url = resolve_service_url(service_url)
175
+ self.timeout_ms = resolve_timeout_ms(timeout_ms)
176
+
177
+ def _url(self, profile_id: str, suffix: str) -> str:
178
+ return f"{self.service_url}/api/v1/profiles/{profile_id}/{suffix}"
179
+
180
+ async def _request(
181
+ self,
182
+ method: str,
183
+ url: str,
184
+ *,
185
+ profile_id: str,
186
+ json_body: Optional[Dict[str, Any]] = None,
187
+ ) -> Any:
188
+ timeout = self.timeout_ms / 1000.0
189
+ try:
190
+ async with httpx.AsyncClient(timeout=timeout) as client:
191
+ response = await client.request(method, url, json=json_body)
192
+ except httpx.TimeoutException as exc:
193
+ raise RequestTimeoutError(
194
+ "REQUEST_TIMEOUT",
195
+ f"request to {url} timed out after {self.timeout_ms}ms",
196
+ profile_id=profile_id,
197
+ cause=exc,
198
+ ) from exc
199
+ except httpx.HTTPError as exc:
200
+ raise ServiceUnavailableError(
201
+ "SERVICE_UNAVAILABLE",
202
+ f"cannot reach Profile Service at {self.service_url}: {exc}",
203
+ profile_id=profile_id,
204
+ cause=exc,
205
+ ) from exc
206
+
207
+ return _parse_response(response, profile_id=profile_id)
208
+
209
+ async def launch_profile(
210
+ self,
211
+ profile_id: str,
212
+ extra_args: Optional[List[str]] = None,
213
+ ) -> LaunchProfileResponse:
214
+ body: Dict[str, Any] = {}
215
+ if extra_args:
216
+ body["extraArgs"] = extra_args
217
+ result = await self._request(
218
+ "POST",
219
+ self._url(profile_id, "launch"),
220
+ profile_id=profile_id,
221
+ json_body=body,
222
+ )
223
+ if not isinstance(result, dict):
224
+ raise ServiceBadResponseError(
225
+ "SERVICE_BAD_RESPONSE",
226
+ "launch response was not a JSON object",
227
+ profile_id=profile_id,
228
+ )
229
+ return LaunchProfileResponse.from_api(result)
230
+
231
+ async def stop_profile(self, profile_id: str) -> Dict[str, Any]:
232
+ result = await self._request(
233
+ "POST",
234
+ self._url(profile_id, "stop"),
235
+ profile_id=profile_id,
236
+ )
237
+ return result if isinstance(result, dict) else {}
238
+
239
+ async def status(self, profile_id: str) -> Dict[str, Any]:
240
+ result = await self._request(
241
+ "GET",
242
+ self._url(profile_id, "status"),
243
+ profile_id=profile_id,
244
+ )
245
+ return result if isinstance(result, dict) else {}
246
+
247
+
248
+ def _parse_response(response: httpx.Response, *, profile_id: str) -> Any:
249
+ """Shared response parser for the async client (mirrors the sync logic)."""
250
+ status = response.status_code
251
+ text = response.text
252
+ body: Any = None
253
+ if text:
254
+ try:
255
+ body = json.loads(text)
256
+ except json.JSONDecodeError as exc:
257
+ if 200 <= status < 300:
258
+ raise ServiceBadResponseError(
259
+ "SERVICE_BAD_RESPONSE",
260
+ f"Profile Service returned invalid JSON ({status})",
261
+ status=status,
262
+ profile_id=profile_id,
263
+ cause=exc,
264
+ ) from exc
265
+ body = None
266
+
267
+ if 200 <= status < 300:
268
+ return body
269
+
270
+ if isinstance(body, dict) and "code" in body:
271
+ raise map_api_error(
272
+ str(body.get("code")),
273
+ str(body.get("message", "")),
274
+ status,
275
+ profile_id=profile_id,
276
+ )
277
+
278
+ raise ServiceBadResponseError(
279
+ "SERVICE_BAD_RESPONSE",
280
+ f"Profile Service returned HTTP {status}",
281
+ status=status,
282
+ profile_id=profile_id,
283
+ )
284
+
285
+
286
+ __all__ = [
287
+ "IntentBaseHttpClient",
288
+ "IntentBaseAsyncHttpClient",
289
+ "IntentBaseError",
290
+ "resolve_service_url",
291
+ "resolve_timeout_ms",
292
+ "DEFAULT_SERVICE_URL",
293
+ "DEFAULT_TIMEOUT_MS",
294
+ ]
@@ -0,0 +1,98 @@
1
+ """Public asynchronous API for the intent-base Python SDK.
2
+
3
+ Shares DTOs and error mapping with the sync client; only the Playwright and
4
+ HTTP transport are async. The returned :class:`LaunchProfileResult` exposes a
5
+ coroutine ``aclose()`` plus a synchronous ``close()`` for symmetry.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from ._http import IntentBaseAsyncHttpClient
14
+ from .models import LaunchProfileResponse
15
+ from .playwright import connect_playwright_async
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AsyncLaunchProfileResult:
20
+ browser: Any
21
+ context: Any
22
+ page: Any
23
+ metadata: LaunchProfileResponse
24
+ _driver: Any = None
25
+ _http: Any = None
26
+ _profile_id: str = ""
27
+ _auto_stop: bool = False
28
+
29
+ @property
30
+ def profile_id(self) -> str:
31
+ return self.metadata.profile_id
32
+
33
+ @property
34
+ def cdp_endpoint(self) -> str:
35
+ return self.metadata.cdp_endpoint
36
+
37
+ async def aclose(self) -> None:
38
+ try:
39
+ await self.browser.close()
40
+ finally:
41
+ if self._driver is not None:
42
+ try:
43
+ await self._driver.stop()
44
+ finally:
45
+ if self._auto_stop and self._http is not None:
46
+ try:
47
+ await self._http.stop_profile(self._profile_id)
48
+ except Exception: # noqa: BLE001
49
+ pass
50
+
51
+
52
+ async def launch_profile_async(
53
+ profile_id: str,
54
+ *,
55
+ service_url: Optional[str] = None,
56
+ extra_args: Optional[List[str]] = None,
57
+ timeout_ms: Optional[int] = None,
58
+ auto_stop: bool = False,
59
+ ) -> AsyncLaunchProfileResult:
60
+ """Async variant of :func:`intent_base.launch_profile`."""
61
+
62
+ http = IntentBaseAsyncHttpClient(service_url, timeout_ms)
63
+ metadata = await http.launch_profile(profile_id, extra_args)
64
+
65
+ browser, context, page, driver = await connect_playwright_async(
66
+ metadata.cdp_endpoint, profile_id=profile_id
67
+ )
68
+
69
+ return AsyncLaunchProfileResult(
70
+ browser=browser,
71
+ context=context,
72
+ page=page,
73
+ metadata=metadata,
74
+ _driver=driver,
75
+ _http=http,
76
+ _profile_id=profile_id,
77
+ _auto_stop=auto_stop,
78
+ )
79
+
80
+
81
+ async def stop_profile_async(
82
+ profile_id: str,
83
+ *,
84
+ service_url: Optional[str] = None,
85
+ timeout_ms: Optional[int] = None,
86
+ ) -> Dict[str, Any]:
87
+ return await IntentBaseAsyncHttpClient(service_url, timeout_ms).stop_profile(
88
+ profile_id
89
+ )
90
+
91
+
92
+ async def profile_status_async(
93
+ profile_id: str,
94
+ *,
95
+ service_url: Optional[str] = None,
96
+ timeout_ms: Optional[int] = None,
97
+ ) -> Dict[str, Any]:
98
+ return await IntentBaseAsyncHttpClient(service_url, timeout_ms).status(profile_id)
@@ -0,0 +1,79 @@
1
+ """Public synchronous API for the intent-base Python SDK.
2
+
3
+ ``launch_profile`` asks the local Profile Service to start the Profile's
4
+ Chromium, then connects Playwright to the returned CDP endpoint. The SDK never
5
+ launches Chromium itself and never passes isolation-critical browser args.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from ._http import IntentBaseHttpClient
13
+ from .errors import CdpConnectError
14
+ from .models import LaunchProfileResponse, LaunchProfileResult
15
+ from .playwright import connect_playwright
16
+
17
+
18
+ def launch_profile(
19
+ profile_id: str,
20
+ *,
21
+ service_url: Optional[str] = None,
22
+ extra_args: Optional[List[str]] = None,
23
+ timeout_ms: Optional[int] = None,
24
+ auto_stop: bool = False,
25
+ ) -> LaunchProfileResult:
26
+ """Launch ``profile_id`` and return a connected Playwright handle.
27
+
28
+ Set ``auto_stop=True`` to also stop the Profile on ``result.close()``;
29
+ otherwise only the CDP connection and Playwright driver are torn down and
30
+ the Profile keeps running.
31
+ """
32
+
33
+ http = IntentBaseHttpClient(service_url, timeout_ms)
34
+ metadata: LaunchProfileResponse = http.launch_profile(profile_id, extra_args)
35
+
36
+ browser, context, page, driver = connect_playwright(
37
+ metadata.cdp_endpoint, profile_id=profile_id
38
+ )
39
+
40
+ def _close() -> None:
41
+ try:
42
+ browser.close()
43
+ finally:
44
+ try:
45
+ driver.stop()
46
+ finally:
47
+ if auto_stop:
48
+ try:
49
+ http.stop_profile(profile_id)
50
+ except Exception: # noqa: BLE001 - best effort on close
51
+ pass
52
+
53
+ return LaunchProfileResult(
54
+ browser=browser,
55
+ context=context,
56
+ page=page,
57
+ metadata=metadata,
58
+ _closer=_close,
59
+ )
60
+
61
+
62
+ def stop_profile(
63
+ profile_id: str,
64
+ *,
65
+ service_url: Optional[str] = None,
66
+ timeout_ms: Optional[int] = None,
67
+ ) -> Dict[str, Any]:
68
+ """Stop a running Profile via the Profile Service."""
69
+ return IntentBaseHttpClient(service_url, timeout_ms).stop_profile(profile_id)
70
+
71
+
72
+ def profile_status(
73
+ profile_id: str,
74
+ *,
75
+ service_url: Optional[str] = None,
76
+ timeout_ms: Optional[int] = None,
77
+ ) -> Dict[str, Any]:
78
+ """Fetch the current lifecycle status of a Profile."""
79
+ return IntentBaseHttpClient(service_url, timeout_ms).status(profile_id)
@@ -0,0 +1,132 @@
1
+ """Typed errors for the intent-base Python SDK.
2
+
3
+ Every failure raised by the SDK is an :class:`IntentBaseError` (or subclass)
4
+ that preserves the machine-readable ``code`` from the Profile Service so callers
5
+ can branch on it without parsing messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ class IntentBaseError(Exception):
14
+ """Base class for every error raised by the SDK."""
15
+
16
+ def __init__(
17
+ self,
18
+ code: str,
19
+ message: str,
20
+ *,
21
+ status: Optional[int] = None,
22
+ profile_id: Optional[str] = None,
23
+ cause: Optional[BaseException] = None,
24
+ details: Optional[Dict[str, Any]] = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.code = code
28
+ self.message = message
29
+ self.status = status
30
+ self.profile_id = profile_id
31
+ self.cause = cause
32
+ self.details = details or {}
33
+ if cause is not None:
34
+ self.__cause__ = cause
35
+
36
+ def __repr__(self) -> str: # pragma: no cover - debug aid
37
+ return (
38
+ f"{type(self).__name__}(code={self.code!r}, message={self.message!r}, "
39
+ f"status={self.status!r}, profile_id={self.profile_id!r})"
40
+ )
41
+
42
+
43
+ class ProfileNotFoundError(IntentBaseError):
44
+ pass
45
+
46
+
47
+ class ProfileAlreadyRunningError(IntentBaseError):
48
+ pass
49
+
50
+
51
+ class ProfileValidationError(IntentBaseError):
52
+ pass
53
+
54
+
55
+ class ProxyCheckFailedError(IntentBaseError):
56
+ pass
57
+
58
+
59
+ class ChromiumNotFoundError(IntentBaseError):
60
+ pass
61
+
62
+
63
+ class ChromiumLaunchFailedError(IntentBaseError):
64
+ pass
65
+
66
+
67
+ class CdpEndpointTimeoutError(IntentBaseError):
68
+ pass
69
+
70
+
71
+ class CdpConnectError(IntentBaseError):
72
+ pass
73
+
74
+
75
+ class ServiceUnavailableError(IntentBaseError):
76
+ pass
77
+
78
+
79
+ class ServiceBadResponseError(IntentBaseError):
80
+ pass
81
+
82
+
83
+ class RequestTimeoutError(IntentBaseError):
84
+ pass
85
+
86
+
87
+ # Service-side codes (see services/profile-service/src/error.rs) plus the
88
+ # SDK-only transport codes the document mandates.
89
+ _CODE_TO_CLASS: Dict[str, type] = {
90
+ "PROFILE_NOT_FOUND": ProfileNotFoundError,
91
+ "PROFILE_ALREADY_RUNNING": ProfileAlreadyRunningError,
92
+ "PROFILE_RUNNING": ProfileAlreadyRunningError,
93
+ "PROFILE_VALIDATION": ProfileValidationError,
94
+ "BROWSER_ARG_FORBIDDEN": ProfileValidationError,
95
+ "PROXY_CHECK_FAILED": ProxyCheckFailedError,
96
+ "PROXY_CONFIG_MISSING": ProxyCheckFailedError,
97
+ "CHROMIUM_NOT_FOUND": ChromiumNotFoundError,
98
+ "CHROMIUM_LAUNCH_FAILED": ChromiumLaunchFailedError,
99
+ "CHROMIUM_EXITED_EARLY": ChromiumLaunchFailedError,
100
+ "CDP_ENDPOINT_TIMEOUT": CdpEndpointTimeoutError,
101
+ "CDP_ENDPOINT_INVALID": CdpConnectError,
102
+ "CDP_CONNECT_FAILED": CdpConnectError,
103
+ "SERVICE_UNAVAILABLE": ServiceUnavailableError,
104
+ "SERVICE_BAD_RESPONSE": ServiceBadResponseError,
105
+ "REQUEST_TIMEOUT": RequestTimeoutError,
106
+ }
107
+
108
+
109
+ def map_api_error(
110
+ code: str,
111
+ message: str,
112
+ status: Optional[int] = None,
113
+ *,
114
+ profile_id: Optional[str] = None,
115
+ cause: Optional[BaseException] = None,
116
+ details: Optional[Dict[str, Any]] = None,
117
+ ) -> IntentBaseError:
118
+ """Build the typed error for a service ``code``.
119
+
120
+ Unknown codes fall back to the base :class:`IntentBaseError` while still
121
+ preserving the original code so callers never lose information.
122
+ """
123
+
124
+ cls = _CODE_TO_CLASS.get(code, IntentBaseError)
125
+ return cls(
126
+ code,
127
+ message,
128
+ status=status,
129
+ profile_id=profile_id,
130
+ cause=cause,
131
+ details=details,
132
+ )
@@ -0,0 +1,67 @@
1
+ """DTOs for the intent-base Python SDK.
2
+
3
+ These mirror the Profile Service API contract defined in
4
+ ``crates/intent-base-types/src/browser.rs``. The HTTP layer speaks camelCase
5
+ on the wire; these dataclasses use snake_case and are built via
6
+ ``LaunchProfileResponse.from_api``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any, Mapping, Optional
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class LaunchProfileResponse:
17
+ """Successful ``POST /api/v1/profiles/:id/launch`` body."""
18
+
19
+ profile_id: str
20
+ status: str
21
+ pid: int
22
+ cdp_endpoint: str
23
+ devtools_port: int
24
+ user_data_path: str
25
+ browser_log_path: str
26
+
27
+ @classmethod
28
+ def from_api(cls, body: Mapping[str, Any]) -> "LaunchProfileResponse":
29
+ return cls(
30
+ profile_id=str(body.get("profileId", "")),
31
+ status=str(body.get("status", "")),
32
+ pid=int(body.get("pid", 0)),
33
+ cdp_endpoint=str(body.get("cdpEndpoint", "")),
34
+ devtools_port=int(body.get("devtoolsPort", 0)),
35
+ user_data_path=str(body.get("userDataPath", "")),
36
+ browser_log_path=str(body.get("browserLogPath", "")),
37
+ )
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class LaunchProfileResult:
42
+ """Handles returned by ``launch_profile``.
43
+
44
+ ``browser`` / ``context`` / ``page`` are Playwright objects (typed as
45
+ ``Any`` so the base SDK does not depend on Playwright). Call ``close()`` to
46
+ disconnect from the browser and stop the Playwright driver; the underlying
47
+ Profile keeps running unless ``auto_stop`` was requested at launch time.
48
+ """
49
+
50
+ browser: Any
51
+ context: Any
52
+ page: Any
53
+ metadata: LaunchProfileResponse
54
+ _closer: Optional[Any] = None
55
+
56
+ @property
57
+ def profile_id(self) -> str:
58
+ return self.metadata.profile_id
59
+
60
+ @property
61
+ def cdp_endpoint(self) -> str:
62
+ return self.metadata.cdp_endpoint
63
+
64
+ def close(self) -> None:
65
+ """Disconnect from the browser and tear down the Playwright driver."""
66
+ if self._closer is not None:
67
+ self._closer()
@@ -0,0 +1,101 @@
1
+ """Playwright connection helpers.
2
+
3
+ Playwright is an optional dependency. Importing this module without Playwright
4
+ installed raises a clear :class:`CdpConnectError` telling the user to install
5
+ it, rather than a bare ``ModuleNotFoundError``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Tuple
11
+
12
+ from ._cdp import normalize_cdp_endpoint
13
+ from .errors import CdpConnectError
14
+
15
+
16
+ def _require_sync_playwright(profile_id: str | None = None):
17
+ try:
18
+ from playwright.sync_api import sync_playwright # type: ignore
19
+ except ImportError as exc: # pragma: no cover - import guard
20
+ raise CdpConnectError(
21
+ "CDP_CONNECT_FAILED",
22
+ "Playwright is required for launch_profile. Install it with "
23
+ "`pip install \"intent-base[playwright]\"` then run `playwright install chromium`.",
24
+ profile_id=profile_id,
25
+ cause=exc,
26
+ ) from exc
27
+ return sync_playwright
28
+
29
+
30
+ def _require_async_playwright(profile_id: str | None = None):
31
+ try:
32
+ from playwright.async_api import async_playwright # type: ignore
33
+ except ImportError as exc: # pragma: no cover - import guard
34
+ raise CdpConnectError(
35
+ "CDP_CONNECT_FAILED",
36
+ "Playwright is required for launch_profile_async. Install it with "
37
+ "`pip install \"intent-base[playwright]\"` then run `playwright install chromium`.",
38
+ profile_id=profile_id,
39
+ cause=exc,
40
+ ) from exc
41
+ return async_playwright
42
+
43
+
44
+ def connect_playwright(
45
+ cdp_endpoint: str,
46
+ *,
47
+ profile_id: str | None = None,
48
+ ) -> Tuple[Any, Any, Any, Any]:
49
+ """Connect to an already-launched browser over CDP (sync API).
50
+
51
+ Returns ``(browser, context, page, driver)`` where ``driver`` is the
52
+ started Playwright instance the caller must stop on close.
53
+ """
54
+
55
+ endpoint = normalize_cdp_endpoint(cdp_endpoint, profile_id=profile_id)
56
+ sync_playwright = _require_sync_playwright(profile_id)
57
+
58
+ driver = sync_playwright().start()
59
+ try:
60
+ browser = driver.chromium.connect_over_cdp(endpoint)
61
+ except Exception as exc: # noqa: BLE001 - normalize any connect failure
62
+ driver.stop()
63
+ raise CdpConnectError(
64
+ "CDP_CONNECT_FAILED",
65
+ f"failed to connect to CDP endpoint: {exc}",
66
+ profile_id=profile_id,
67
+ cause=exc,
68
+ ) from exc
69
+
70
+ context = browser.contexts[0] if browser.contexts else browser.new_context()
71
+ page = context.pages[0] if context.pages else context.new_page()
72
+ return browser, context, page, driver
73
+
74
+
75
+ async def connect_playwright_async(
76
+ cdp_endpoint: str,
77
+ *,
78
+ profile_id: str | None = None,
79
+ ) -> Tuple[Any, Any, Any, Any]:
80
+ """Connect to an already-launched browser over CDP (async API)."""
81
+
82
+ endpoint = normalize_cdp_endpoint(cdp_endpoint, profile_id=profile_id)
83
+ async_playwright = _require_async_playwright(profile_id)
84
+
85
+ driver = await async_playwright().start()
86
+ try:
87
+ browser = await driver.chromium.connect_over_cdp(endpoint)
88
+ except Exception as exc: # noqa: BLE001
89
+ await driver.stop()
90
+ raise CdpConnectError(
91
+ "CDP_CONNECT_FAILED",
92
+ f"failed to connect to CDP endpoint: {exc}",
93
+ profile_id=profile_id,
94
+ cause=exc,
95
+ ) from exc
96
+
97
+ contexts = browser.contexts
98
+ context = contexts[0] if contexts else await browser.new_context()
99
+ pages = context.pages
100
+ page = pages[0] if pages else await context.new_page()
101
+ return browser, context, page, driver
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "intent-base-sdk"
3
+ version = "0.4.0"
4
+ description = "intent-base Python SDK: launch isolated Profiles via the local Profile Service and drive them with Playwright over CDP."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "httpx>=0.27",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ playwright = ["playwright>=1.45"]
14
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "build>=1.2", "twine>=5"]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["intent_base"]
22
+
23
+ [tool.pytest.ini_options]
24
+ asyncio_mode = "auto"
25
+ testpaths = ["tests"]
@@ -0,0 +1,33 @@
1
+ import pytest
2
+
3
+ from intent_base._cdp import is_websocket_endpoint, normalize_cdp_endpoint
4
+ from intent_base.errors import CdpConnectError
5
+
6
+
7
+ def test_normalize_websocket_endpoint_passthrough():
8
+ ws = "ws://127.0.0.1:52341/devtools/browser/abc-123"
9
+ assert normalize_cdp_endpoint(ws) == ws
10
+ assert is_websocket_endpoint(ws) is True
11
+
12
+
13
+ def test_normalize_wss_endpoint_passthrough():
14
+ wss = "wss://example.com:443/devtools/browser/x"
15
+ assert normalize_cdp_endpoint(wss) == wss
16
+
17
+
18
+ def test_normalize_http_endpoint_strips_trailing_slash():
19
+ assert normalize_cdp_endpoint("http://127.0.0.1:9222/") == "http://127.0.0.1:9222"
20
+ assert is_websocket_endpoint("http://127.0.0.1:9222") is False
21
+
22
+
23
+ def test_empty_endpoint_raises_cdp_connect_failed():
24
+ with pytest.raises(CdpConnectError) as exc:
25
+ normalize_cdp_endpoint("", profile_id="p1")
26
+ assert exc.value.code == "CDP_CONNECT_FAILED"
27
+ assert exc.value.profile_id == "p1"
28
+
29
+
30
+ def test_unsupported_scheme_raises():
31
+ with pytest.raises(CdpConnectError) as exc:
32
+ normalize_cdp_endpoint("tcp://127.0.0.1:9222")
33
+ assert exc.value.code == "CDP_CONNECT_FAILED"
@@ -0,0 +1,113 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from intent_base._http import IntentBaseHttpClient
6
+ from intent_base.errors import (
7
+ ProfileAlreadyRunningError,
8
+ ProfileNotFoundError,
9
+ RequestTimeoutError,
10
+ ServiceBadResponseError,
11
+ ServiceUnavailableError,
12
+ )
13
+
14
+ SERVICE = "http://127.0.0.1:17390"
15
+ LAUNCH_URL = f"{SERVICE}/api/v1/profiles/profile_123/launch"
16
+
17
+ LAUNCH_BODY = {
18
+ "profileId": "profile_123",
19
+ "status": "running",
20
+ "pid": 4242,
21
+ "cdpEndpoint": "ws://127.0.0.1:52341/devtools/browser/abc-123",
22
+ "devtoolsPort": 52341,
23
+ "userDataPath": "C:/data/profiles/profile_123/user_data",
24
+ "browserLogPath": "C:/data/profiles/profile_123/logs/browser.log",
25
+ }
26
+
27
+
28
+ @respx.mock
29
+ def test_launch_profile_calls_correct_url_and_parses():
30
+ route = respx.post(LAUNCH_URL).mock(
31
+ return_value=httpx.Response(200, json=LAUNCH_BODY)
32
+ )
33
+ client = IntentBaseHttpClient(SERVICE)
34
+ meta = client.launch_profile("profile_123", extra_args=["--lang=en-US"])
35
+
36
+ assert route.called
37
+ request = route.calls.last.request
38
+ import json
39
+
40
+ assert json.loads(request.content) == {"extraArgs": ["--lang=en-US"]}
41
+ assert meta.profile_id == "profile_123"
42
+ assert meta.pid == 4242
43
+ assert meta.cdp_endpoint == "ws://127.0.0.1:52341/devtools/browser/abc-123"
44
+ assert meta.devtools_port == 52341
45
+
46
+
47
+ @respx.mock
48
+ def test_non_2xx_maps_to_typed_error():
49
+ respx.post(LAUNCH_URL).mock(
50
+ return_value=httpx.Response(
51
+ 409, json={"code": "PROFILE_ALREADY_RUNNING", "message": "running"}
52
+ )
53
+ )
54
+ with pytest.raises(ProfileAlreadyRunningError) as exc:
55
+ IntentBaseHttpClient(SERVICE).launch_profile("profile_123")
56
+ assert exc.value.code == "PROFILE_ALREADY_RUNNING"
57
+ assert exc.value.status == 409
58
+
59
+
60
+ @respx.mock
61
+ def test_not_found_maps_to_profile_not_found():
62
+ respx.post(LAUNCH_URL).mock(
63
+ return_value=httpx.Response(
64
+ 404, json={"code": "PROFILE_NOT_FOUND", "message": "nope"}
65
+ )
66
+ )
67
+ with pytest.raises(ProfileNotFoundError):
68
+ IntentBaseHttpClient(SERVICE).launch_profile("profile_123")
69
+
70
+
71
+ @respx.mock
72
+ def test_connection_error_maps_to_service_unavailable():
73
+ respx.post(LAUNCH_URL).mock(side_effect=httpx.ConnectError("refused"))
74
+ with pytest.raises(ServiceUnavailableError) as exc:
75
+ IntentBaseHttpClient(SERVICE).launch_profile("profile_123")
76
+ assert exc.value.code == "SERVICE_UNAVAILABLE"
77
+
78
+
79
+ @respx.mock
80
+ def test_timeout_maps_to_request_timeout():
81
+ respx.post(LAUNCH_URL).mock(side_effect=httpx.ReadTimeout("slow"))
82
+ with pytest.raises(RequestTimeoutError) as exc:
83
+ IntentBaseHttpClient(SERVICE).launch_profile("profile_123")
84
+ assert exc.value.code == "REQUEST_TIMEOUT"
85
+
86
+
87
+ @respx.mock
88
+ def test_invalid_json_2xx_maps_to_bad_response():
89
+ respx.post(LAUNCH_URL).mock(
90
+ return_value=httpx.Response(200, content=b"not json")
91
+ )
92
+ with pytest.raises(ServiceBadResponseError):
93
+ IntentBaseHttpClient(SERVICE).launch_profile("profile_123")
94
+
95
+
96
+ @respx.mock
97
+ def test_error_status_without_body_maps_to_bad_response():
98
+ respx.post(LAUNCH_URL).mock(return_value=httpx.Response(500))
99
+ with pytest.raises(ServiceBadResponseError) as exc:
100
+ IntentBaseHttpClient(SERVICE).launch_profile("profile_123")
101
+ assert exc.value.status == 500
102
+
103
+
104
+ def test_service_url_resolution_from_env(monkeypatch):
105
+ monkeypatch.setenv("INTENT_BASE_SERVICE_URL", "http://10.0.0.1:9999/")
106
+ client = IntentBaseHttpClient()
107
+ assert client.service_url == "http://10.0.0.1:9999"
108
+
109
+
110
+ def test_timeout_resolution_from_env(monkeypatch):
111
+ monkeypatch.setenv("INTENT_BASE_TIMEOUT_MS", "5000")
112
+ client = IntentBaseHttpClient()
113
+ assert client.timeout_ms == 5000
@@ -0,0 +1,41 @@
1
+ from intent_base.errors import (
2
+ CdpConnectError,
3
+ IntentBaseError,
4
+ ProfileAlreadyRunningError,
5
+ ProfileNotFoundError,
6
+ ProxyCheckFailedError,
7
+ ServiceUnavailableError,
8
+ map_api_error,
9
+ )
10
+
11
+
12
+ def test_map_known_codes_to_typed_errors():
13
+ assert isinstance(map_api_error("PROFILE_NOT_FOUND", "x"), ProfileNotFoundError)
14
+ assert isinstance(
15
+ map_api_error("PROFILE_ALREADY_RUNNING", "x"), ProfileAlreadyRunningError
16
+ )
17
+ assert isinstance(map_api_error("PROXY_CHECK_FAILED", "x"), ProxyCheckFailedError)
18
+ assert isinstance(map_api_error("CDP_CONNECT_FAILED", "x"), CdpConnectError)
19
+ assert isinstance(
20
+ map_api_error("SERVICE_UNAVAILABLE", "x"), ServiceUnavailableError
21
+ )
22
+
23
+
24
+ def test_profile_running_alias_maps_to_already_running():
25
+ assert isinstance(
26
+ map_api_error("PROFILE_RUNNING", "x"), ProfileAlreadyRunningError
27
+ )
28
+
29
+
30
+ def test_unknown_code_falls_back_to_base_but_preserves_code():
31
+ err = map_api_error("SOME_NEW_CODE", "boom", 500, profile_id="p1")
32
+ assert isinstance(err, IntentBaseError)
33
+ assert err.code == "SOME_NEW_CODE"
34
+ assert err.message == "boom"
35
+ assert err.status == 500
36
+ assert err.profile_id == "p1"
37
+
38
+
39
+ def test_error_str_is_message():
40
+ err = map_api_error("PROFILE_NOT_FOUND", "missing profile")
41
+ assert str(err) == "missing profile"