allwright 0.0.1__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,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: allwright
3
+ Version: 0.0.1
4
+ Summary: High-level Python client for the allwright engine
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: grpcio<2,>=1.74.0
8
+ Requires-Dist: grpcio-tools<2,>=1.74.0
9
+ Requires-Dist: protobuf<7,>=5
10
+ Requires-Dist: PyYAML<7,>=6
11
+
12
+ # allwright Python client
13
+
14
+ This folder contains the high-level Python client for the allwright engine.
15
+
16
+ It loads the shared `proto/engine/v1/engine.proto` contract dynamically at runtime and exposes a browser/page API instead of raw gRPC channel setup.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install -e ./python
22
+ ```
23
+
24
+ ## Example
25
+
26
+ ```python
27
+ from allwright import firefox
28
+
29
+ browser = firefox.launch()
30
+ page = browser.page()
31
+ page.goto("https://example.com")
32
+ page.click("h1")
33
+ browser.close()
34
+ ```
@@ -0,0 +1,23 @@
1
+ # allwright Python client
2
+
3
+ This folder contains the high-level Python client for the allwright engine.
4
+
5
+ It loads the shared `proto/engine/v1/engine.proto` contract dynamically at runtime and exposes a browser/page API instead of raw gRPC channel setup.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install -e ./python
11
+ ```
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ from allwright import firefox
17
+
18
+ browser = firefox.launch()
19
+ page = browser.page()
20
+ page.goto("https://example.com")
21
+ page.click("h1")
22
+ browser.close()
23
+ ```
@@ -0,0 +1,69 @@
1
+ from .client import (
2
+ Browser,
3
+ BrowserType,
4
+ ClickResult,
5
+ CommandOptions,
6
+ CountResult,
7
+ ElementResult,
8
+ FillResult,
9
+ HighlightOptions,
10
+ HighlightResult,
11
+ LaunchOptions,
12
+ Locator,
13
+ AllwrightConfig,
14
+ NavigateResult,
15
+ Page,
16
+ PressOptions,
17
+ PressResult,
18
+ ResolveConfigOptions,
19
+ ResolvedConfig,
20
+ TextResult,
21
+ WaitForSelectorOptions,
22
+ WaitForSelectorResult,
23
+ chromium,
24
+ find_config_file,
25
+ firefox,
26
+ launch_chrome,
27
+ launch_configured_browser,
28
+ launch_firefox,
29
+ load_config_file,
30
+ ping,
31
+ resolve_config,
32
+ set_server_addr,
33
+ shutdown,
34
+ )
35
+
36
+ __all__ = [
37
+ "Browser",
38
+ "BrowserType",
39
+ "ClickResult",
40
+ "CommandOptions",
41
+ "CountResult",
42
+ "ElementResult",
43
+ "FillResult",
44
+ "HighlightOptions",
45
+ "HighlightResult",
46
+ "LaunchOptions",
47
+ "Locator",
48
+ "AllwrightConfig",
49
+ "NavigateResult",
50
+ "Page",
51
+ "PressOptions",
52
+ "PressResult",
53
+ "ResolveConfigOptions",
54
+ "ResolvedConfig",
55
+ "TextResult",
56
+ "WaitForSelectorOptions",
57
+ "WaitForSelectorResult",
58
+ "chromium",
59
+ "find_config_file",
60
+ "firefox",
61
+ "launch_chrome",
62
+ "launch_configured_browser",
63
+ "launch_firefox",
64
+ "load_config_file",
65
+ "ping",
66
+ "resolve_config",
67
+ "set_server_addr",
68
+ "shutdown",
69
+ ]
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+
5
+ from ._proto import engine_pb2
6
+ from ._types import AllwrightError, CommandOptions, LaunchOptions
7
+
8
+
9
+ class BrowserType:
10
+ def __init__(self, browser_kind: int = engine_pb2.BROWSER_KIND_CHROMIUM) -> None:
11
+ self._browser_kind = browser_kind
12
+
13
+ def launch(self, options: LaunchOptions | None = None) -> Browser:
14
+ from .client import launch_browser
15
+
16
+ return launch_browser(self._browser_kind, options or LaunchOptions())
17
+
18
+
19
+ class Browser:
20
+ def __init__(
21
+ self,
22
+ runtime: RuntimeClient,
23
+ stream: StreamHandle,
24
+ session_id: str,
25
+ browser_name: str,
26
+ launch_note: str,
27
+ cdp_websocket_url: str,
28
+ user_data_dir: str,
29
+ initial_page: Page,
30
+ ) -> None:
31
+ self._runtime = runtime
32
+ self._stream = stream
33
+ self._lock = threading.Lock()
34
+ self._closed = False
35
+ self._pages: dict[str, Page] = {initial_page.session_id: initial_page}
36
+ self.session_id = session_id
37
+ self.browser_name = browser_name
38
+ self.launch_note = launch_note
39
+ self.cdp_websocket_url = cdp_websocket_url
40
+ self.user_data_dir = user_data_dir
41
+ self._initial_page = initial_page
42
+
43
+ def page(self) -> Page:
44
+ return self._initial_page
45
+
46
+ def initial_page(self) -> Page:
47
+ return self._initial_page
48
+
49
+ def initial_tab(self) -> Page:
50
+ return self._initial_page
51
+
52
+ def pages(self) -> list[Page]:
53
+ return list(self._pages.values())
54
+
55
+ def new_page(self, options: CommandOptions | None = None) -> Page:
56
+ from ._page import Page
57
+ from ._runtime import retry_options
58
+
59
+ with self._lock:
60
+ self._ensure_open()
61
+ command_options = options or CommandOptions()
62
+ self._stream.send(
63
+ engine_pb2.BrowserSessionCommand(
64
+ open_tab=engine_pb2.OpenTabCommand(
65
+ retry_options=retry_options(command_options.timeout_ms),
66
+ ),
67
+ )
68
+ )
69
+
70
+ while True:
71
+ event = self._stream.recv("receive browser session event while opening page")
72
+ match event.WhichOneof("event"):
73
+ case "tab_opened":
74
+ page = Page(
75
+ runtime=self._runtime,
76
+ browser_session_id=self.session_id,
77
+ session_id=event.tab_opened.tab_session_id,
78
+ )
79
+ self._pages[page.session_id] = page
80
+ return page
81
+ case "error":
82
+ raise AllwrightError(
83
+ f"browser session error while opening page: {event.error.message}"
84
+ )
85
+
86
+ def new_tab(self, options: CommandOptions | None = None) -> Page:
87
+ return self.new_page(options)
88
+
89
+ def ping(self, message: str = "ping") -> str:
90
+ with self._lock:
91
+ self._ensure_open()
92
+ self._stream.send(
93
+ engine_pb2.BrowserSessionCommand(
94
+ ping=engine_pb2.SessionPingCommand(message=message),
95
+ )
96
+ )
97
+
98
+ while True:
99
+ event = self._stream.recv("receive browser session event while pinging browser")
100
+ match event.WhichOneof("event"):
101
+ case "pong":
102
+ return event.pong.message
103
+ case "error":
104
+ raise AllwrightError(
105
+ f"browser session error while pinging: {event.error.message}"
106
+ )
107
+
108
+ def close(self) -> None:
109
+ with self._lock:
110
+ if self._closed:
111
+ return
112
+
113
+ self._stream.send(
114
+ engine_pb2.BrowserSessionCommand(
115
+ close=engine_pb2.CloseBrowserSessionCommand(),
116
+ )
117
+ )
118
+
119
+ while True:
120
+ event = self._stream.recv("receive browser session event while closing browser")
121
+ match event.WhichOneof("event"):
122
+ case "closed":
123
+ self._closed = True
124
+ self._stream.close_send()
125
+ return
126
+ case "error":
127
+ raise AllwrightError(
128
+ f"browser session error while closing: {event.error.message}"
129
+ )
130
+
131
+ def _ensure_open(self) -> None:
132
+ if self._closed:
133
+ raise AllwrightError(f"browser session {self.session_id} is closed")
134
+
135
+
136
+ from ._page import Page
137
+ from ._transport import RuntimeClient, StreamHandle
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from ._types import (
10
+ AllwrightConfig,
11
+ AllwrightError,
12
+ LaunchOptions,
13
+ ResolveConfigOptions,
14
+ ResolvedConfig,
15
+ RetryConfig,
16
+ )
17
+
18
+ CONFIG_FILENAMES = (
19
+ "allwright.config.yaml",
20
+ "allwright.config.yml",
21
+ "allwright.config.json",
22
+ ".allwright/config.yaml",
23
+ ".allwright/config.yml",
24
+ ".allwright/config.json",
25
+ )
26
+
27
+
28
+ def find_config_file(start_dir: str | Path | None = None) -> Path | None:
29
+ current_dir = Path(start_dir or Path.cwd()).resolve()
30
+
31
+ while True:
32
+ for filename in CONFIG_FILENAMES:
33
+ candidate = current_dir / filename
34
+ if candidate.is_file():
35
+ return candidate
36
+
37
+ if current_dir.parent == current_dir:
38
+ return None
39
+ current_dir = current_dir.parent
40
+
41
+
42
+ def load_config_file(config_file: str | Path) -> AllwrightConfig:
43
+ resolved = Path(config_file).resolve()
44
+ raw = resolved.read_text(encoding="utf-8")
45
+
46
+ if resolved.suffix.lower() == ".json":
47
+ parsed = json.loads(raw)
48
+ elif resolved.suffix.lower() in {".yaml", ".yml"}:
49
+ parsed = yaml.safe_load(raw) or {}
50
+ else:
51
+ raise AllwrightError(
52
+ f"unsupported allwright config file extension {resolved.suffix or '<none>'} for {resolved}"
53
+ )
54
+
55
+ if not isinstance(parsed, dict):
56
+ raise AllwrightError(f"allwright config {resolved} must contain a top-level object")
57
+
58
+ validate_config_shape(parsed, resolved)
59
+ return AllwrightConfig(
60
+ schema_version=parsed.get("schemaVersion"),
61
+ server=parsed.get("server"),
62
+ browser=parsed.get("browser"),
63
+ expect=retry_config_from_mapping(parsed.get("expect")),
64
+ suites=parsed.get("suites"),
65
+ )
66
+
67
+
68
+ def resolve_config(options: ResolveConfigOptions | None = None) -> ResolvedConfig:
69
+ resolved_options = options or ResolveConfigOptions()
70
+ config_file_path = (
71
+ Path(resolved_options.config_file).resolve()
72
+ if resolved_options.config_file
73
+ else find_config_file(resolved_options.cwd)
74
+ )
75
+ file_config = load_config_file(config_file_path) if config_file_path else AllwrightConfig()
76
+ suite_name = (resolved_options.suite or "").strip() or None
77
+ suite_config = None
78
+
79
+ if suite_name:
80
+ suite_config = (file_config.suites or {}).get(suite_name)
81
+ if suite_config is None:
82
+ source = str(config_file_path) if config_file_path else "the resolved config file"
83
+ raise AllwrightError(
84
+ f'allwright config suite "{suite_name}" was not found in {source}'
85
+ )
86
+
87
+ server_addr = first_non_empty(
88
+ server_addr_from_mapping(suite_config.get("server") if suite_config else None),
89
+ server_addr_from_mapping(file_config.server),
90
+ )
91
+ browser_name = first_non_empty(
92
+ browser_name_from_mapping(suite_config.get("browser") if suite_config else None),
93
+ browser_name_from_mapping(file_config.browser),
94
+ ) or "chromium"
95
+ browser_binary = first_non_empty(
96
+ browser_binary_from_mapping(suite_config.get("browser") if suite_config else None),
97
+ browser_binary_from_mapping(file_config.browser),
98
+ )
99
+ launch_options = merge_launch_options(
100
+ launch_options_from_mapping(file_config.browser),
101
+ launch_options_from_mapping(suite_config.get("browser") if suite_config else None),
102
+ )
103
+ if browser_binary:
104
+ launch_options.browser_binary = browser_binary
105
+ expect = merge_retry_config(
106
+ file_config.expect,
107
+ retry_config_from_mapping(suite_config.get("expect") if suite_config else None),
108
+ )
109
+
110
+ return ResolvedConfig(
111
+ config_file_path=config_file_path,
112
+ suite_name=suite_name,
113
+ server_addr=server_addr,
114
+ browser_name=browser_name,
115
+ browser_binary=browser_binary,
116
+ launch_options=launch_options,
117
+ expect=expect,
118
+ )
119
+
120
+
121
+ def validate_config_shape(config: dict[str, Any], source: Path) -> None:
122
+ schema_version = config.get("schemaVersion")
123
+ if schema_version is not None and schema_version != 1:
124
+ raise AllwrightError(
125
+ f"allwright config {source} has unsupported schemaVersion {schema_version}; expected 1"
126
+ )
127
+
128
+ browser_name = browser_name_from_mapping(config.get("browser"))
129
+ if browser_name and browser_name not in {"chromium", "firefox"}:
130
+ raise AllwrightError(
131
+ f'allwright config {source} has unsupported browser.name "{browser_name}"; use "chromium" or "firefox"'
132
+ )
133
+
134
+
135
+ def browser_name_from_mapping(browser: dict[str, Any] | None) -> str | None:
136
+ if not isinstance(browser, dict):
137
+ return None
138
+ value = browser.get("name")
139
+ return value.strip() if isinstance(value, str) and value.strip() else None
140
+
141
+
142
+ def browser_binary_from_mapping(browser: dict[str, Any] | None) -> str | None:
143
+ if not isinstance(browser, dict):
144
+ return None
145
+ value = browser.get("binary")
146
+ return value.strip() if isinstance(value, str) and value.strip() else None
147
+
148
+
149
+ def server_addr_from_mapping(server: dict[str, Any] | None) -> str | None:
150
+ if not isinstance(server, dict):
151
+ return None
152
+ value = server.get("addr")
153
+ return value.strip() if isinstance(value, str) and value.strip() else None
154
+
155
+
156
+ def launch_options_from_mapping(browser: dict[str, Any] | None) -> LaunchOptions:
157
+ if not isinstance(browser, dict):
158
+ return LaunchOptions()
159
+ launch_options = browser.get("launchOptions")
160
+ if not isinstance(launch_options, dict):
161
+ return LaunchOptions()
162
+ return LaunchOptions(
163
+ browser_binary=launch_options.get("browserBinary"),
164
+ timeout_ms=launch_options.get("timeoutMs"),
165
+ )
166
+
167
+
168
+ def retry_config_from_mapping(value: dict[str, Any] | None) -> RetryConfig | None:
169
+ if not isinstance(value, dict):
170
+ return None
171
+ return RetryConfig(
172
+ timeout_ms=value.get("timeoutMs"),
173
+ interval_ms=value.get("intervalMs"),
174
+ )
175
+
176
+
177
+ def merge_launch_options(base: LaunchOptions, override: LaunchOptions) -> LaunchOptions:
178
+ return LaunchOptions(
179
+ browser_binary=override.browser_binary or base.browser_binary,
180
+ timeout_ms=override.timeout_ms if override.timeout_ms is not None else base.timeout_ms,
181
+ )
182
+
183
+
184
+ def merge_retry_config(base: RetryConfig | None, override: RetryConfig | None) -> RetryConfig:
185
+ return RetryConfig(
186
+ timeout_ms=(
187
+ override.timeout_ms
188
+ if override and override.timeout_ms is not None
189
+ else (base.timeout_ms if base else None)
190
+ ),
191
+ interval_ms=(
192
+ override.interval_ms
193
+ if override and override.interval_ms is not None
194
+ else (base.interval_ms if base else None)
195
+ ),
196
+ )
197
+
198
+
199
+ def first_non_empty(*values: str | None) -> str | None:
200
+ for value in values:
201
+ if isinstance(value, str) and value.strip():
202
+ return value.strip()
203
+ return None
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ._selectors import chain_selector_for_transport
7
+ from ._types import (
8
+ ClickResult,
9
+ CommandOptions,
10
+ CountResult,
11
+ ElementResult,
12
+ FillResult,
13
+ HighlightOptions,
14
+ HighlightResult,
15
+ PressOptions,
16
+ PressResult,
17
+ TextResult,
18
+ WaitForSelectorOptions,
19
+ WaitForSelectorResult,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from ._page import Page
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Locator:
28
+ page: Page
29
+ selector: str
30
+
31
+ def locator(self, selector: str) -> Locator:
32
+ return Locator(page=self.page, selector=chain_selector_for_transport(self.selector, selector))
33
+
34
+ def click(self, options: CommandOptions | None = None) -> ClickResult:
35
+ return self.page.click(self.selector, options)
36
+
37
+ def count(self, options: CommandOptions | None = None) -> CountResult:
38
+ return self.page.count(self.selector, options)
39
+
40
+ def highlight(self, options: HighlightOptions | None = None) -> HighlightResult:
41
+ return self.page.highlight(self.selector, options)
42
+
43
+ def focus(self, options: CommandOptions | None = None) -> ElementResult:
44
+ return self.page.focus(self.selector, options)
45
+
46
+ def fill(self, value: str, options: CommandOptions | None = None) -> FillResult:
47
+ return self.page.fill(self.selector, value, options)
48
+
49
+ def hover(self, options: CommandOptions | None = None) -> ElementResult:
50
+ return self.page.hover(self.selector, options)
51
+
52
+ def press(self, key: str, options: PressOptions | None = None) -> PressResult:
53
+ return self.page.press(self.selector, key, options)
54
+
55
+ def text_content(self, options: CommandOptions | None = None) -> TextResult:
56
+ return self.page.text_content(self.selector, options)
57
+
58
+ def inner_text(self, options: CommandOptions | None = None) -> TextResult:
59
+ return self.page.inner_text(self.selector, options)
60
+
61
+ def wait_for(self, options: WaitForSelectorOptions | None = None) -> WaitForSelectorResult:
62
+ return self.page.wait_for_selector(self.selector, options)