pybroswer 0.3.1__py3-none-any.whl

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.
pybroswer/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Python browser for PyDroid. HTTP engine in-app; optional remote Chromium."""
2
+
3
+ from pybroswer.client import Browser
4
+ from pybroswer.exceptions import ActionFailed, ApiError, EngineError, PyBroswerError, SessionNotFound
5
+ from pybroswer.models import (
6
+ DevicePreset,
7
+ FrameInfo,
8
+ FrameTarget,
9
+ Health,
10
+ InspectResult,
11
+ LogEntry,
12
+ SessionInfo,
13
+ Snapshot,
14
+ UploadFile,
15
+ Viewport,
16
+ )
17
+ from pybroswer.session import Session
18
+
19
+ __all__ = [
20
+ "ActionFailed",
21
+ "ApiError",
22
+ "Browser",
23
+ "DevicePreset",
24
+ "EngineError",
25
+ "FrameInfo",
26
+ "FrameTarget",
27
+ "Health",
28
+ "InspectResult",
29
+ "LogEntry",
30
+ "PyBroswerError",
31
+ "Session",
32
+ "SessionInfo",
33
+ "SessionNotFound",
34
+ "Snapshot",
35
+ "UploadFile",
36
+ "Viewport",
37
+ "__version__",
38
+ ]
39
+
40
+ __version__ = "0.3.1"
pybroswer/__main__.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from pybroswer.server import serve
6
+
7
+
8
+ def main() -> None:
9
+ parser = argparse.ArgumentParser(prog="pybroswer", description="Local Chromium REST for PyDroid")
10
+ sub = parser.add_subparsers(dest="cmd", required=True)
11
+
12
+ serve_p = sub.add_parser("serve", help="Start Chromium on this PC and expose REST for phones")
13
+ serve_p.add_argument("--host", default="0.0.0.0")
14
+ serve_p.add_argument("--port", type=int, default=5173)
15
+ serve_p.add_argument("--headed", action="store_true", help="Show the browser window")
16
+
17
+ args = parser.parse_args()
18
+ if args.cmd == "serve":
19
+ serve(host=args.host, port=args.port, headless=not args.headed)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
pybroswer/_http.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+ import httpx
6
+
7
+ from pybroswer.exceptions import ActionFailed, ApiError, SessionNotFound
8
+
9
+
10
+ class Transport(Protocol):
11
+ base_url: str
12
+
13
+ def close(self) -> None: ...
14
+
15
+ def request(
16
+ self,
17
+ method: str,
18
+ path: str,
19
+ *,
20
+ json: dict[str, Any] | None = None,
21
+ params: dict[str, Any] | None = None,
22
+ expect_json: bool = True,
23
+ ) -> Any: ...
24
+
25
+
26
+ class HttpTransport:
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ *,
31
+ timeout: float = 60.0,
32
+ headers: dict[str, str] | None = None,
33
+ client: httpx.Client | None = None,
34
+ ) -> None:
35
+ self.base_url = base_url.rstrip("/")
36
+ self._owns = client is None
37
+ self._client = client or httpx.Client(
38
+ base_url=self.base_url,
39
+ timeout=timeout,
40
+ headers={"Accept": "application/json", **(headers or {})},
41
+ follow_redirects=True,
42
+ )
43
+
44
+ def close(self) -> None:
45
+ if self._owns:
46
+ self._client.close()
47
+
48
+ def request(
49
+ self,
50
+ method: str,
51
+ path: str,
52
+ *,
53
+ json: dict[str, Any] | None = None,
54
+ params: dict[str, Any] | None = None,
55
+ expect_json: bool = True,
56
+ ) -> Any:
57
+ response = self._client.request(method, path, json=json, params=params)
58
+ if response.status_code == 404:
59
+ payload = _safe_json(response)
60
+ message = _error_message(payload, response)
61
+ if "session" in message.lower():
62
+ raise SessionNotFound(message, status_code=404, payload=payload)
63
+ raise ApiError(message, status_code=404, payload=payload)
64
+ if response.status_code >= 400:
65
+ payload = _safe_json(response)
66
+ raise ApiError(_error_message(payload, response), status_code=response.status_code, payload=payload)
67
+ if not expect_json:
68
+ return response.content
69
+ if not response.content:
70
+ return {}
71
+ ctype = response.headers.get("content-type", "")
72
+ if "application/json" not in ctype:
73
+ return response.content
74
+ data = response.json()
75
+ if isinstance(data, dict) and data.get("ok") is False and data.get("error"):
76
+ raise ActionFailed(str(data["error"]), status_code=response.status_code, payload=data)
77
+ return data
78
+
79
+
80
+ def _safe_json(response: httpx.Response) -> dict[str, Any]:
81
+ try:
82
+ data = response.json()
83
+ except Exception:
84
+ return {"error": response.text}
85
+ return data if isinstance(data, dict) else {"error": data}
86
+
87
+
88
+ def _error_message(payload: dict[str, Any], response: httpx.Response) -> str:
89
+ return str(payload.get("error") or response.reason_phrase or f"HTTP {response.status_code}")
pybroswer/client.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pybroswer._http import HttpTransport, Transport
6
+ from pybroswer.models import (
7
+ DeviceName,
8
+ DevicePreset,
9
+ Health,
10
+ SessionInfo,
11
+ Snapshot,
12
+ presets_from,
13
+ session_info_from,
14
+ snapshot_from,
15
+ )
16
+ from pybroswer.session import Session
17
+
18
+
19
+ class Browser:
20
+ def __init__(
21
+ self,
22
+ base_url: str | None = None,
23
+ *,
24
+ timeout: float = 60.0,
25
+ headers: dict[str, str] | None = None,
26
+ transport: Transport | None = None,
27
+ engine: Any | None = None,
28
+ ) -> None:
29
+ if transport is not None:
30
+ self._http: Transport = transport
31
+ elif engine is not None:
32
+ from pybroswer.engine import EngineTransport
33
+
34
+ self._http = EngineTransport(engine, owns=False)
35
+ elif base_url:
36
+ self._http = HttpTransport(base_url, timeout=timeout, headers=headers)
37
+ else:
38
+ self._http = _http_engine_transport(max_sessions=5, idle_ms=10 * 60 * 1000, timeout=timeout)
39
+
40
+ @classmethod
41
+ def launch(
42
+ cls,
43
+ *,
44
+ engine: str = "http",
45
+ headless: bool = True,
46
+ max_sessions: int = 5,
47
+ idle_ms: int = 10 * 60 * 1000,
48
+ timeout: float = 30.0,
49
+ executable_path: str | None = None,
50
+ channel: str | None = None,
51
+ ) -> Browser:
52
+ from pybroswer.engine import EngineTransport
53
+
54
+ if engine in {"chromium", "playwright"}:
55
+ from pybroswer.engine import ChromiumEngine
56
+
57
+ chromium = ChromiumEngine(
58
+ headless=headless,
59
+ max_sessions=max_sessions,
60
+ idle_ms=idle_ms,
61
+ executable_path=executable_path,
62
+ channel=channel,
63
+ )
64
+ chromium.start()
65
+ return cls(transport=EngineTransport(chromium, owns=True))
66
+ return cls(transport=_http_engine_transport(max_sessions=max_sessions, idle_ms=idle_ms, timeout=timeout))
67
+
68
+ @classmethod
69
+ def connect(
70
+ cls,
71
+ url: str = "http://127.0.0.1:5173",
72
+ *,
73
+ timeout: float = 60.0,
74
+ headers: dict[str, str] | None = None,
75
+ ) -> Browser:
76
+ return cls(url, timeout=timeout, headers=headers)
77
+
78
+ def close(self) -> None:
79
+ self._http.close()
80
+
81
+ def health(self) -> Health:
82
+ data = self._http.request("GET", "/api/v1/health")
83
+ return Health(
84
+ ok=bool(data.get("ok")),
85
+ service=data.get("service"),
86
+ engine=data.get("engine"),
87
+ sessions=int(data.get("sessions") or 0),
88
+ max_sessions=int(data.get("maxSessions") or 0),
89
+ error=data.get("error"),
90
+ )
91
+
92
+ def ua_presets(self) -> list[DevicePreset]:
93
+ data = self._http.request("GET", "/api/v1/ua")
94
+ return presets_from(data.get("presets"))
95
+
96
+ def sessions(self) -> list[SessionInfo]:
97
+ data = self._http.request("GET", "/api/v1/sessions")
98
+ return [session_info_from(item) for item in data.get("sessions") or []]
99
+
100
+ def create_session(
101
+ self,
102
+ *,
103
+ device: DeviceName | str = "mobile",
104
+ url: str | None = None,
105
+ user_agent: str | None = None,
106
+ width: int | None = None,
107
+ height: int | None = None,
108
+ ) -> Session:
109
+ body: dict[str, Any] = {"device": device}
110
+ if url:
111
+ body["url"] = url
112
+ if user_agent:
113
+ body["userAgent"] = user_agent
114
+ if width is not None:
115
+ body["width"] = width
116
+ if height is not None:
117
+ body["height"] = height
118
+ data = self._http.request("POST", "/api/v1/sessions", json=body)
119
+ return Session(self._http, str(data["id"]))
120
+
121
+ def session(self, session_id: str) -> Session:
122
+ return Session(self._http, session_id)
123
+
124
+ def browse(
125
+ self,
126
+ url: str,
127
+ *,
128
+ device: DeviceName | str = "desktop",
129
+ user_agent: str | None = None,
130
+ width: int | None = None,
131
+ height: int | None = None,
132
+ ) -> Snapshot:
133
+ body: dict[str, Any] = {"url": url, "device": device}
134
+ if user_agent:
135
+ body["userAgent"] = user_agent
136
+ if width is not None:
137
+ body["width"] = width
138
+ if height is not None:
139
+ body["height"] = height
140
+ return snapshot_from(self._http.request("POST", "/api/v1/browse", json=body))
141
+
142
+ def __enter__(self) -> Browser:
143
+ return self
144
+
145
+ def __exit__(self, *args: object) -> None:
146
+ self.close()
147
+
148
+
149
+ def _http_engine_transport(*, max_sessions: int, idle_ms: int, timeout: float) -> Any:
150
+ from pybroswer.engine import EngineTransport
151
+ from pybroswer.http_engine import HttpEngine
152
+
153
+ engine = HttpEngine(max_sessions=max_sessions, idle_ms=idle_ms, timeout=timeout)
154
+ engine.start()
155
+ return EngineTransport(engine, owns=True)
pybroswer/dom.py ADDED
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from html.parser import HTMLParser
5
+ from typing import Any
6
+ from urllib.parse import urljoin
7
+
8
+ VOID = {
9
+ "area",
10
+ "base",
11
+ "br",
12
+ "col",
13
+ "embed",
14
+ "hr",
15
+ "img",
16
+ "input",
17
+ "link",
18
+ "meta",
19
+ "param",
20
+ "source",
21
+ "track",
22
+ "wbr",
23
+ }
24
+
25
+
26
+ class Node:
27
+ __slots__ = ("tag", "attrs", "children", "parent")
28
+
29
+ def __init__(self, tag: str, attrs: dict[str, str] | None = None, parent: Node | None = None) -> None:
30
+ self.tag = tag.lower()
31
+ self.attrs = {k.lower(): v for k, v in (attrs or {}).items()}
32
+ self.children: list[Node | str] = []
33
+ self.parent = parent
34
+
35
+ @property
36
+ def id(self) -> str:
37
+ return self.attrs.get("id", "")
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ return self.attrs.get("name", "")
42
+
43
+ @property
44
+ def type(self) -> str:
45
+ return self.attrs.get("type", "")
46
+
47
+ @property
48
+ def classes(self) -> set[str]:
49
+ return {c for c in self.attrs.get("class", "").split() if c}
50
+
51
+ @property
52
+ def href(self) -> str | None:
53
+ return self.attrs.get("href")
54
+
55
+ @property
56
+ def value(self) -> str:
57
+ if "value" in self.attrs:
58
+ return self.attrs["value"]
59
+ if self.tag == "textarea":
60
+ return self.inner_text
61
+ return ""
62
+
63
+ @value.setter
64
+ def value(self, text: str) -> None:
65
+ self.attrs["value"] = text
66
+ if self.tag in {"textarea", "option"}:
67
+ self.children = [text]
68
+
69
+ @property
70
+ def inner_text(self) -> str:
71
+ parts: list[str] = []
72
+ for child in self.children:
73
+ if isinstance(child, str):
74
+ parts.append(child)
75
+ elif child.tag not in {"script", "style"}:
76
+ parts.append(child.inner_text)
77
+ return re.sub(r"\s+", " ", "".join(parts)).strip()
78
+
79
+ @property
80
+ def inner_html(self) -> str:
81
+ return "".join(_serialize(c) for c in self.children)
82
+
83
+ def descendants(self) -> list[Node]:
84
+ out: list[Node] = []
85
+ for child in self.children:
86
+ if isinstance(child, Node):
87
+ out.append(child)
88
+ out.extend(child.descendants())
89
+ return out
90
+
91
+ def form(self) -> Node | None:
92
+ node: Node | None = self
93
+ while node is not None:
94
+ if node.tag == "form":
95
+ return node
96
+ node = node.parent
97
+ return None
98
+
99
+
100
+ def _serialize(node: Node | str) -> str:
101
+ if isinstance(node, str):
102
+ return node
103
+ attrs = "".join(f' {k}="{v}"' for k, v in node.attrs.items())
104
+ if node.tag in VOID:
105
+ return f"<{node.tag}{attrs}>"
106
+ return f"<{node.tag}{attrs}>{''.join(_serialize(c) for c in node.children)}</{node.tag}>"
107
+
108
+
109
+ class _Parser(HTMLParser):
110
+ def __init__(self) -> None:
111
+ super().__init__(convert_charrefs=True)
112
+ self.root = Node("document")
113
+ self._stack = [self.root]
114
+
115
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
116
+ parent = self._stack[-1]
117
+ node = Node(tag, {k: v or "" for k, v in attrs}, parent)
118
+ parent.children.append(node)
119
+ if tag.lower() not in VOID:
120
+ self._stack.append(node)
121
+
122
+ def handle_endtag(self, tag: str) -> None:
123
+ tag = tag.lower()
124
+ if tag in VOID:
125
+ return
126
+ for i in range(len(self._stack) - 1, 0, -1):
127
+ if self._stack[i].tag == tag:
128
+ del self._stack[i:]
129
+ break
130
+
131
+ def handle_data(self, data: str) -> None:
132
+ if data:
133
+ self._stack[-1].children.append(data)
134
+
135
+
136
+ def parse_html(html: str) -> Node:
137
+ parser = _Parser()
138
+ parser.feed(html or "")
139
+ parser.close()
140
+ return parser.root
141
+
142
+
143
+ def document_element(root: Node) -> Node:
144
+ for child in root.children:
145
+ if isinstance(child, Node) and child.tag == "html":
146
+ return child
147
+ return root
148
+
149
+
150
+ def title_of(root: Node) -> str:
151
+ for node in root.descendants():
152
+ if node.tag == "title":
153
+ return node.inner_text
154
+ return ""
155
+
156
+
157
+ def body_of(root: Node) -> Node | None:
158
+ for node in root.descendants():
159
+ if node.tag == "body":
160
+ return node
161
+ return None
162
+
163
+
164
+ _ATTR_RE = re.compile(
165
+ r"\[([^\s\]=~|^$*]+)\s*(?:([~|^$*]?=)\s*(?:\"([^\"]*)\"|'([^']*)'|([^\]]+)))?\]"
166
+ )
167
+ _SIMPLE_RE = re.compile(r"([a-zA-Z][\w:-]*|\*)|#([\w-]+)|\.([\w-]+)|(\[[^\]]+\])")
168
+
169
+
170
+ def query_all(root: Node, selector: str) -> list[Node]:
171
+ selector = (selector or "").strip()
172
+ if not selector:
173
+ return []
174
+ found: list[Node] = []
175
+ seen: set[int] = set()
176
+ for group in selector.split(","):
177
+ for node in _query_group(root, group.strip()):
178
+ key = id(node)
179
+ if key not in seen:
180
+ seen.add(key)
181
+ found.append(node)
182
+ return found
183
+
184
+
185
+ def query_one(root: Node, selector: str) -> Node | None:
186
+ nodes = query_all(root, selector)
187
+ return nodes[0] if nodes else None
188
+
189
+
190
+ def _query_group(root: Node, selector: str) -> list[Node]:
191
+ parts = re.split(r"\s*(>|\s+)\s*", selector.strip())
192
+ parts = [p for p in parts if p and p.strip()]
193
+ combinators: list[str] = []
194
+ simples: list[str] = []
195
+ expect = "simple"
196
+ for part in parts:
197
+ if part in {">"}:
198
+ combinators.append(part)
199
+ expect = "simple"
200
+ continue
201
+ if expect == "simple":
202
+ if simples:
203
+ combinators.append(" ")
204
+ simples.append(part)
205
+ expect = "combo"
206
+ else:
207
+ combinators.append(" ")
208
+ simples.append(part)
209
+ if not simples:
210
+ return []
211
+ matches = [n for n in _all_nodes(root) if _match_simple(n, simples[0])]
212
+ for combo, simple in zip(combinators, simples[1:]):
213
+ nxt: list[Node] = []
214
+ seen: set[int] = set()
215
+ for node in matches:
216
+ candidates = node.children if combo == ">" else node.descendants()
217
+ for child in candidates:
218
+ if isinstance(child, Node) and _match_simple(child, simple) and id(child) not in seen:
219
+ seen.add(id(child))
220
+ nxt.append(child)
221
+ matches = nxt
222
+ return matches
223
+
224
+
225
+ def _all_nodes(root: Node) -> list[Node]:
226
+ return [root, *root.descendants()]
227
+
228
+
229
+ def _match_simple(node: Node, simple: str) -> bool:
230
+ tag = None
231
+ nid = None
232
+ classes: list[str] = []
233
+ attrs: list[tuple[str, str | None, str | None]] = []
234
+ pos = 0
235
+ for m in _SIMPLE_RE.finditer(simple):
236
+ if m.start() != pos:
237
+ return False
238
+ pos = m.end()
239
+ if m.group(1):
240
+ tag = m.group(1).lower()
241
+ elif m.group(2):
242
+ nid = m.group(2)
243
+ elif m.group(3):
244
+ classes.append(m.group(3))
245
+ else:
246
+ am = _ATTR_RE.match(m.group(4))
247
+ if not am:
248
+ return False
249
+ op = am.group(2)
250
+ val = am.group(3) or am.group(4) or am.group(5)
251
+ if val:
252
+ val = val.strip()
253
+ attrs.append((am.group(1).lower(), op, val))
254
+ if pos != len(simple):
255
+ return False
256
+ if tag and tag != "*" and node.tag != tag:
257
+ return False
258
+ if nid is not None and node.id != nid:
259
+ return False
260
+ if any(cls not in node.classes for cls in classes):
261
+ return False
262
+ for name, op, val in attrs:
263
+ actual = node.attrs.get(name)
264
+ if op is None:
265
+ if actual is None:
266
+ return False
267
+ continue
268
+ if actual is None:
269
+ return False
270
+ if op == "=" and actual != val:
271
+ return False
272
+ if op == "~=" and val not in actual.split():
273
+ return False
274
+ if op == "|=" and actual != val and not actual.startswith(f"{val}-"):
275
+ return False
276
+ if op == "^=" and not actual.startswith(val or ""):
277
+ return False
278
+ if op == "$=" and not actual.endswith(val or ""):
279
+ return False
280
+ if op == "*=" and (val or "") not in actual:
281
+ return False
282
+ return True
283
+
284
+
285
+ def form_fields(form: Node) -> dict[str, str]:
286
+ data: dict[str, str] = {}
287
+ for node in form.descendants():
288
+ if node.tag not in {"input", "textarea", "select"}:
289
+ continue
290
+ name = node.name
291
+ if not name:
292
+ continue
293
+ if node.tag == "input" and node.type in {"submit", "button", "image", "reset", "file"}:
294
+ continue
295
+ if node.tag == "input" and node.type in {"checkbox", "radio"} and "checked" not in node.attrs:
296
+ continue
297
+ if node.tag == "select":
298
+ selected = None
299
+ first = None
300
+ for opt in node.descendants():
301
+ if opt.tag != "option":
302
+ continue
303
+ if first is None:
304
+ first = opt.attrs.get("value", opt.inner_text)
305
+ if "selected" in opt.attrs:
306
+ selected = opt.attrs.get("value", opt.inner_text)
307
+ break
308
+ data[name] = selected if selected is not None else (first or "")
309
+ continue
310
+ data[name] = node.value
311
+ return data
312
+
313
+
314
+ def form_action(form: Node, base: str) -> tuple[str, str]:
315
+ method = (form.attrs.get("method") or "GET").upper()
316
+ action = form.attrs.get("action") or base
317
+ return method, urljoin(base, action)
318
+
319
+
320
+ def frames_of(root: Node, page_url: str) -> list[dict[str, Any]]:
321
+ out = [{"index": 0, "name": "", "url": page_url, "parentUrl": None, "isMain": True}]
322
+ i = 1
323
+ for node in root.descendants():
324
+ if node.tag != "iframe":
325
+ continue
326
+ src = node.attrs.get("src") or ""
327
+ out.append(
328
+ {
329
+ "index": i,
330
+ "name": node.attrs.get("name") or node.id,
331
+ "url": urljoin(page_url, src) if src else "",
332
+ "parentUrl": page_url,
333
+ "isMain": False,
334
+ }
335
+ )
336
+ i += 1
337
+ return out
338
+
339
+
340
+ def inspect_node(node: Node) -> dict[str, Any]:
341
+ return {
342
+ "tag": node.tag.upper(),
343
+ "id": node.id,
344
+ "name": node.name or None,
345
+ "type": node.type or None,
346
+ "value": node.value,
347
+ "text": node.inner_text[:2000],
348
+ "href": node.href,
349
+ "visible": True,
350
+ "rect": {"x": 0, "y": 0, "width": 0, "height": 0, "top": 0, "right": 0, "bottom": 0, "left": 0},
351
+ }