parsek-cdp 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.
Files changed (242) hide show
  1. parsek_cdp/__init__.py +55 -0
  2. parsek_cdp/_logging.py +49 -0
  3. parsek_cdp/cdp/__init__.py +247 -0
  4. parsek_cdp/cdp/accessibility/__init__.py +15 -0
  5. parsek_cdp/cdp/accessibility/events.py +35 -0
  6. parsek_cdp/cdp/accessibility/functions.py +202 -0
  7. parsek_cdp/cdp/accessibility/types.py +235 -0
  8. parsek_cdp/cdp/animation/__init__.py +15 -0
  9. parsek_cdp/cdp/animation/events.py +52 -0
  10. parsek_cdp/cdp/animation/functions.py +140 -0
  11. parsek_cdp/cdp/animation/types.py +113 -0
  12. parsek_cdp/cdp/audits/__init__.py +15 -0
  13. parsek_cdp/cdp/audits/events.py +21 -0
  14. parsek_cdp/cdp/audits/functions.py +95 -0
  15. parsek_cdp/cdp/audits/types.py +1051 -0
  16. parsek_cdp/cdp/autofill/__init__.py +15 -0
  17. parsek_cdp/cdp/autofill/events.py +27 -0
  18. parsek_cdp/cdp/autofill/functions.py +64 -0
  19. parsek_cdp/cdp/autofill/types.py +108 -0
  20. parsek_cdp/cdp/backgroundservice/__init__.py +15 -0
  21. parsek_cdp/cdp/backgroundservice/events.py +40 -0
  22. parsek_cdp/cdp/backgroundservice/functions.py +59 -0
  23. parsek_cdp/cdp/backgroundservice/types.py +63 -0
  24. parsek_cdp/cdp/bluetoothemulation/__init__.py +15 -0
  25. parsek_cdp/cdp/bluetoothemulation/events.py +70 -0
  26. parsek_cdp/cdp/bluetoothemulation/functions.py +247 -0
  27. parsek_cdp/cdp/bluetoothemulation/types.py +120 -0
  28. parsek_cdp/cdp/browser/__init__.py +15 -0
  29. parsek_cdp/cdp/browser/events.py +46 -0
  30. parsek_cdp/cdp/browser/functions.py +350 -0
  31. parsek_cdp/cdp/browser/types.py +158 -0
  32. parsek_cdp/cdp/cachestorage/__init__.py +15 -0
  33. parsek_cdp/cdp/cachestorage/events.py +13 -0
  34. parsek_cdp/cdp/cachestorage/functions.py +126 -0
  35. parsek_cdp/cdp/cachestorage/types.py +89 -0
  36. parsek_cdp/cdp/cast/__init__.py +15 -0
  37. parsek_cdp/cdp/cast/events.py +38 -0
  38. parsek_cdp/cdp/cast/functions.py +77 -0
  39. parsek_cdp/cdp/cast/types.py +23 -0
  40. parsek_cdp/cdp/console/__init__.py +15 -0
  41. parsek_cdp/cdp/console/events.py +22 -0
  42. parsek_cdp/cdp/console/functions.py +36 -0
  43. parsek_cdp/cdp/console/types.py +30 -0
  44. parsek_cdp/cdp/css/__init__.py +15 -0
  45. parsek_cdp/cdp/css/events.py +79 -0
  46. parsek_cdp/cdp/css/functions.py +788 -0
  47. parsek_cdp/cdp/css/types.py +742 -0
  48. parsek_cdp/cdp/debugger/__init__.py +15 -0
  49. parsek_cdp/cdp/debugger/events.py +164 -0
  50. parsek_cdp/cdp/debugger/functions.py +663 -0
  51. parsek_cdp/cdp/debugger/types.py +171 -0
  52. parsek_cdp/cdp/deviceaccess/__init__.py +15 -0
  53. parsek_cdp/cdp/deviceaccess/events.py +30 -0
  54. parsek_cdp/cdp/deviceaccess/functions.py +54 -0
  55. parsek_cdp/cdp/deviceaccess/types.py +28 -0
  56. parsek_cdp/cdp/deviceorientation/__init__.py +15 -0
  57. parsek_cdp/cdp/deviceorientation/events.py +13 -0
  58. parsek_cdp/cdp/deviceorientation/functions.py +35 -0
  59. parsek_cdp/cdp/deviceorientation/types.py +10 -0
  60. parsek_cdp/cdp/dom/__init__.py +15 -0
  61. parsek_cdp/cdp/dom/events.py +228 -0
  62. parsek_cdp/cdp/dom/functions.py +1033 -0
  63. parsek_cdp/cdp/dom/types.py +293 -0
  64. parsek_cdp/cdp/domdebugger/__init__.py +15 -0
  65. parsek_cdp/cdp/domdebugger/events.py +13 -0
  66. parsek_cdp/cdp/domdebugger/functions.py +155 -0
  67. parsek_cdp/cdp/domdebugger/types.py +57 -0
  68. parsek_cdp/cdp/domsnapshot/__init__.py +15 -0
  69. parsek_cdp/cdp/domsnapshot/events.py +13 -0
  70. parsek_cdp/cdp/domsnapshot/functions.py +111 -0
  71. parsek_cdp/cdp/domsnapshot/types.py +312 -0
  72. parsek_cdp/cdp/domstorage/__init__.py +15 -0
  73. parsek_cdp/cdp/domstorage/events.py +60 -0
  74. parsek_cdp/cdp/domstorage/functions.py +79 -0
  75. parsek_cdp/cdp/domstorage/types.py +30 -0
  76. parsek_cdp/cdp/emulation/__init__.py +15 -0
  77. parsek_cdp/cdp/emulation/events.py +17 -0
  78. parsek_cdp/cdp/emulation/functions.py +688 -0
  79. parsek_cdp/cdp/emulation/types.py +305 -0
  80. parsek_cdp/cdp/eventbreakpoints/__init__.py +15 -0
  81. parsek_cdp/cdp/eventbreakpoints/events.py +13 -0
  82. parsek_cdp/cdp/eventbreakpoints/functions.py +41 -0
  83. parsek_cdp/cdp/eventbreakpoints/types.py +10 -0
  84. parsek_cdp/cdp/extensions/__init__.py +15 -0
  85. parsek_cdp/cdp/extensions/events.py +13 -0
  86. parsek_cdp/cdp/extensions/functions.py +118 -0
  87. parsek_cdp/cdp/extensions/types.py +19 -0
  88. parsek_cdp/cdp/fedcm/__init__.py +15 -0
  89. parsek_cdp/cdp/fedcm/events.py +45 -0
  90. parsek_cdp/cdp/fedcm/functions.py +93 -0
  91. parsek_cdp/cdp/fedcm/types.py +72 -0
  92. parsek_cdp/cdp/fetch/__init__.py +15 -0
  93. parsek_cdp/cdp/fetch/events.py +82 -0
  94. parsek_cdp/cdp/fetch/functions.py +215 -0
  95. parsek_cdp/cdp/fetch/types.py +81 -0
  96. parsek_cdp/cdp/filesystem/__init__.py +15 -0
  97. parsek_cdp/cdp/filesystem/events.py +13 -0
  98. parsek_cdp/cdp/filesystem/functions.py +36 -0
  99. parsek_cdp/cdp/filesystem/types.py +54 -0
  100. parsek_cdp/cdp/headlessexperimental/__init__.py +15 -0
  101. parsek_cdp/cdp/headlessexperimental/events.py +13 -0
  102. parsek_cdp/cdp/headlessexperimental/functions.py +77 -0
  103. parsek_cdp/cdp/headlessexperimental/types.py +24 -0
  104. parsek_cdp/cdp/heapprofiler/__init__.py +15 -0
  105. parsek_cdp/cdp/heapprofiler/events.py +65 -0
  106. parsek_cdp/cdp/heapprofiler/functions.py +193 -0
  107. parsek_cdp/cdp/heapprofiler/types.py +57 -0
  108. parsek_cdp/cdp/indexeddb/__init__.py +15 -0
  109. parsek_cdp/cdp/indexeddb/events.py +13 -0
  110. parsek_cdp/cdp/indexeddb/functions.py +232 -0
  111. parsek_cdp/cdp/indexeddb/types.py +120 -0
  112. parsek_cdp/cdp/input/__init__.py +15 -0
  113. parsek_cdp/cdp/input/events.py +25 -0
  114. parsek_cdp/cdp/input/functions.py +361 -0
  115. parsek_cdp/cdp/input/types.py +87 -0
  116. parsek_cdp/cdp/inspector/__init__.py +15 -0
  117. parsek_cdp/cdp/inspector/events.py +41 -0
  118. parsek_cdp/cdp/inspector/functions.py +27 -0
  119. parsek_cdp/cdp/inspector/types.py +10 -0
  120. parsek_cdp/cdp/io/__init__.py +15 -0
  121. parsek_cdp/cdp/io/events.py +13 -0
  122. parsek_cdp/cdp/io/functions.py +77 -0
  123. parsek_cdp/cdp/io/types.py +13 -0
  124. parsek_cdp/cdp/layertree/__init__.py +15 -0
  125. parsek_cdp/cdp/layertree/events.py +36 -0
  126. parsek_cdp/cdp/layertree/functions.py +178 -0
  127. parsek_cdp/cdp/layertree/types.py +104 -0
  128. parsek_cdp/cdp/log/__init__.py +15 -0
  129. parsek_cdp/cdp/log/events.py +22 -0
  130. parsek_cdp/cdp/log/functions.py +54 -0
  131. parsek_cdp/cdp/log/types.py +57 -0
  132. parsek_cdp/cdp/media/__init__.py +15 -0
  133. parsek_cdp/cdp/media/events.py +87 -0
  134. parsek_cdp/cdp/media/functions.py +27 -0
  135. parsek_cdp/cdp/media/types.py +101 -0
  136. parsek_cdp/cdp/memory/__init__.py +15 -0
  137. parsek_cdp/cdp/memory/events.py +13 -0
  138. parsek_cdp/cdp/memory/functions.py +164 -0
  139. parsek_cdp/cdp/memory/types.py +71 -0
  140. parsek_cdp/cdp/mixins/__init__.py +21 -0
  141. parsek_cdp/cdp/mixins/datatype.py +123 -0
  142. parsek_cdp/cdp/mixins/event.py +78 -0
  143. parsek_cdp/cdp/mixins/namespace.py +31 -0
  144. parsek_cdp/cdp/network/__init__.py +15 -0
  145. parsek_cdp/cdp/network/events.py +726 -0
  146. parsek_cdp/cdp/network/functions.py +734 -0
  147. parsek_cdp/cdp/network/types.py +1247 -0
  148. parsek_cdp/cdp/overlay/__init__.py +15 -0
  149. parsek_cdp/cdp/overlay/events.py +54 -0
  150. parsek_cdp/cdp/overlay/functions.py +407 -0
  151. parsek_cdp/cdp/overlay/types.py +328 -0
  152. parsek_cdp/cdp/page/__init__.py +15 -0
  153. parsek_cdp/cdp/page/events.py +416 -0
  154. parsek_cdp/cdp/page/functions.py +1081 -0
  155. parsek_cdp/cdp/page/types.py +1088 -0
  156. parsek_cdp/cdp/performance/__init__.py +15 -0
  157. parsek_cdp/cdp/performance/events.py +24 -0
  158. parsek_cdp/cdp/performance/functions.py +63 -0
  159. parsek_cdp/cdp/performance/types.py +22 -0
  160. parsek_cdp/cdp/performancetimeline/__init__.py +15 -0
  161. parsek_cdp/cdp/performancetimeline/events.py +22 -0
  162. parsek_cdp/cdp/performancetimeline/functions.py +30 -0
  163. parsek_cdp/cdp/performancetimeline/types.py +85 -0
  164. parsek_cdp/cdp/preload/__init__.py +15 -0
  165. parsek_cdp/cdp/preload/events.py +116 -0
  166. parsek_cdp/cdp/preload/functions.py +25 -0
  167. parsek_cdp/cdp/preload/types.py +267 -0
  168. parsek_cdp/cdp/profiler/__init__.py +15 -0
  169. parsek_cdp/cdp/profiler/events.py +64 -0
  170. parsek_cdp/cdp/profiler/functions.py +134 -0
  171. parsek_cdp/cdp/profiler/types.py +105 -0
  172. parsek_cdp/cdp/pwa/__init__.py +15 -0
  173. parsek_cdp/cdp/pwa/events.py +13 -0
  174. parsek_cdp/cdp/pwa/functions.py +196 -0
  175. parsek_cdp/cdp/pwa/types.py +45 -0
  176. parsek_cdp/cdp/runtime/__init__.py +15 -0
  177. parsek_cdp/cdp/runtime/events.py +123 -0
  178. parsek_cdp/cdp/runtime/functions.py +552 -0
  179. parsek_cdp/cdp/runtime/types.py +319 -0
  180. parsek_cdp/cdp/schema/__init__.py +15 -0
  181. parsek_cdp/cdp/schema/events.py +13 -0
  182. parsek_cdp/cdp/schema/functions.py +32 -0
  183. parsek_cdp/cdp/schema/types.py +22 -0
  184. parsek_cdp/cdp/security/__init__.py +15 -0
  185. parsek_cdp/cdp/security/events.py +64 -0
  186. parsek_cdp/cdp/security/functions.py +66 -0
  187. parsek_cdp/cdp/security/types.py +168 -0
  188. parsek_cdp/cdp/serviceworker/__init__.py +15 -0
  189. parsek_cdp/cdp/serviceworker/events.py +43 -0
  190. parsek_cdp/cdp/serviceworker/functions.py +115 -0
  191. parsek_cdp/cdp/serviceworker/types.py +95 -0
  192. parsek_cdp/cdp/storage/__init__.py +15 -0
  193. parsek_cdp/cdp/storage/events.py +288 -0
  194. parsek_cdp/cdp/storage/functions.py +571 -0
  195. parsek_cdp/cdp/storage/types.py +656 -0
  196. parsek_cdp/cdp/systeminfo/__init__.py +15 -0
  197. parsek_cdp/cdp/systeminfo/events.py +13 -0
  198. parsek_cdp/cdp/systeminfo/functions.py +75 -0
  199. parsek_cdp/cdp/systeminfo/types.py +132 -0
  200. parsek_cdp/cdp/target/__init__.py +15 -0
  201. parsek_cdp/cdp/target/events.py +109 -0
  202. parsek_cdp/cdp/target/functions.py +424 -0
  203. parsek_cdp/cdp/target/types.py +83 -0
  204. parsek_cdp/cdp/tethering/__init__.py +15 -0
  205. parsek_cdp/cdp/tethering/events.py +22 -0
  206. parsek_cdp/cdp/tethering/functions.py +35 -0
  207. parsek_cdp/cdp/tethering/types.py +10 -0
  208. parsek_cdp/cdp/tracing/__init__.py +15 -0
  209. parsek_cdp/cdp/tracing/events.py +61 -0
  210. parsek_cdp/cdp/tracing/functions.py +119 -0
  211. parsek_cdp/cdp/tracing/types.py +81 -0
  212. parsek_cdp/cdp/webaudio/__init__.py +15 -0
  213. parsek_cdp/cdp/webaudio/events.py +184 -0
  214. parsek_cdp/cdp/webaudio/functions.py +51 -0
  215. parsek_cdp/cdp/webaudio/types.py +155 -0
  216. parsek_cdp/cdp/webauthn/__init__.py +15 -0
  217. parsek_cdp/cdp/webauthn/events.py +69 -0
  218. parsek_cdp/cdp/webauthn/functions.py +214 -0
  219. parsek_cdp/cdp/webauthn/types.py +96 -0
  220. parsek_cdp/core/__init__.py +18 -0
  221. parsek_cdp/core/browser.py +240 -0
  222. parsek_cdp/core/browser_context.py +58 -0
  223. parsek_cdp/core/element.py +405 -0
  224. parsek_cdp/core/feature.py +376 -0
  225. parsek_cdp/core/frame.py +351 -0
  226. parsek_cdp/core/pagable.py +175 -0
  227. parsek_cdp/core/page.py +163 -0
  228. parsek_cdp/core/target.py +490 -0
  229. parsek_cdp/features/__init__.py +14 -0
  230. parsek_cdp/features/request_listener/__init__.py +256 -0
  231. parsek_cdp/features/request_listener/events.py +48 -0
  232. parsek_cdp/features/request_listener/types.py +235 -0
  233. parsek_cdp/generate.py +642 -0
  234. parsek_cdp/parsek/__init__.py +18 -0
  235. parsek_cdp/parsek/events.py +38 -0
  236. parsek_cdp/parsek/types.py +36 -0
  237. parsek_cdp/py.typed +0 -0
  238. parsek_cdp-0.1.0.dist-info/METADATA +277 -0
  239. parsek_cdp-0.1.0.dist-info/RECORD +242 -0
  240. parsek_cdp-0.1.0.dist-info/WHEEL +5 -0
  241. parsek_cdp-0.1.0.dist-info/licenses/LICENSE +201 -0
  242. parsek_cdp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,376 @@
1
+ """Unified feature framework -- one feature class, two roles (server / client),
2
+ plus the host mixin that composes features onto targets/pages.
3
+
4
+ A feature is written **once** and runs in either role:
5
+
6
+ * **server (producer)** -- enables the CDP ``domains`` it declares, receives their
7
+ raw events, and runs the ``@on(<CdpEvent>)`` handlers. A handler marked
8
+ ``@emit(<ParsekEvent>)`` has its return value sent to the client *and* fed
9
+ through the feature's own reducers, so state is built by the same code paths;
10
+ * **client (view)** -- receives the ``Parsek.*`` events off the wire and runs the
11
+ ``@on(<ParsekEvent>)`` reducers, which build the exact same state.
12
+
13
+ Because the reducers run in both roles, the feature's public API (properties /
14
+ methods reading that state) is identical client- and server-side -- and the
15
+ embedded/local mode just runs the server role in-process.
16
+
17
+ Three building blocks:
18
+
19
+ * ``domains`` -- CDP domains the feature owns; auto-enabled, and their
20
+ raw events are suppressed from client passthrough (the proxy reads
21
+ :meth:`Feature.suppressed_prefixes`);
22
+ * ``@on(Event, ...)`` -- register a method as a handler. A CDP event makes it a
23
+ *producer* (server only); a ``Parsek.*`` event makes it a *reducer* (both);
24
+ * ``@emit(Event)`` -- the handler's return (an event instance, or a list of
25
+ them, or ``None`` to skip) is published: emitted to the client and reduced
26
+ locally;
27
+ * ``@parsek_type(name)``-- declare a wire type whose ``__FIELDS__`` are derived
28
+ from its annotations (no hand-written ``FieldMeta``).
29
+
30
+ :class:`FeatureHost` is the mixin that targets/pages use to compose features
31
+ declaratively (``Page[RequestListener]``) or attach them imperatively
32
+ (:meth:`FeatureHost.attach` / :meth:`FeatureHost.get_feature`). Hosted features
33
+ are created in the host's :attr:`_feature_role` (``"local"`` -- direct to the
34
+ browser -- for a plain :class:`~parsek_cdp.core.page.Page`) and their async
35
+ :meth:`Feature.start` is scheduled, then awaited together by
36
+ :meth:`wait_features_ready`.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import asyncio
42
+ import dataclasses
43
+ import typing
44
+ from enum import Enum
45
+ from typing import (
46
+ Any,
47
+ Callable,
48
+ ClassVar,
49
+ Dict,
50
+ List,
51
+ Optional,
52
+ Tuple,
53
+ Type,
54
+ get_args,
55
+ get_origin,
56
+ )
57
+
58
+ from ..cdp.mixins.datatype import DataType, FieldMeta, register
59
+
60
+ __all__ = ["Feature", "FeatureHost", "parsek_type", "on", "emit"]
61
+
62
+ # python class -> its registered qualified protocol name, for nested-ref resolution.
63
+ _QUALNAME: dict[type, str] = {}
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # custom-type decorator: __FIELDS__ from annotations
68
+ # --------------------------------------------------------------------------- #
69
+
70
+
71
+ def _camel(name: str) -> str:
72
+ head, *tail = name.split("_")
73
+ return head + "".join(p[:1].upper() + p[1:] for p in tail)
74
+
75
+
76
+ def _meta_for(py: str, json: str, hint: Any, optional: bool) -> FieldMeta:
77
+ origin = get_origin(hint)
78
+ if origin is typing.Union: # Optional[X] / Union[X, None]
79
+ args = [a for a in get_args(hint) if a is not type(None)] # noqa: E721
80
+ return _meta_for(py, json, args[0], True)
81
+ if origin in (list, typing.List):
82
+ inner = _meta_for("", "", get_args(hint)[0], False)
83
+ return FieldMeta(py, json, optional, "array", inner=inner)
84
+ if isinstance(hint, type) and issubclass(hint, Enum):
85
+ return FieldMeta(py, json, optional, "enum", ref=_QUALNAME[hint])
86
+ if isinstance(hint, type) and issubclass(hint, DataType):
87
+ return FieldMeta(py, json, optional, "object", ref=_QUALNAME[hint])
88
+ return FieldMeta(py, json, optional, "primitive") # str/int/float/bool/dict/Any
89
+
90
+
91
+ def parsek_type(name: str) -> Callable[[type], type]:
92
+ """Register ``cls`` as a wire type and synthesize ``__FIELDS__`` from its hints.
93
+
94
+ The class must subclass :class:`~parsek_cdp.cdp.mixins.datatype.DataType`.
95
+ ``@dataclass`` is applied if not already. ``snake_case`` fields map to
96
+ ``camelCase`` wire keys; a field is optional iff it has a default. Nested
97
+ enum/``DataType`` references resolve by qualified name, so declare leaf types
98
+ before the types that reference them.
99
+ """
100
+
101
+ def deco(cls: type) -> type:
102
+ if not dataclasses.is_dataclass(cls):
103
+ cls = dataclasses.dataclass(cls)
104
+ register(name)(cls)
105
+ _QUALNAME[cls] = name
106
+ if not cls.__dict__.get("__FIELDS__"):
107
+ hints = typing.get_type_hints(cls)
108
+ fields = tuple(
109
+ _meta_for(
110
+ f.name,
111
+ _camel(f.name),
112
+ hints.get(f.name, f.type),
113
+ f.default is not dataclasses.MISSING
114
+ or f.default_factory is not dataclasses.MISSING,
115
+ )
116
+ for f in dataclasses.fields(cls)
117
+ )
118
+ cls.__FIELDS__ = fields # type: ignore[attr-defined]
119
+ return cls
120
+
121
+ return deco
122
+
123
+
124
+ # --------------------------------------------------------------------------- #
125
+ # handler decorators
126
+ # --------------------------------------------------------------------------- #
127
+
128
+
129
+ def on(*events: type) -> Callable[[Callable], Callable]:
130
+ """Register a method as a handler for one or more events (CDP or Parsek)."""
131
+
132
+ def deco(fn: Callable) -> Callable:
133
+ fn._handles = tuple(getattr(fn, "_handles", ())) + events # type: ignore[attr-defined]
134
+ return fn
135
+
136
+ return deco
137
+
138
+
139
+ def emit(event_cls: type) -> Callable[[Callable], Callable]:
140
+ """Mark a handler whose return value is published as ``event_cls``.
141
+
142
+ Return an instance (or a list of instances) to publish, or ``None`` to skip.
143
+ """
144
+
145
+ def deco(fn: Callable) -> Callable:
146
+ fn._emits = event_cls # type: ignore[attr-defined]
147
+ return fn
148
+
149
+ return deco
150
+
151
+
152
+ # --------------------------------------------------------------------------- #
153
+ # the feature base
154
+ # --------------------------------------------------------------------------- #
155
+
156
+
157
+ class Feature:
158
+ """A behaviour bound to a host, runnable in one of three roles.
159
+
160
+ * ``"local"`` -- direct to the browser, no Parsek server: the producers
161
+ (``@on(<CdpEvent>)``) subscribe to raw CDP on the host target and feed the
162
+ reducers in-process; nothing is sent over a wire. This is what a plain
163
+ :class:`~parsek_cdp.core.page.Page` uses.
164
+ * ``"server"`` -- the proxy feeds raw CDP via :meth:`handle_cdp_event`; a
165
+ produced event is emitted to the client *and* reduced locally (for the
166
+ snapshot). ``host`` exposes ``send_cdp`` / ``emit_parsek``.
167
+ * ``"client"`` -- only the reducers run, fed by ``Parsek.*`` events arriving
168
+ off the wire on the host target.
169
+
170
+ The reducers run in every role, so the public API (state they build) is
171
+ identical regardless of where the feature runs.
172
+ """
173
+
174
+ #: CDP domains this feature owns, as the namespaces imported from
175
+ #: :mod:`parsek_cdp.cdp` -- e.g. ``domains = (Network, Page)``. They are
176
+ #: auto-enabled on the server and their raw events are suppressed from
177
+ #: client passthrough.
178
+ domains: tuple = ()
179
+
180
+ def __init__(self, host: Any, *, role: str = "client") -> None:
181
+ self.host = host
182
+ self.role = role
183
+ #: cdp method -> (event class, bound handler, emits class | None)
184
+ self._producers: dict[str, tuple[type, Callable, Optional[type]]] = {}
185
+ #: parsek method -> list[(event class, bound handler)]
186
+ self._reducers: dict[str, list[tuple[type, Callable]]] = {}
187
+ self._scan()
188
+
189
+ def _scan(self) -> None:
190
+ for name in dir(type(self)):
191
+ func = getattr(type(self), name, None)
192
+ handles = getattr(func, "_handles", ())
193
+ if not handles:
194
+ continue
195
+ bound = getattr(self, name)
196
+ for ev in handles:
197
+ method = ev.EVENT_METHOD
198
+ if method.startswith("Parsek."):
199
+ self._reducers.setdefault(method, []).append((ev, bound))
200
+ else:
201
+ self._producers[method] = (ev, bound, getattr(func, "_emits", None))
202
+
203
+ @classmethod
204
+ def suppressed_prefixes(cls) -> tuple[str, ...]:
205
+ """Raw CDP method prefixes the proxy must not pass through (server)."""
206
+ return tuple(f"{d.DOMAIN}." for d in cls.domains)
207
+
208
+ # -- lifecycle --------------------------------------------------------- #
209
+
210
+ async def start(self) -> None:
211
+ """Wire the feature for its role (enable domains and/or bind handlers)."""
212
+ if self.role == "server":
213
+ for domain in self.domains:
214
+ await self.host.send_cdp(f"{domain.DOMAIN}.enable", {})
215
+ elif self.role == "local":
216
+ # Direct to the browser: enable domains on the target, subscribe the
217
+ # producers to raw CDP, and reduce their output in-process.
218
+ for domain in self.domains:
219
+ await self.host.send(f"{domain.DOMAIN}.enable", {})
220
+ for ev_cls, fn, emits in self._producers.values():
221
+ self.host.on(ev_cls, self._local_producer(fn, emits))
222
+ else: # client
223
+ for handlers in self._reducers.values():
224
+ for ev_cls, fn in handlers:
225
+ self.host.on(ev_cls, fn) # connection dispatch routes Parsek.*
226
+
227
+ # -- server: raw CDP in (fed by the proxy) ----------------------------- #
228
+
229
+ async def handle_cdp_event(
230
+ self, method: str, params: dict, session_id: str
231
+ ) -> None:
232
+ """Feed one raw CDP event to its producer (called by the proxy, server)."""
233
+ entry = self._producers.get(method)
234
+ if entry is None:
235
+ return
236
+ ev_cls, fn, emits = entry
237
+ result = fn(ev_cls.parse(params))
238
+ if asyncio.iscoroutine(result):
239
+ result = await result
240
+ if emits is not None and result is not None:
241
+ events = result if isinstance(result, (list, tuple)) else (result,)
242
+ for event in events:
243
+ await self._publish(event, session_id)
244
+
245
+ async def _publish(self, event: Any, session_id: str) -> None:
246
+ """Emit a produced event to the client and reduce it locally (server)."""
247
+ await self.host.emit_parsek(event.EVENT_METHOD, event.to_json(), session_id)
248
+ self._reduce(event)
249
+
250
+ # -- local: raw CDP in (subscribed directly on the target) ------------- #
251
+
252
+ def _local_producer(self, fn: Callable, emits: Optional[type]) -> Callable:
253
+ """Wrap a producer so its return is reduced in-process (no wire emit)."""
254
+
255
+ def handler(event: Any) -> None:
256
+ result = fn(event)
257
+ if asyncio.iscoroutine(result):
258
+ asyncio.ensure_future(self._reduce_async(result, emits))
259
+ elif emits is not None:
260
+ self._reduce_result(result, emits)
261
+
262
+ return handler
263
+
264
+ async def _reduce_async(self, coro: Any, emits: Optional[type]) -> None:
265
+ if emits is not None:
266
+ self._reduce_result(await coro, emits)
267
+
268
+ def _reduce_result(self, result: Any, emits: Optional[type]) -> None:
269
+ if result is None:
270
+ return
271
+ for event in result if isinstance(result, (list, tuple)) else (result,):
272
+ self._reduce(event)
273
+
274
+ def _reduce(self, event: Any) -> None:
275
+ """Run every reducer registered for ``event`` (builds feature state)."""
276
+ for _ev_cls, fn in self._reducers.get(event.EVENT_METHOD, ()):
277
+ fn(event)
278
+
279
+ # -- snapshot ---------------------------------------------------------- #
280
+
281
+ async def snapshot(self) -> dict:
282
+ """State for this feature's ``get*`` command (override in stateful features)."""
283
+ return {}
284
+
285
+
286
+ # --------------------------------------------------------------------------- #
287
+ # the host mixin
288
+ # --------------------------------------------------------------------------- #
289
+
290
+
291
+ class FeatureHost[T: Feature = Feature]:
292
+ """Mixin giving a class the ``[Feat]`` composition + ``attach``/``get_feature`` API."""
293
+
294
+ #: Role given to features hosted here ("local" = direct to the browser).
295
+ _feature_role: ClassVar[str] = "local"
296
+ #: Feature classes auto-attached on construction (extended by ``cls[Feat]``).
297
+ _feature_classes: ClassVar[Tuple[Type[Feature], ...]] = ()
298
+ # Cache of composed subclasses so ``Cls[Feat] is Cls[Feat]``.
299
+ _composed_cache: ClassVar[Dict[Tuple[type, ...], type]] = {}
300
+
301
+ #: Flipped on by :meth:`wait_features_ready` once the host's connection is
302
+ #: live; until then every ``start()`` is deferred (it would otherwise send
303
+ #: CDP commands before the websocket is open).
304
+ _features_connected: bool = False
305
+
306
+ def _init_features(self) -> None:
307
+ """Instantiate every declared feature. Call once from ``__init__``."""
308
+ self._features: Dict[Type[Feature], Feature] = {}
309
+ self._pending_starts: List = [] # start() coroutines awaiting connect
310
+ self._start_tasks: List[asyncio.Task] = []
311
+ self._features_connected = False
312
+ for feat_cls in self._feature_classes:
313
+ self.attach(feat_cls)
314
+
315
+ def attach[FeatT: Feature](self, feat_cls: Type[FeatT]) -> FeatT:
316
+ """Instantiate ``feat_cls`` for this host's role and schedule its start.
317
+
318
+ Access an attached feature via :meth:`get_feature` (no magic attribute is
319
+ bound on the host).
320
+ """
321
+ feat = feat_cls(self, role=self._feature_role) # type: ignore[arg-type]
322
+ self._features[feat_cls] = feat
323
+ self._schedule_coro(feat.start())
324
+ return feat
325
+
326
+ def _schedule_coro(self, coro) -> None:
327
+ """Start ``coro`` once the host is connected, else defer it.
328
+
329
+ Shared by feature ``start()`` and built-in page setup (e.g. frame
330
+ tracking), so everything that must be enabled is awaited at one point
331
+ (:meth:`wait_features_ready`) -- and never before the websocket is open.
332
+ """
333
+ if not self._features_connected:
334
+ self._pending_starts.append(coro) # not connected yet -- defer
335
+ return
336
+ try:
337
+ loop = asyncio.get_running_loop()
338
+ except RuntimeError:
339
+ self._pending_starts.append(coro) # no loop yet -- defer
340
+ return
341
+ self._start_tasks.append(loop.create_task(coro))
342
+
343
+ async def wait_features_ready(self) -> None:
344
+ """Await every feature's :meth:`Feature.start` (and any deferred coros)."""
345
+ self._features_connected = True
346
+ for coro in self._pending_starts:
347
+ self._start_tasks.append(asyncio.ensure_future(coro))
348
+ self._pending_starts.clear()
349
+ if self._start_tasks:
350
+ await asyncio.gather(*self._start_tasks)
351
+ self._start_tasks.clear()
352
+
353
+ def get_feature[FeatT: Feature](self, feat_cls: Type[FeatT]) -> FeatT:
354
+ """Return the attached ``feat_cls`` instance, attaching it on first use."""
355
+ feat = self._features.get(feat_cls)
356
+ if feat is None:
357
+ feat = self.attach(feat_cls)
358
+ return feat # type: ignore[return-value]
359
+
360
+ def has_feature(self, feat_cls: Type[Feature]) -> bool:
361
+ return feat_cls in self._features
362
+
363
+ def __class_getitem__(cls, item):
364
+ feats = item if isinstance(item, tuple) else (item,)
365
+ if feats and all(isinstance(f, type) and issubclass(f, Feature) for f in feats):
366
+ key = (cls, *feats)
367
+ cached = FeatureHost._composed_cache.get(key)
368
+ if cached is not None:
369
+ return cached
370
+ name = f"{cls.__name__}[{', '.join(f.__name__ for f in feats)}]"
371
+ composed = type(
372
+ name, (cls,), {"_feature_classes": (*cls._feature_classes, *feats)}
373
+ )
374
+ FeatureHost._composed_cache[key] = composed
375
+ return composed
376
+ return super().__class_getitem__(item) # type: ignore[misc]