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.
- intent_base_sdk-0.4.0/.gitignore +54 -0
- intent_base_sdk-0.4.0/CHANGELOG.md +11 -0
- intent_base_sdk-0.4.0/PKG-INFO +92 -0
- intent_base_sdk-0.4.0/README.md +75 -0
- intent_base_sdk-0.4.0/intent_base/__init__.py +74 -0
- intent_base_sdk-0.4.0/intent_base/_cdp.py +43 -0
- intent_base_sdk-0.4.0/intent_base/_http.py +294 -0
- intent_base_sdk-0.4.0/intent_base/async_client.py +98 -0
- intent_base_sdk-0.4.0/intent_base/client.py +79 -0
- intent_base_sdk-0.4.0/intent_base/errors.py +132 -0
- intent_base_sdk-0.4.0/intent_base/models.py +67 -0
- intent_base_sdk-0.4.0/intent_base/playwright.py +101 -0
- intent_base_sdk-0.4.0/pyproject.toml +25 -0
- intent_base_sdk-0.4.0/tests/test_cdp.py +33 -0
- intent_base_sdk-0.4.0/tests/test_client.py +113 -0
- intent_base_sdk-0.4.0/tests/test_errors.py +41 -0
|
@@ -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"
|