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,351 @@
1
+ """A single frame -- the mandatory browser abstraction.
2
+
3
+ A :class:`Frame` is self-contained: it binds to the CDP session that owns it
4
+ (:attr:`Frame.target` -- a dedicated session of its own for a cross-origin
5
+ OOPIF, the page's session otherwise) and exposes :meth:`Frame.select` /
6
+ :meth:`Frame.evaluate` and the ``wait_for_*`` helpers, all scoped to that frame.
7
+ Everything routes through :attr:`Frame.cdp` so same- and cross-origin frames
8
+ behave identically.
9
+
10
+ Tracking the *tree* of frames (parent/child links, attach/detach, OOPIF
11
+ discovery) is a separate, optional concern handled by a tree-manager feature --
12
+ a :class:`Frame` never depends on it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from enum import Enum
19
+ from typing import TYPE_CHECKING, List, Optional, Tuple
20
+
21
+ from ..cdp import DOM, Page
22
+ from .target import ProtocolError, Target
23
+
24
+ if TYPE_CHECKING:
25
+ from .element import Element
26
+
27
+
28
+ class ElementState(Enum):
29
+ """Condition awaited by :meth:`Frame.wait_for_selector` / :meth:`Frame.wait_for_xpath`."""
30
+
31
+ ATTACHED = "attached"
32
+ VISIBLE = "visible"
33
+ HIDDEN = "hidden"
34
+ DETACHED = "detached"
35
+
36
+
37
+ class LoadState(Enum):
38
+ """A page lifecycle milestone; values are the CDP ``Page.lifecycleEvent`` names."""
39
+
40
+ LOAD = "load"
41
+ DOMCONTENTLOADED = "DOMContentLoaded"
42
+ NETWORKIDLE = "networkIdle"
43
+
44
+
45
+ class Frame:
46
+ """A single frame, bound to the CDP session that owns it.
47
+
48
+ Holds the protocol :class:`~parsek_cdp.cdp.page.types.Frame` fields that
49
+ matter day to day (``url``, ``name``, ``loader_id``) and offers
50
+ :meth:`evaluate` / :meth:`select` scoped to it. A cross-origin frame is
51
+ bound to its own dedicated CDP session (:attr:`target`); everything routes
52
+ through :attr:`cdp` so same- and cross-origin frames behave identically.
53
+
54
+ ``target`` is the session this frame runs on (the page for same-origin
55
+ frames; for the main frame the page *is* the frame). ``parent_id`` is kept
56
+ as raw protocol data, but resolving it to a parent frame -- and listing
57
+ children -- is the job of the optional tree-manager feature.
58
+ """
59
+
60
+ _head_elem: Element | None
61
+
62
+ def __init__(
63
+ self,
64
+ target: "Target",
65
+ frame_info: Page.Frame,
66
+ parent: Frame | None = None,
67
+ ) -> None:
68
+ self.target = target
69
+ self._frame = frame_info
70
+ self._parent = parent
71
+ self._frames: dict[Page.FrameId, Frame] = {}
72
+
73
+ self._dom_generation = 0
74
+ self._head_elem = None
75
+
76
+ self._bind_dom_tracking()
77
+
78
+ def _bind_dom_tracking(self) -> None:
79
+ """Track every event that invalidates this frame's ``nodeId`` map.
80
+
81
+ ``DOM.enable``/``DOM.disable`` rebuild (or drop) the whole node-id map and
82
+ ``DOM.documentUpdated`` fires when the document is replaced -- each leaves
83
+ previously minted ``nodeId``s stale. Which one happened doesn't matter,
84
+ only that the map changed, so all three bump a single monotonic counter;
85
+ an :class:`Element` whose ``_generation`` lags re-mints its id from the
86
+ stable ``backendNodeId`` (see :meth:`Element.refresh`).
87
+ """
88
+ self.target.on_function(self.target.cdp.DOM.disable, self._bump_dom_generation)
89
+ self.target.on_function(self.target.cdp.DOM.enable, self._bump_dom_generation)
90
+ self.target.on(DOM.DocumentUpdated, self._bump_dom_generation)
91
+
92
+ def _unbind_dom_tracking(self) -> None:
93
+ self.target.off_function(self.target.cdp.DOM.disable, self._bump_dom_generation)
94
+ self.target.off_function(self.target.cdp.DOM.enable, self._bump_dom_generation)
95
+ self.target.off(DOM.DocumentUpdated, self._bump_dom_generation)
96
+
97
+ def _bump_dom_generation(self, _) -> None:
98
+ self._dom_generation += 1
99
+
100
+ def dispose(self) -> None:
101
+ """Unregister this frame's (and its subtree's) DOM-tracking handlers.
102
+
103
+ Same-origin frames share one target, so the handlers bound in
104
+ :meth:`_bind_dom_tracking` outlive the frame unless removed explicitly.
105
+ Call this whenever a frame leaves the tree (see :meth:`detach_frame` /
106
+ :meth:`refresh_frame`) so recreated frames don't pile up dead handlers on
107
+ the target.
108
+ """
109
+ self._unbind_dom_tracking()
110
+ for child in list(self._frames.values()):
111
+ child.dispose()
112
+ self._frames.clear()
113
+
114
+ @property
115
+ def id(self) -> Page.FrameId:
116
+ return self._frame.id
117
+
118
+ @property
119
+ def url(self) -> str:
120
+ return self._frame.url
121
+
122
+ @property
123
+ def name(self) -> Optional[str]:
124
+ return self._frame.name
125
+
126
+ @property
127
+ def loader_id(self) -> str:
128
+ return self._frame.loader_id
129
+
130
+ @property
131
+ def is_main(self) -> bool:
132
+ return self._frame.parent_id is None
133
+
134
+ @property
135
+ def is_oopif(self) -> bool:
136
+ """``True`` when this frame is a cross-origin frame with its own session.
137
+
138
+ A frame keeps its own identity (:attr:`id`); this only asks whether the
139
+ session it is *bound* to is dedicated to it (an out-of-process iframe).
140
+ """
141
+ return self.target.id == self._frame.id
142
+
143
+ @property
144
+ def raw_frame(self) -> Page.Frame:
145
+ return self._frame
146
+
147
+ @property
148
+ def frames(self):
149
+ return list(self._frames.values())
150
+
151
+ @property
152
+ def cdp(self):
153
+ """The CDP entry point of the session this frame is bound to."""
154
+ return self.target.cdp
155
+
156
+ async def _get_head_elem(self):
157
+ if (
158
+ self._head_elem is None
159
+ or self._dom_generation != self._head_elem._generation
160
+ ):
161
+ from .element import Element
162
+
163
+ document = await self.cdp.DOM.get_document(depth=0)
164
+ self._head_elem = Element._from_node(document.root, frame=self)
165
+ self._head_elem._generation = self._dom_generation
166
+ return self._head_elem
167
+
168
+ @classmethod
169
+ def from_frame_tree(cls, origin_frame: Frame, frame_tree: Page.FrameTree):
170
+ frame_id = frame_tree.frame.id
171
+ target = origin_frame.target._targets.get(frame_id, origin_frame.target)
172
+ instance = cls(target, frame_tree.frame, origin_frame)
173
+ for frame_tree in frame_tree.child_frames or []:
174
+ child = Frame.from_frame_tree(instance, frame_tree)
175
+ instance._frames[child.id] = child
176
+ return instance
177
+
178
+ async def refresh_frame(self):
179
+ tree = await self.cdp.Page.get_frame_tree()
180
+ self._merge_frame_tree(tree.frame_tree)
181
+
182
+ def _merge_frame_tree(self, frame_tree: Page.FrameTree) -> None:
183
+ """Reconcile this frame's children against a fresh ``Page.getFrameTree``.
184
+
185
+ Only frames absent from ``frame_tree`` are disposed; survivors keep their
186
+ identity (and their bound DOM-tracking handlers), so we neither leak
187
+ handlers by recreating them nor throw away their per-frame state (DOM
188
+ generation, cached head element). New frames are built fresh.
189
+ """
190
+ self._frame = frame_tree.frame
191
+ children = frame_tree.child_frames or []
192
+ live_ids = {child.frame.id for child in children}
193
+ for frame_id in list(self._frames):
194
+ if frame_id not in live_ids:
195
+ self._frames.pop(frame_id).dispose()
196
+ for child_tree in children:
197
+ existing = self._frames.get(child_tree.frame.id)
198
+ if existing is not None:
199
+ existing._merge_frame_tree(child_tree)
200
+ else:
201
+ child = Frame.from_frame_tree(self, child_tree)
202
+ self._frames[child.id] = child
203
+
204
+ async def select(
205
+ self, *, selector: Optional[str] = None, xpath: Optional[str] = None
206
+ ) -> Optional[Element]:
207
+ """Find the first node matching a CSS ``selector`` or an ``xpath``.
208
+
209
+ Returns its :class:`Element`, or ``None`` if nothing matches. The query
210
+ runs in this frame's document, so subframes are resolved correctly.
211
+ """
212
+ from .element import Element
213
+
214
+ if xpath is not None:
215
+ node_ids = await self._perform_search(xpath)
216
+ if not node_ids:
217
+ return None
218
+ return await Element.describe_node(node_id=node_ids[0], frame=self)
219
+ if selector is None:
220
+ raise ValueError("select() needs either selector= or xpath=")
221
+ head_node = await self._get_head_elem()
222
+ return await head_node.query_selector(selector=selector)
223
+
224
+ async def select_all(self, selector: str) -> List[Element]:
225
+ """Return an :class:`Element` for every node matching ``selector``."""
226
+
227
+ head_node = await self._get_head_elem()
228
+ res = []
229
+ async for elem in head_node.query_selector_all(selector=selector):
230
+ res.append(elem)
231
+ return res
232
+
233
+ async def _perform_search(self, query: str) -> List[int]:
234
+ """Run a DevTools DOM search (text / attributes / markup / XPath)."""
235
+ dom = self.cdp.DOM
236
+ search = await dom.perform_search(
237
+ query=query, include_user_agent_shadow_dom=True
238
+ )
239
+ try:
240
+ if not search.result_count:
241
+ return []
242
+ results = await dom.get_search_results(
243
+ search_id=search.search_id,
244
+ from_index=0,
245
+ to_index=min(search.result_count, 50),
246
+ )
247
+ return [n for n in results.node_ids if n]
248
+ finally:
249
+ try:
250
+ await dom.discard_search_results(search_id=search.search_id)
251
+ except Exception:
252
+ pass
253
+
254
+ async def evaluate(self, expression: str, *, await_promise: bool = False):
255
+ """Evaluate JavaScript in this frame and return the value.
256
+
257
+ Runs in the frame's own execution context, so subframes (including
258
+ cross-origin ones) are evaluated correctly rather than against the main
259
+ page. Raises :class:`RuntimeError` if a non-main frame has no context
260
+ yet (e.g. before it has loaded).
261
+ """
262
+ result = await self.cdp.Runtime.evaluate(
263
+ expression=expression,
264
+ await_promise=await_promise,
265
+ return_by_value=True,
266
+ ) # TODO: Сделать по контексту
267
+ return result.result.value if result.result else None
268
+
269
+ async def wait_for_selector(
270
+ self,
271
+ selector: str,
272
+ *,
273
+ state: ElementState = ElementState.VISIBLE,
274
+ timeout: float = 30,
275
+ poll_interval: float = 0.1,
276
+ ) -> Optional[Element]:
277
+ """Poll until a node matching the CSS ``selector`` reaches ``state``.
278
+
279
+ Returns the :class:`Element` for ``ATTACHED`` / ``VISIBLE`` (or ``None``
280
+ for ``HIDDEN`` / ``DETACHED``). Raises :class:`asyncio.TimeoutError` if
281
+ the state is not reached within ``timeout`` seconds. Each poll
282
+ re-queries, so a re-mounted node (fresh ``nodeId``) is handled correctly.
283
+ """
284
+ return await self._wait_for_query(
285
+ {"selector": selector}, state, timeout, poll_interval
286
+ )
287
+
288
+ async def wait_for_xpath(
289
+ self,
290
+ xpath: str,
291
+ *,
292
+ state: ElementState = ElementState.VISIBLE,
293
+ timeout: float = 30,
294
+ poll_interval: float = 0.1,
295
+ ) -> Optional[Element]:
296
+ """Like :meth:`wait_for_selector` but matching an XPath expression."""
297
+ return await self._wait_for_query(
298
+ {"xpath": xpath}, state, timeout, poll_interval
299
+ )
300
+
301
+ async def _wait_for_query(
302
+ self,
303
+ query: dict,
304
+ state: ElementState,
305
+ timeout: float,
306
+ poll_interval: float,
307
+ ) -> Optional[Element]:
308
+ loop = asyncio.get_running_loop()
309
+ deadline = loop.time() + timeout
310
+ while True:
311
+ try:
312
+ element = await self.select(**query)
313
+ except ProtocolError:
314
+ element = None
315
+ done, result = await self._state_satisfied(element, state)
316
+ if done:
317
+ return result
318
+ if loop.time() >= deadline:
319
+ target = query.get("selector") or query.get("xpath")
320
+ raise asyncio.TimeoutError(
321
+ f"waiting for {target!r} to be {state.value} timed out after {timeout}s"
322
+ )
323
+ await asyncio.sleep(poll_interval)
324
+
325
+ @staticmethod
326
+ async def _state_satisfied(
327
+ element: Optional[Element], state: ElementState
328
+ ) -> Tuple[bool, Optional[Element]]:
329
+ """Whether ``element`` meets ``state`` (and what to return on success)."""
330
+ if state is ElementState.ATTACHED:
331
+ return element is not None, element
332
+ if state is ElementState.DETACHED:
333
+ return element is None, None
334
+ visible = element is not None and await element.is_visible()
335
+ if state is ElementState.VISIBLE:
336
+ return visible, element
337
+ return not visible, element
338
+
339
+ def attach_frame(self, frame: Frame):
340
+ self._frames[frame.id] = frame
341
+
342
+ def detach_frame(self, frame: Frame):
343
+ detached = self._frames.pop(frame.id)
344
+ detached.dispose()
345
+ return detached
346
+
347
+ def __str__(self) -> str:
348
+ kind = "oopif" if self.is_oopif else ("main" if self.is_main else "child")
349
+ return f"<Frame {kind} {self.target.url!r}>"
350
+
351
+ __repr__ = __str__
@@ -0,0 +1,175 @@
1
+ """``PagableTarget`` -- shared management of pages (the page/tab handling).
2
+
3
+ Both :class:`Browser` (all pages) and :class:`BrowserContext` (pages of one
4
+ context) manage a collection of pages with identical bookkeeping, so the whole
5
+ page/tab lifecycle -- *including reacting to the ``Target.*`` discovery events*
6
+ -- lives here:
7
+
8
+ * :meth:`watch` registers the discovery handlers on the connection that delivers
9
+ them (the browser's own websocket);
10
+ * :meth:`_on_target_created` / :meth:`_on_target_destroyed` /
11
+ :meth:`_on_target_info_changed` react to the protocol events;
12
+ * :meth:`new_page` issues ``Target.createTarget`` and waits for the matching
13
+ target to be discovered;
14
+ * :meth:`_ensure_page` builds a :class:`Page` (via ``self.page_class``), connects
15
+ its own websocket, binds it and registers it.
16
+
17
+ Pages are keyed by their **target id** (each target has its own websocket, so
18
+ there is no multiplexing ``sessionId``). A page registered in a scope *bubbles*
19
+ to its :attr:`_parent` (a context's parent is its browser), so a page created in
20
+ a context also lands in the browser's global registry -- one :class:`Page`
21
+ instance shared by both. The only browser-specific bit is resolving which
22
+ context owns a target, exposed through :meth:`_context_for` (defaults to
23
+ ``self``; the browser overrides it).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import functools
30
+ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, cast
31
+
32
+ from ..cdp import Target as TargetDomain
33
+
34
+ if TYPE_CHECKING:
35
+ from ..cdp import CDP
36
+ from .browser import Browser
37
+ from .page import Page
38
+ from .target import Target
39
+
40
+ PageCallback = Callable[["Page"], None]
41
+
42
+
43
+ class PagableTarget[T: Page]:
44
+ """Mixin managing a collection of pages within a scope."""
45
+
46
+ _cdp: CDP
47
+ page_class: type[T]
48
+ _parent: PagableTarget[T] | None = None
49
+
50
+ def __init__(self) -> None:
51
+ self._pages: Dict[TargetDomain.TargetID, T] = {}
52
+ self._page_callbacks: List[PageCallback] = []
53
+ self._page_waiters: List[list] = []
54
+
55
+ @property
56
+ def pages(self):
57
+ return list(self._pages.values())
58
+
59
+ @property
60
+ def _scope_context_id(self) -> Optional[str]:
61
+ """Browser-context id new pages are created in (``None`` = default)."""
62
+ raise NotImplementedError
63
+
64
+ def _bind_page(self, page: T) -> None:
65
+ """Set ``page.browser`` / ``page.browser_context``. Overridden per scope."""
66
+
67
+ def _context_for(self, context_id: Optional[str]) -> "PagableTarget[T]":
68
+ """Resolve the scope that owns a target. Browser overrides; else self."""
69
+ return self
70
+
71
+ def watch(self, connection: "Target") -> None:
72
+ """Register the page-discovery handlers on ``connection``'s websocket.
73
+
74
+ Only the browser calls this (its websocket is where ``Target.*`` events
75
+ arrive); contexts are driven by the browser routing through
76
+ :meth:`_context_for`.
77
+ """
78
+ connection.on(TargetDomain.TargetCreated, self.__on_target_created)
79
+ connection.on(TargetDomain.TargetDestroyed, self.__on_target_destroyed)
80
+ connection.on(TargetDomain.TargetInfoChanged, self.__on_target_info_changed)
81
+
82
+ def __on_target_created(self, event: TargetDomain.TargetCreated) -> None:
83
+ info = event.target_info
84
+ if info.type_ in ("page", "tab"):
85
+ scope = self._context_for(info.browser_context_id)
86
+ asyncio.ensure_future(scope._ensure_page(info))
87
+
88
+ def __on_target_destroyed(self, event: TargetDomain.TargetDestroyed) -> None:
89
+ self._drop_page(event.target_id)
90
+
91
+ def __on_target_info_changed(self, event: TargetDomain.TargetInfoChanged) -> None:
92
+ page = self.get_page(target_id=event.target_info.target_id)
93
+ if page is not None:
94
+ page._target = event.target_info
95
+
96
+ def on_page(self, callback: PageCallback) -> PageCallback:
97
+ """Register a callback invoked for every page added to this scope."""
98
+ self._page_callbacks.append(callback)
99
+
100
+ @functools.wraps(callback)
101
+ def wrapper(page: Page):
102
+ callback(page)
103
+
104
+ return wrapper
105
+
106
+ @property
107
+ def page_list(self) -> List[T]:
108
+ return list(self._pages.values())
109
+
110
+ def get_page(self, *, target_id: Optional[str] = None) -> Optional[T]:
111
+ """Look a page up by target id."""
112
+ if target_id is not None:
113
+ return self._pages.get(target_id)
114
+ return None
115
+
116
+ async def new_page(self, url: str = "about:blank", *, timeout: float = 30) -> T:
117
+ """Create a new page/tab in this scope and return it once connected.
118
+
119
+ Relies on target discovery to deliver the new target, build the page and
120
+ open its own websocket.
121
+ """
122
+ loop = asyncio.get_running_loop()
123
+ future: asyncio.Future = loop.create_future()
124
+ waiter: list = [None, future]
125
+ self._page_waiters.append(waiter)
126
+ try:
127
+ created = await self._cdp.Target.create_target(
128
+ url=url,
129
+ browser_context_id=self._scope_context_id,
130
+ )
131
+ waiter[0] = created.target_id
132
+ existing = self.get_page(target_id=created.target_id)
133
+ if existing is not None and not future.done():
134
+ future.set_result(existing)
135
+ page: T = await asyncio.wait_for(future, timeout)
136
+ await page.wait_features_ready()
137
+ return page
138
+ finally:
139
+ if waiter in self._page_waiters:
140
+ self._page_waiters.remove(waiter)
141
+
142
+ async def _ensure_page(self, target_info: TargetDomain.TargetInfo) -> T:
143
+ """Build, connect + register a page in this scope (once per target)."""
144
+ page = self._pages.get(target_info.target_id)
145
+ if page is None:
146
+ owner = cast("Browser[T]", self._parent or self)
147
+ page = self.page_class(owner, target_info)
148
+ self._bind_page(page)
149
+ await page.connect()
150
+ await page.wait_features_ready()
151
+ self._add_page(page)
152
+ return page
153
+
154
+ def _add_page(self, page: T) -> None:
155
+ if page.id is not None:
156
+ self._pages[page.id] = page
157
+ for callback in list(self._page_callbacks):
158
+ callback(page)
159
+ for waiter in list(self._page_waiters):
160
+ target_id, future = waiter
161
+ if target_id == page.id and not future.done():
162
+ future.set_result(page)
163
+ if self._parent is not None:
164
+ self._parent._add_page(page) # bubble to the global registry
165
+
166
+ def _drop_page(self, target_id: Optional[str]) -> None:
167
+ page = self._pages.pop(target_id or "", None)
168
+ if page is None:
169
+ return
170
+ context = getattr(page, "browser_context", None)
171
+ if context is not None and context is not self:
172
+ context._remove_page(page.id)
173
+
174
+ def _remove_page(self, target_id: Optional[str]) -> None:
175
+ self._pages.pop(target_id or "", None)
@@ -0,0 +1,163 @@
1
+ """A page target: a CDP session attached to a page/tab.
2
+
3
+ ``Page`` is a bare host -- it attaches **no** features by default; every feature
4
+ (frame-tree tracking, request listening, ...) is optional and opted into:
5
+
6
+ * declaratively -- ``Page[Frames]`` / ``Page[Frames, RequestListener]`` (or
7
+ chained ``Page[A][B]``); each is attached on construction;
8
+ * imperatively + type-safely -- ``page.get_feature(Frames)`` returns the feature
9
+ typed as ``Frames`` (attaching it on first use).
10
+
11
+ A page *is its own main frame*: it subclasses :class:`Frame` (the main frame's id
12
+ equals the page's target id), so ``page.select(...)`` / ``page.evaluate(...)``
13
+ work on the top frame without any feature. The frame *tree* (subframes, OOPIFs)
14
+ lives in the optional :class:`Frames` feature.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from contextlib import asynccontextmanager
21
+ from typing import TYPE_CHECKING, AsyncIterator
22
+
23
+ from ..cdp import Target as TargetDomain
24
+ from .feature import Feature, FeatureHost
25
+ from .frame import Frame
26
+ from .target import Target
27
+
28
+ if TYPE_CHECKING:
29
+ from .browser import Browser
30
+ from .browser_context import BrowserContext
31
+
32
+
33
+ class NavigationError(Exception):
34
+ """Raised by :meth:`Page.no_navigation` when the guarded block was cut short
35
+ by a main-frame navigation (only when ``raise_=True``)."""
36
+
37
+
38
+ class _NavGuard:
39
+ """Handle yielded by :meth:`Page.no_navigation`; ``navigated`` flips on nav."""
40
+
41
+ navigated: bool = False
42
+
43
+
44
+ class Page[T: Feature](Target, FeatureHost[T], Frame):
45
+ """A page (tab) target exposing every CDP domain through :attr:`cdp`.
46
+
47
+ A page *is its own main frame*: it subclasses :class:`Frame` (in CDP the
48
+ main frame's id equals the page's target id), so ``page.url`` and
49
+ ``page.evaluate(...)`` operate on the top frame, while the full frame tree
50
+ (and navigation like ``page.frames.children_of(page)``) lives in
51
+ :attr:`frames`.
52
+
53
+ Generic over the feature type so ``Page[RequestListener]`` is accepted by
54
+ type checkers (the runtime composition is handled by ``FeatureHost``).
55
+ """
56
+
57
+ browser: "Browser"
58
+ browser_context: "BrowserContext"
59
+
60
+ def __init__(
61
+ self,
62
+ browser: Browser,
63
+ target_info: TargetDomain.TargetInfo,
64
+ ):
65
+ Target.__init__(self, target_info, browser)
66
+ self._init_features()
67
+ self.browser = browser
68
+ self.browser._targets[self.id] = self
69
+
70
+ async def connect(self) -> "Page[T]":
71
+ """Open the page's own websocket, learn its main frame, start features."""
72
+ await super().connect()
73
+ tree = await self.cdp.Page.get_frame_tree()
74
+ Frame.__init__(self, self, tree.frame_tree.frame, None)
75
+ await self.wait_features_ready()
76
+ return self
77
+
78
+ async def navigate(self, url: str, *, wait_load: bool = False, timeout: float = 30):
79
+ """Navigate to ``url``; optionally wait for the load event.
80
+
81
+ ``Page.loadEventFired`` only arrives while the ``Page`` domain is enabled,
82
+ so ``wait_load`` enables it for the duration here. The waiter is armed
83
+ *before* navigating, so a fast load that fires before the command returns
84
+ is not missed.
85
+ """
86
+ from ..cdp.page.events import LoadEventFired
87
+
88
+ if not wait_load:
89
+ return await self.cdp.Page.navigate(url=url)
90
+
91
+ async with self.domain_enabled(self.cdp.Page):
92
+ loop = asyncio.get_running_loop()
93
+ loaded: asyncio.Future = loop.create_future()
94
+
95
+ def _on_load(_event: object) -> None:
96
+ if not loaded.done():
97
+ loaded.set_result(None)
98
+
99
+ self.on(LoadEventFired, _on_load)
100
+ try:
101
+ data = await self.cdp.Page.navigate(url=url)
102
+ await asyncio.wait_for(loaded, timeout)
103
+ finally:
104
+ self.off(LoadEventFired, _on_load)
105
+ return data
106
+
107
+ @asynccontextmanager
108
+ async def with_same_navigation(
109
+ self, *, raise_: bool = True
110
+ ) -> AsyncIterator[_NavGuard]:
111
+ """Run the block only while the document stays the same.
112
+
113
+ Guards a block of work against a main-frame navigation: if the page
114
+ navigates (the main frame commits a new ``loaderId``) while the body
115
+ runs, the body is cancelled at its next ``await`` -- so logic never keeps
116
+ operating on a document that has been replaced underneath it::
117
+
118
+ async with page.with_same_navigation():
119
+ el = await page.select(selector="#row")
120
+ await el.click() # cut short if navigation happens here
121
+
122
+ With ``raise_=True`` (default) a :class:`NavigationError` is raised when
123
+ the block is cut short; with ``raise_=False`` it is swallowed and the
124
+ yielded guard's ``navigated`` flag lets the caller decide (e.g. retry)::
125
+
126
+ async with page.with_same_navigation(raise_=False) as guard:
127
+ ...
128
+ if guard.navigated:
129
+ ... # the block was interrupted by a navigation
130
+ """
131
+ from ..cdp.page.events import FrameNavigated
132
+
133
+ guard = _NavGuard()
134
+ baseline = self.loader_id
135
+ task = asyncio.current_task()
136
+
137
+ def _on(e: "FrameNavigated") -> None:
138
+ if (
139
+ e.frame.parent_id is None
140
+ and e.frame.loader_id != baseline
141
+ and not guard.navigated
142
+ ):
143
+ guard.navigated = True
144
+ if task is not None:
145
+ task.cancel()
146
+
147
+ self.on(FrameNavigated, _on)
148
+ try:
149
+ async with self.domain_enabled(self.cdp.Page):
150
+ yield guard
151
+ if guard.navigated and raise_:
152
+ raise NavigationError(
153
+ f"navigation away from {self.url!r} cut the block short"
154
+ )
155
+ except asyncio.CancelledError:
156
+ if not guard.navigated:
157
+ raise # a genuine external cancellation -- propagate untouched
158
+ if raise_:
159
+ raise NavigationError(
160
+ f"navigation away from {self.url!r} cut the block short"
161
+ ) from None
162
+ finally:
163
+ self.off(FrameNavigated, _on)