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 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