wbb 0.1.0__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.
- wbb/__init__.py +27 -0
- wbb/__main__.py +165 -0
- wbb/_shm.py +89 -0
- wbb/browser.py +673 -0
- wbb/buffer.py +245 -0
- wbb/display.py +162 -0
- wbb/filters.py +218 -0
- wbb/frame.py +89 -0
- wbb-0.1.0.dist-info/METADATA +421 -0
- wbb-0.1.0.dist-info/RECORD +13 -0
- wbb-0.1.0.dist-info/WHEEL +5 -0
- wbb-0.1.0.dist-info/entry_points.txt +2 -0
- wbb-0.1.0.dist-info/top_level.txt +1 -0
wbb/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wbb — WebView Buffer Bridge
|
|
3
|
+
|
|
4
|
+
Renders a live website into an off-screen shared-memory pixel buffer and
|
|
5
|
+
exposes that buffer as a composable, scriptable async Python primitive.
|
|
6
|
+
|
|
7
|
+
Public surface::
|
|
8
|
+
|
|
9
|
+
from wbb import BrowserBridge, FrameBuffer, Frame, DisplayClient
|
|
10
|
+
from wbb import filters
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from wbb.browser import BrowserBridge
|
|
14
|
+
from wbb.buffer import FrameBuffer
|
|
15
|
+
from wbb.frame import Frame
|
|
16
|
+
from wbb.display import DisplayClient
|
|
17
|
+
from wbb import filters
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BrowserBridge",
|
|
21
|
+
"FrameBuffer",
|
|
22
|
+
"Frame",
|
|
23
|
+
"DisplayClient",
|
|
24
|
+
"filters",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
wbb/__main__.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
python -m wbb — thin CLI demonstration consumer of the wbb public API.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
::
|
|
7
|
+
|
|
8
|
+
# Display a page in a window (requires pygame)
|
|
9
|
+
python -m wbb display https://example.com
|
|
10
|
+
|
|
11
|
+
# Run a user scenario script
|
|
12
|
+
python -m wbb script path/to/scenario.py [--url URL] [--width W] [--height H]
|
|
13
|
+
|
|
14
|
+
# Headless: save one screenshot and exit
|
|
15
|
+
python -m wbb screenshot https://example.com output.png
|
|
16
|
+
|
|
17
|
+
The CLI does not implement any scenario logic; it only bootstraps the
|
|
18
|
+
library primitives and hands them to the user script or the built-in demo
|
|
19
|
+
commands.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import importlib.util
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
p = argparse.ArgumentParser(
|
|
33
|
+
prog="python -m wbb",
|
|
34
|
+
description="WebView Buffer Bridge — scriptable headless browser buffer",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument("--width", type=int, default=1280)
|
|
37
|
+
p.add_argument("--height", type=int, default=720)
|
|
38
|
+
p.add_argument("--buffer-name", default="wbb_default")
|
|
39
|
+
|
|
40
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
41
|
+
|
|
42
|
+
# display
|
|
43
|
+
d = sub.add_parser("display", help="Display a URL in a window (requires pygame)")
|
|
44
|
+
d.add_argument("url")
|
|
45
|
+
d.add_argument("--quality", type=int, default=80)
|
|
46
|
+
|
|
47
|
+
# screenshot
|
|
48
|
+
s = sub.add_parser("screenshot", help="Save a screenshot and exit")
|
|
49
|
+
s.add_argument("url")
|
|
50
|
+
s.add_argument("output", help="Output file path (PNG, JPEG, …)")
|
|
51
|
+
s.add_argument("--wait", type=float, default=2.0, help="Seconds to wait after load")
|
|
52
|
+
|
|
53
|
+
# script
|
|
54
|
+
sc = sub.add_parser("script", help="Run a user scenario script")
|
|
55
|
+
sc.add_argument("script_path", metavar="SCRIPT")
|
|
56
|
+
sc.add_argument("--url", default="about:blank")
|
|
57
|
+
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Commands
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _cmd_display(args: argparse.Namespace) -> None:
|
|
67
|
+
from wbb import BrowserBridge, DisplayClient, FrameBuffer # noqa: PLC0415
|
|
68
|
+
|
|
69
|
+
buf = FrameBuffer(args.buffer_name, args.width, args.height)
|
|
70
|
+
try:
|
|
71
|
+
async with BrowserBridge(buf, width=args.width, height=args.height) as br:
|
|
72
|
+
await br.navigate(args.url)
|
|
73
|
+
display = DisplayClient(buf, title=f"wbb — {args.url}")
|
|
74
|
+
await display.run_async()
|
|
75
|
+
finally:
|
|
76
|
+
buf.close()
|
|
77
|
+
buf.unlink()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _cmd_screenshot(args: argparse.Namespace) -> None:
|
|
81
|
+
from wbb import BrowserBridge, FrameBuffer # noqa: PLC0415
|
|
82
|
+
|
|
83
|
+
buf = FrameBuffer(args.buffer_name, args.width, args.height)
|
|
84
|
+
try:
|
|
85
|
+
async with BrowserBridge(buf, width=args.width, height=args.height) as br:
|
|
86
|
+
await br.navigate(args.url)
|
|
87
|
+
# await br.wait_for_load()
|
|
88
|
+
await asyncio.sleep(args.wait)
|
|
89
|
+
frame = await buf.next_frame()
|
|
90
|
+
frame = buf.read()
|
|
91
|
+
frame.save(args.output)
|
|
92
|
+
print(f"Saved {args.width}×{args.height} screenshot → {args.output}")
|
|
93
|
+
del frame # release the zero-copy view before the buffer closes
|
|
94
|
+
finally:
|
|
95
|
+
buf.close()
|
|
96
|
+
buf.unlink()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _cmd_script(args: argparse.Namespace) -> None:
|
|
100
|
+
from wbb import BrowserBridge, DisplayClient, Frame, FrameBuffer # noqa: PLC0415
|
|
101
|
+
from wbb import filters # noqa: PLC0415
|
|
102
|
+
|
|
103
|
+
path = Path(args.script_path).resolve()
|
|
104
|
+
if not path.exists():
|
|
105
|
+
sys.exit(f"Script not found: {path}")
|
|
106
|
+
|
|
107
|
+
# Pre-initialise objects the script can use via wbb_buffer / wbb_browser.
|
|
108
|
+
# wbb_browser is NOT started — the script owns its lifecycle, same as
|
|
109
|
+
# every BrowserBridge in examples/ (`async with BrowserBridge(...) as br`).
|
|
110
|
+
# This avoids launching a Chrome process the script may never touch if
|
|
111
|
+
# it builds its own objects instead, which is equally valid.
|
|
112
|
+
buf = FrameBuffer(args.buffer_name, args.width, args.height)
|
|
113
|
+
br = BrowserBridge(buf, width=args.width, height=args.height)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
spec = importlib.util.spec_from_file_location("_wbb_user_script", path)
|
|
117
|
+
assert spec and spec.loader
|
|
118
|
+
module = importlib.util.module_from_spec(spec)
|
|
119
|
+
|
|
120
|
+
# Inject library primitives into the module namespace
|
|
121
|
+
module.wbb_buffer = buf # type: ignore[attr-defined]
|
|
122
|
+
module.wbb_browser = br # type: ignore[attr-defined]
|
|
123
|
+
module.wbb_url = args.url # type: ignore[attr-defined]
|
|
124
|
+
module.BrowserBridge = BrowserBridge # type: ignore[attr-defined]
|
|
125
|
+
module.FrameBuffer = FrameBuffer # type: ignore[attr-defined]
|
|
126
|
+
module.Frame = Frame # type: ignore[attr-defined]
|
|
127
|
+
module.DisplayClient = DisplayClient # type: ignore[attr-defined]
|
|
128
|
+
module.filters = filters # type: ignore[attr-defined]
|
|
129
|
+
|
|
130
|
+
spec.loader.exec_module(module)
|
|
131
|
+
|
|
132
|
+
# If the script defines an async main(), call it; otherwise it ran at import
|
|
133
|
+
if hasattr(module, "main") and asyncio.iscoroutinefunction(module.main):
|
|
134
|
+
await module.main()
|
|
135
|
+
finally:
|
|
136
|
+
# br.stop() is a no-op (returns immediately) if the script never
|
|
137
|
+
# started it — see BrowserBridge.stop()'s _running guard.
|
|
138
|
+
await br.stop()
|
|
139
|
+
buf.close()
|
|
140
|
+
buf.unlink()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Entry point
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main() -> None:
|
|
149
|
+
parser = _build_parser()
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
|
|
152
|
+
dispatch = {
|
|
153
|
+
"display": _cmd_display,
|
|
154
|
+
"screenshot": _cmd_screenshot,
|
|
155
|
+
"script": _cmd_script,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
asyncio.run(dispatch[args.command](args))
|
|
160
|
+
except KeyboardInterrupt:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
wbb/_shm.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_shm.py — thin cross-platform wrapper around OS shared memory.
|
|
3
|
+
|
|
4
|
+
Uses ``multiprocessing.shared_memory.SharedMemory`` on all platforms so
|
|
5
|
+
the caller does not need to care about POSIX vs Windows SHM APIs. The
|
|
6
|
+
``name`` attribute of ``SharedMemory`` serves as the POSIX segment name
|
|
7
|
+
on Linux/macOS and a named file-mapping name on Windows.
|
|
8
|
+
|
|
9
|
+
``ShmSegment`` exposes a ``buf`` attribute that behaves like a
|
|
10
|
+
``memoryview``/``mmap`` into the segment.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from multiprocessing import resource_tracker
|
|
16
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ShmSegment:
|
|
21
|
+
"""
|
|
22
|
+
Wrapper around :class:`multiprocessing.shared_memory.SharedMemory`.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
name:
|
|
27
|
+
Segment name. Must be unique per system session.
|
|
28
|
+
size:
|
|
29
|
+
Size in bytes. Ignored when *attach* is True.
|
|
30
|
+
attach:
|
|
31
|
+
If True, connect to an existing segment (create=False).
|
|
32
|
+
If False, create a new one (create=True).
|
|
33
|
+
|
|
34
|
+
Notes
|
|
35
|
+
-----
|
|
36
|
+
``buf`` returns the *same* memoryview instance on every access rather
|
|
37
|
+
than re-exporting one from the underlying mmap each time. numpy views
|
|
38
|
+
built with ``np.frombuffer(seg.buf, ...)`` hold an export on whatever
|
|
39
|
+
memoryview they were given; if every ``.buf`` access minted a new one,
|
|
40
|
+
each numpy array would pin a separate export and the mmap could never
|
|
41
|
+
be closed cleanly. Call :meth:`close` (which releases this cached
|
|
42
|
+
view first) rather than reaching into ``_shm`` directly.
|
|
43
|
+
|
|
44
|
+
Resource-tracker note
|
|
45
|
+
----------------------
|
|
46
|
+
``multiprocessing.shared_memory.SharedMemory`` registers every
|
|
47
|
+
segment it opens — including attached, non-owned ones — with the
|
|
48
|
+
current process's resource tracker for crash-safety cleanup. That
|
|
49
|
+
means an *attaching* process (``attach=True``) would otherwise have
|
|
50
|
+
its own tracker unlink the segment on exit even though it never
|
|
51
|
+
created it and even if it only ever calls ``close()`` — racing with,
|
|
52
|
+
or pre-empting, the owning process's own ``unlink()``. We immediately
|
|
53
|
+
unregister right after attaching to opt this process's tracker out
|
|
54
|
+
of cleanup duty for memory it doesn't own. The owning process
|
|
55
|
+
(``attach=False``) is left registered as normal, since stdlib's own
|
|
56
|
+
``SharedMemory.unlink()`` already unregisters correctly when *it*
|
|
57
|
+
calls unlink — see CPython bpo-38119 for the underlying upstream
|
|
58
|
+
wart this works around.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, name: str, size: int, *, attach: bool = False) -> None:
|
|
62
|
+
self._name = name
|
|
63
|
+
self._shm = SharedMemory(name=name, create=not attach, size=size)
|
|
64
|
+
self._buf: Optional[memoryview] = self._shm.buf
|
|
65
|
+
|
|
66
|
+
if attach:
|
|
67
|
+
# Opt this process's tracker out of unlink duty for a segment
|
|
68
|
+
# it merely attached to — see class docstring "Resource-
|
|
69
|
+
# tracker note" above for why this is necessary.
|
|
70
|
+
resource_tracker.unregister(self._shm._name, "shared_memory")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def buf(self) -> memoryview:
|
|
74
|
+
if self._buf is None:
|
|
75
|
+
raise RuntimeError(f"Segment '{self._name}' is closed")
|
|
76
|
+
return self._buf
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
if self._buf is not None:
|
|
80
|
+
self._buf.release()
|
|
81
|
+
self._buf = None
|
|
82
|
+
self._shm.close()
|
|
83
|
+
|
|
84
|
+
def unlink(self) -> None:
|
|
85
|
+
self._shm.unlink()
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def name(self) -> str:
|
|
89
|
+
return self._name
|