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.
- parsek_cdp/__init__.py +55 -0
- parsek_cdp/_logging.py +49 -0
- parsek_cdp/cdp/__init__.py +247 -0
- parsek_cdp/cdp/accessibility/__init__.py +15 -0
- parsek_cdp/cdp/accessibility/events.py +35 -0
- parsek_cdp/cdp/accessibility/functions.py +202 -0
- parsek_cdp/cdp/accessibility/types.py +235 -0
- parsek_cdp/cdp/animation/__init__.py +15 -0
- parsek_cdp/cdp/animation/events.py +52 -0
- parsek_cdp/cdp/animation/functions.py +140 -0
- parsek_cdp/cdp/animation/types.py +113 -0
- parsek_cdp/cdp/audits/__init__.py +15 -0
- parsek_cdp/cdp/audits/events.py +21 -0
- parsek_cdp/cdp/audits/functions.py +95 -0
- parsek_cdp/cdp/audits/types.py +1051 -0
- parsek_cdp/cdp/autofill/__init__.py +15 -0
- parsek_cdp/cdp/autofill/events.py +27 -0
- parsek_cdp/cdp/autofill/functions.py +64 -0
- parsek_cdp/cdp/autofill/types.py +108 -0
- parsek_cdp/cdp/backgroundservice/__init__.py +15 -0
- parsek_cdp/cdp/backgroundservice/events.py +40 -0
- parsek_cdp/cdp/backgroundservice/functions.py +59 -0
- parsek_cdp/cdp/backgroundservice/types.py +63 -0
- parsek_cdp/cdp/bluetoothemulation/__init__.py +15 -0
- parsek_cdp/cdp/bluetoothemulation/events.py +70 -0
- parsek_cdp/cdp/bluetoothemulation/functions.py +247 -0
- parsek_cdp/cdp/bluetoothemulation/types.py +120 -0
- parsek_cdp/cdp/browser/__init__.py +15 -0
- parsek_cdp/cdp/browser/events.py +46 -0
- parsek_cdp/cdp/browser/functions.py +350 -0
- parsek_cdp/cdp/browser/types.py +158 -0
- parsek_cdp/cdp/cachestorage/__init__.py +15 -0
- parsek_cdp/cdp/cachestorage/events.py +13 -0
- parsek_cdp/cdp/cachestorage/functions.py +126 -0
- parsek_cdp/cdp/cachestorage/types.py +89 -0
- parsek_cdp/cdp/cast/__init__.py +15 -0
- parsek_cdp/cdp/cast/events.py +38 -0
- parsek_cdp/cdp/cast/functions.py +77 -0
- parsek_cdp/cdp/cast/types.py +23 -0
- parsek_cdp/cdp/console/__init__.py +15 -0
- parsek_cdp/cdp/console/events.py +22 -0
- parsek_cdp/cdp/console/functions.py +36 -0
- parsek_cdp/cdp/console/types.py +30 -0
- parsek_cdp/cdp/css/__init__.py +15 -0
- parsek_cdp/cdp/css/events.py +79 -0
- parsek_cdp/cdp/css/functions.py +788 -0
- parsek_cdp/cdp/css/types.py +742 -0
- parsek_cdp/cdp/debugger/__init__.py +15 -0
- parsek_cdp/cdp/debugger/events.py +164 -0
- parsek_cdp/cdp/debugger/functions.py +663 -0
- parsek_cdp/cdp/debugger/types.py +171 -0
- parsek_cdp/cdp/deviceaccess/__init__.py +15 -0
- parsek_cdp/cdp/deviceaccess/events.py +30 -0
- parsek_cdp/cdp/deviceaccess/functions.py +54 -0
- parsek_cdp/cdp/deviceaccess/types.py +28 -0
- parsek_cdp/cdp/deviceorientation/__init__.py +15 -0
- parsek_cdp/cdp/deviceorientation/events.py +13 -0
- parsek_cdp/cdp/deviceorientation/functions.py +35 -0
- parsek_cdp/cdp/deviceorientation/types.py +10 -0
- parsek_cdp/cdp/dom/__init__.py +15 -0
- parsek_cdp/cdp/dom/events.py +228 -0
- parsek_cdp/cdp/dom/functions.py +1033 -0
- parsek_cdp/cdp/dom/types.py +293 -0
- parsek_cdp/cdp/domdebugger/__init__.py +15 -0
- parsek_cdp/cdp/domdebugger/events.py +13 -0
- parsek_cdp/cdp/domdebugger/functions.py +155 -0
- parsek_cdp/cdp/domdebugger/types.py +57 -0
- parsek_cdp/cdp/domsnapshot/__init__.py +15 -0
- parsek_cdp/cdp/domsnapshot/events.py +13 -0
- parsek_cdp/cdp/domsnapshot/functions.py +111 -0
- parsek_cdp/cdp/domsnapshot/types.py +312 -0
- parsek_cdp/cdp/domstorage/__init__.py +15 -0
- parsek_cdp/cdp/domstorage/events.py +60 -0
- parsek_cdp/cdp/domstorage/functions.py +79 -0
- parsek_cdp/cdp/domstorage/types.py +30 -0
- parsek_cdp/cdp/emulation/__init__.py +15 -0
- parsek_cdp/cdp/emulation/events.py +17 -0
- parsek_cdp/cdp/emulation/functions.py +688 -0
- parsek_cdp/cdp/emulation/types.py +305 -0
- parsek_cdp/cdp/eventbreakpoints/__init__.py +15 -0
- parsek_cdp/cdp/eventbreakpoints/events.py +13 -0
- parsek_cdp/cdp/eventbreakpoints/functions.py +41 -0
- parsek_cdp/cdp/eventbreakpoints/types.py +10 -0
- parsek_cdp/cdp/extensions/__init__.py +15 -0
- parsek_cdp/cdp/extensions/events.py +13 -0
- parsek_cdp/cdp/extensions/functions.py +118 -0
- parsek_cdp/cdp/extensions/types.py +19 -0
- parsek_cdp/cdp/fedcm/__init__.py +15 -0
- parsek_cdp/cdp/fedcm/events.py +45 -0
- parsek_cdp/cdp/fedcm/functions.py +93 -0
- parsek_cdp/cdp/fedcm/types.py +72 -0
- parsek_cdp/cdp/fetch/__init__.py +15 -0
- parsek_cdp/cdp/fetch/events.py +82 -0
- parsek_cdp/cdp/fetch/functions.py +215 -0
- parsek_cdp/cdp/fetch/types.py +81 -0
- parsek_cdp/cdp/filesystem/__init__.py +15 -0
- parsek_cdp/cdp/filesystem/events.py +13 -0
- parsek_cdp/cdp/filesystem/functions.py +36 -0
- parsek_cdp/cdp/filesystem/types.py +54 -0
- parsek_cdp/cdp/headlessexperimental/__init__.py +15 -0
- parsek_cdp/cdp/headlessexperimental/events.py +13 -0
- parsek_cdp/cdp/headlessexperimental/functions.py +77 -0
- parsek_cdp/cdp/headlessexperimental/types.py +24 -0
- parsek_cdp/cdp/heapprofiler/__init__.py +15 -0
- parsek_cdp/cdp/heapprofiler/events.py +65 -0
- parsek_cdp/cdp/heapprofiler/functions.py +193 -0
- parsek_cdp/cdp/heapprofiler/types.py +57 -0
- parsek_cdp/cdp/indexeddb/__init__.py +15 -0
- parsek_cdp/cdp/indexeddb/events.py +13 -0
- parsek_cdp/cdp/indexeddb/functions.py +232 -0
- parsek_cdp/cdp/indexeddb/types.py +120 -0
- parsek_cdp/cdp/input/__init__.py +15 -0
- parsek_cdp/cdp/input/events.py +25 -0
- parsek_cdp/cdp/input/functions.py +361 -0
- parsek_cdp/cdp/input/types.py +87 -0
- parsek_cdp/cdp/inspector/__init__.py +15 -0
- parsek_cdp/cdp/inspector/events.py +41 -0
- parsek_cdp/cdp/inspector/functions.py +27 -0
- parsek_cdp/cdp/inspector/types.py +10 -0
- parsek_cdp/cdp/io/__init__.py +15 -0
- parsek_cdp/cdp/io/events.py +13 -0
- parsek_cdp/cdp/io/functions.py +77 -0
- parsek_cdp/cdp/io/types.py +13 -0
- parsek_cdp/cdp/layertree/__init__.py +15 -0
- parsek_cdp/cdp/layertree/events.py +36 -0
- parsek_cdp/cdp/layertree/functions.py +178 -0
- parsek_cdp/cdp/layertree/types.py +104 -0
- parsek_cdp/cdp/log/__init__.py +15 -0
- parsek_cdp/cdp/log/events.py +22 -0
- parsek_cdp/cdp/log/functions.py +54 -0
- parsek_cdp/cdp/log/types.py +57 -0
- parsek_cdp/cdp/media/__init__.py +15 -0
- parsek_cdp/cdp/media/events.py +87 -0
- parsek_cdp/cdp/media/functions.py +27 -0
- parsek_cdp/cdp/media/types.py +101 -0
- parsek_cdp/cdp/memory/__init__.py +15 -0
- parsek_cdp/cdp/memory/events.py +13 -0
- parsek_cdp/cdp/memory/functions.py +164 -0
- parsek_cdp/cdp/memory/types.py +71 -0
- parsek_cdp/cdp/mixins/__init__.py +21 -0
- parsek_cdp/cdp/mixins/datatype.py +123 -0
- parsek_cdp/cdp/mixins/event.py +78 -0
- parsek_cdp/cdp/mixins/namespace.py +31 -0
- parsek_cdp/cdp/network/__init__.py +15 -0
- parsek_cdp/cdp/network/events.py +726 -0
- parsek_cdp/cdp/network/functions.py +734 -0
- parsek_cdp/cdp/network/types.py +1247 -0
- parsek_cdp/cdp/overlay/__init__.py +15 -0
- parsek_cdp/cdp/overlay/events.py +54 -0
- parsek_cdp/cdp/overlay/functions.py +407 -0
- parsek_cdp/cdp/overlay/types.py +328 -0
- parsek_cdp/cdp/page/__init__.py +15 -0
- parsek_cdp/cdp/page/events.py +416 -0
- parsek_cdp/cdp/page/functions.py +1081 -0
- parsek_cdp/cdp/page/types.py +1088 -0
- parsek_cdp/cdp/performance/__init__.py +15 -0
- parsek_cdp/cdp/performance/events.py +24 -0
- parsek_cdp/cdp/performance/functions.py +63 -0
- parsek_cdp/cdp/performance/types.py +22 -0
- parsek_cdp/cdp/performancetimeline/__init__.py +15 -0
- parsek_cdp/cdp/performancetimeline/events.py +22 -0
- parsek_cdp/cdp/performancetimeline/functions.py +30 -0
- parsek_cdp/cdp/performancetimeline/types.py +85 -0
- parsek_cdp/cdp/preload/__init__.py +15 -0
- parsek_cdp/cdp/preload/events.py +116 -0
- parsek_cdp/cdp/preload/functions.py +25 -0
- parsek_cdp/cdp/preload/types.py +267 -0
- parsek_cdp/cdp/profiler/__init__.py +15 -0
- parsek_cdp/cdp/profiler/events.py +64 -0
- parsek_cdp/cdp/profiler/functions.py +134 -0
- parsek_cdp/cdp/profiler/types.py +105 -0
- parsek_cdp/cdp/pwa/__init__.py +15 -0
- parsek_cdp/cdp/pwa/events.py +13 -0
- parsek_cdp/cdp/pwa/functions.py +196 -0
- parsek_cdp/cdp/pwa/types.py +45 -0
- parsek_cdp/cdp/runtime/__init__.py +15 -0
- parsek_cdp/cdp/runtime/events.py +123 -0
- parsek_cdp/cdp/runtime/functions.py +552 -0
- parsek_cdp/cdp/runtime/types.py +319 -0
- parsek_cdp/cdp/schema/__init__.py +15 -0
- parsek_cdp/cdp/schema/events.py +13 -0
- parsek_cdp/cdp/schema/functions.py +32 -0
- parsek_cdp/cdp/schema/types.py +22 -0
- parsek_cdp/cdp/security/__init__.py +15 -0
- parsek_cdp/cdp/security/events.py +64 -0
- parsek_cdp/cdp/security/functions.py +66 -0
- parsek_cdp/cdp/security/types.py +168 -0
- parsek_cdp/cdp/serviceworker/__init__.py +15 -0
- parsek_cdp/cdp/serviceworker/events.py +43 -0
- parsek_cdp/cdp/serviceworker/functions.py +115 -0
- parsek_cdp/cdp/serviceworker/types.py +95 -0
- parsek_cdp/cdp/storage/__init__.py +15 -0
- parsek_cdp/cdp/storage/events.py +288 -0
- parsek_cdp/cdp/storage/functions.py +571 -0
- parsek_cdp/cdp/storage/types.py +656 -0
- parsek_cdp/cdp/systeminfo/__init__.py +15 -0
- parsek_cdp/cdp/systeminfo/events.py +13 -0
- parsek_cdp/cdp/systeminfo/functions.py +75 -0
- parsek_cdp/cdp/systeminfo/types.py +132 -0
- parsek_cdp/cdp/target/__init__.py +15 -0
- parsek_cdp/cdp/target/events.py +109 -0
- parsek_cdp/cdp/target/functions.py +424 -0
- parsek_cdp/cdp/target/types.py +83 -0
- parsek_cdp/cdp/tethering/__init__.py +15 -0
- parsek_cdp/cdp/tethering/events.py +22 -0
- parsek_cdp/cdp/tethering/functions.py +35 -0
- parsek_cdp/cdp/tethering/types.py +10 -0
- parsek_cdp/cdp/tracing/__init__.py +15 -0
- parsek_cdp/cdp/tracing/events.py +61 -0
- parsek_cdp/cdp/tracing/functions.py +119 -0
- parsek_cdp/cdp/tracing/types.py +81 -0
- parsek_cdp/cdp/webaudio/__init__.py +15 -0
- parsek_cdp/cdp/webaudio/events.py +184 -0
- parsek_cdp/cdp/webaudio/functions.py +51 -0
- parsek_cdp/cdp/webaudio/types.py +155 -0
- parsek_cdp/cdp/webauthn/__init__.py +15 -0
- parsek_cdp/cdp/webauthn/events.py +69 -0
- parsek_cdp/cdp/webauthn/functions.py +214 -0
- parsek_cdp/cdp/webauthn/types.py +96 -0
- parsek_cdp/core/__init__.py +18 -0
- parsek_cdp/core/browser.py +240 -0
- parsek_cdp/core/browser_context.py +58 -0
- parsek_cdp/core/element.py +405 -0
- parsek_cdp/core/feature.py +376 -0
- parsek_cdp/core/frame.py +351 -0
- parsek_cdp/core/pagable.py +175 -0
- parsek_cdp/core/page.py +163 -0
- parsek_cdp/core/target.py +490 -0
- parsek_cdp/features/__init__.py +14 -0
- parsek_cdp/features/request_listener/__init__.py +256 -0
- parsek_cdp/features/request_listener/events.py +48 -0
- parsek_cdp/features/request_listener/types.py +235 -0
- parsek_cdp/generate.py +642 -0
- parsek_cdp/parsek/__init__.py +18 -0
- parsek_cdp/parsek/events.py +38 -0
- parsek_cdp/parsek/types.py +36 -0
- parsek_cdp/py.typed +0 -0
- parsek_cdp-0.1.0.dist-info/METADATA +277 -0
- parsek_cdp-0.1.0.dist-info/RECORD +242 -0
- parsek_cdp-0.1.0.dist-info/WHEEL +5 -0
- parsek_cdp-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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]
|