webtap-tool 0.11.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 (64) hide show
  1. webtap/VISION.md +246 -0
  2. webtap/__init__.py +84 -0
  3. webtap/__main__.py +6 -0
  4. webtap/api/__init__.py +9 -0
  5. webtap/api/app.py +26 -0
  6. webtap/api/models.py +69 -0
  7. webtap/api/server.py +111 -0
  8. webtap/api/sse.py +182 -0
  9. webtap/api/state.py +89 -0
  10. webtap/app.py +79 -0
  11. webtap/cdp/README.md +275 -0
  12. webtap/cdp/__init__.py +12 -0
  13. webtap/cdp/har.py +302 -0
  14. webtap/cdp/schema/README.md +41 -0
  15. webtap/cdp/schema/cdp_protocol.json +32785 -0
  16. webtap/cdp/schema/cdp_version.json +8 -0
  17. webtap/cdp/session.py +667 -0
  18. webtap/client.py +81 -0
  19. webtap/commands/DEVELOPER_GUIDE.md +401 -0
  20. webtap/commands/TIPS.md +269 -0
  21. webtap/commands/__init__.py +29 -0
  22. webtap/commands/_builders.py +331 -0
  23. webtap/commands/_code_generation.py +110 -0
  24. webtap/commands/_tips.py +147 -0
  25. webtap/commands/_utils.py +273 -0
  26. webtap/commands/connection.py +220 -0
  27. webtap/commands/console.py +87 -0
  28. webtap/commands/fetch.py +310 -0
  29. webtap/commands/filters.py +116 -0
  30. webtap/commands/javascript.py +73 -0
  31. webtap/commands/js_export.py +73 -0
  32. webtap/commands/launch.py +72 -0
  33. webtap/commands/navigation.py +197 -0
  34. webtap/commands/network.py +136 -0
  35. webtap/commands/quicktype.py +306 -0
  36. webtap/commands/request.py +93 -0
  37. webtap/commands/selections.py +138 -0
  38. webtap/commands/setup.py +219 -0
  39. webtap/commands/to_model.py +163 -0
  40. webtap/daemon.py +185 -0
  41. webtap/daemon_state.py +53 -0
  42. webtap/filters.py +219 -0
  43. webtap/rpc/__init__.py +14 -0
  44. webtap/rpc/errors.py +49 -0
  45. webtap/rpc/framework.py +223 -0
  46. webtap/rpc/handlers.py +625 -0
  47. webtap/rpc/machine.py +84 -0
  48. webtap/services/README.md +83 -0
  49. webtap/services/__init__.py +15 -0
  50. webtap/services/console.py +124 -0
  51. webtap/services/dom.py +547 -0
  52. webtap/services/fetch.py +415 -0
  53. webtap/services/main.py +392 -0
  54. webtap/services/network.py +401 -0
  55. webtap/services/setup/__init__.py +185 -0
  56. webtap/services/setup/chrome.py +233 -0
  57. webtap/services/setup/desktop.py +255 -0
  58. webtap/services/setup/extension.py +147 -0
  59. webtap/services/setup/platform.py +162 -0
  60. webtap/services/state_snapshot.py +86 -0
  61. webtap_tool-0.11.0.dist-info/METADATA +535 -0
  62. webtap_tool-0.11.0.dist-info/RECORD +64 -0
  63. webtap_tool-0.11.0.dist-info/WHEEL +4 -0
  64. webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
webtap/filters.py ADDED
@@ -0,0 +1,219 @@
1
+ """Network request filter management for WebTap."""
2
+
3
+ import json
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class FilterGroup:
13
+ """A named filter group with hide configuration."""
14
+
15
+ hide: dict # {"types": [...], "urls": [...]}
16
+
17
+
18
+ class FilterManager:
19
+ """Manages filter groups with file persistence and memory toggle state.
20
+
21
+ Groups define what to hide (types, URL patterns). Enabled state is in-memory
22
+ only - all groups start disabled on load. Group definitions persist to file.
23
+ """
24
+
25
+ def __init__(self, filter_path: Path | str | None = None):
26
+ """Initialize filter manager.
27
+
28
+ Args:
29
+ filter_path: Path to filters.json. Defaults to .webtap/filters.json.
30
+ """
31
+ if filter_path is None:
32
+ self.filter_path = Path.cwd() / ".webtap" / "filters.json"
33
+ else:
34
+ self.filter_path = Path(filter_path)
35
+ self.groups: dict[str, FilterGroup] = {}
36
+ self.enabled: set[str] = set()
37
+
38
+ def load(self) -> bool:
39
+ """Load group definitions from file. All disabled by default.
40
+
41
+ Returns:
42
+ True if loaded successfully, False otherwise.
43
+ """
44
+ if self.filter_path.exists():
45
+ try:
46
+ with open(self.filter_path) as f:
47
+ data = json.load(f)
48
+ self.groups = {
49
+ name: FilterGroup(hide=cfg.get("hide", {"types": [], "urls": []}))
50
+ for name, cfg in data.get("groups", {}).items()
51
+ }
52
+ self.enabled = set() # All disabled on load
53
+ logger.info(f"Loaded {len(self.groups)} filter groups from {self.filter_path}")
54
+ return True
55
+ except Exception as e:
56
+ logger.error(f"Failed to load filters: {e}")
57
+ self.groups = {}
58
+ return False
59
+ else:
60
+ logger.debug(f"No filters found at {self.filter_path}")
61
+ self.groups = {}
62
+ return False
63
+
64
+ def save(self) -> bool:
65
+ """Save group definitions to file (not enabled state).
66
+
67
+ Returns:
68
+ True if saved successfully, False on error.
69
+ """
70
+ try:
71
+ self.filter_path.parent.mkdir(parents=True, exist_ok=True)
72
+ data = {"groups": {name: {"hide": group.hide} for name, group in self.groups.items()}}
73
+ with open(self.filter_path, "w") as f:
74
+ json.dump(data, f, indent=2)
75
+ logger.info(f"Saved filters to {self.filter_path}")
76
+ return True
77
+ except Exception as e:
78
+ logger.error(f"Failed to save filters: {e}")
79
+ return False
80
+
81
+ def add(self, name: str, hide: dict) -> None:
82
+ """Add a group definition and persist to file.
83
+
84
+ Args:
85
+ name: Group name.
86
+ hide: Filter config {"types": [...], "urls": [...]}.
87
+ """
88
+ # Ensure hide has required keys
89
+ normalized_hide = {
90
+ "types": hide.get("types", []),
91
+ "urls": hide.get("urls", []),
92
+ }
93
+ self.groups[name] = FilterGroup(hide=normalized_hide)
94
+ self.save()
95
+
96
+ def remove(self, name: str) -> bool:
97
+ """Remove a group and persist to file.
98
+
99
+ Args:
100
+ name: Group name to remove.
101
+
102
+ Returns:
103
+ True if removed, False if not found.
104
+ """
105
+ if name in self.groups:
106
+ del self.groups[name]
107
+ self.enabled.discard(name)
108
+ self.save()
109
+ return True
110
+ return False
111
+
112
+ def enable(self, name: str) -> bool:
113
+ """Enable a group (in-memory only).
114
+
115
+ Args:
116
+ name: Group name to enable.
117
+
118
+ Returns:
119
+ True if group exists and was enabled, False otherwise.
120
+ """
121
+ if name in self.groups:
122
+ self.enabled.add(name)
123
+ return True
124
+ return False
125
+
126
+ def disable(self, name: str) -> bool:
127
+ """Disable a group (in-memory only).
128
+
129
+ Args:
130
+ name: Group name to disable.
131
+
132
+ Returns:
133
+ True if group exists and was disabled, False otherwise.
134
+ """
135
+ if name in self.groups:
136
+ self.enabled.discard(name)
137
+ return True
138
+ return False
139
+
140
+ def get_active_filters(self) -> dict:
141
+ """Get consolidated filters from enabled groups (deduplicated).
142
+
143
+ Returns:
144
+ Dict with "types" and "urls" lists from all enabled groups.
145
+ """
146
+ hide_types: set[str] = set()
147
+ hide_urls: set[str] = set()
148
+ for name in self.enabled:
149
+ if name in self.groups:
150
+ hide_types.update(self.groups[name].hide.get("types", []))
151
+ hide_urls.update(self.groups[name].hide.get("urls", []))
152
+ return {"types": list(hide_types), "urls": list(hide_urls)}
153
+
154
+ def get_status(self) -> dict:
155
+ """Get all groups with enabled status.
156
+
157
+ Returns:
158
+ Dict mapping group names to their config and enabled status.
159
+ """
160
+ return {
161
+ name: {
162
+ "enabled": name in self.enabled,
163
+ "hide": group.hide,
164
+ }
165
+ for name, group in self.groups.items()
166
+ }
167
+
168
+ def build_filter_sql(
169
+ self,
170
+ status: int | None = None,
171
+ method: str | None = None,
172
+ type_filter: str | None = None,
173
+ url: str | None = None,
174
+ apply_groups: bool = True,
175
+ ) -> str:
176
+ """Build SQL WHERE conditions for har_summary filtering.
177
+
178
+ Args:
179
+ status: Filter by HTTP status code.
180
+ method: Filter by HTTP method.
181
+ type_filter: Filter by resource type.
182
+ url: Filter by URL pattern (supports * wildcard).
183
+ apply_groups: Apply enabled filter groups.
184
+
185
+ Returns:
186
+ SQL WHERE clause conditions (without WHERE keyword).
187
+ """
188
+ conditions = []
189
+
190
+ # Inline filters
191
+ if status is not None:
192
+ conditions.append(f"status = {status}")
193
+ if method:
194
+ conditions.append(f"UPPER(method) = '{method.upper()}'")
195
+ if type_filter:
196
+ conditions.append(f"LOWER(type) = '{type_filter.lower()}'")
197
+ if url:
198
+ sql_pattern = url.replace("'", "''").replace("*", "%")
199
+ conditions.append(f"url LIKE '{sql_pattern}'")
200
+
201
+ # Apply enabled filter groups
202
+ if apply_groups:
203
+ active = self.get_active_filters()
204
+
205
+ # Hide matching types
206
+ if active["types"]:
207
+ escaped_types = [t.replace("'", "''") for t in active["types"]]
208
+ type_list = ", ".join(f"'{t}'" for t in escaped_types)
209
+ conditions.append(f"type NOT IN ({type_list})")
210
+
211
+ # Hide matching URLs
212
+ for pattern in active["urls"]:
213
+ sql_pattern = pattern.replace("'", "''").replace("*", "%")
214
+ conditions.append(f"url NOT LIKE '{sql_pattern}'")
215
+
216
+ return " AND ".join(conditions) if conditions else ""
217
+
218
+
219
+ __all__ = ["FilterManager"]
webtap/rpc/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """WebTap RPC Framework.
2
+
3
+ PUBLIC API:
4
+ - RPCFramework: Core RPC request/response handler
5
+ - RPCError: Exception class for structured errors
6
+ - ErrorCode: Standard RPC error codes
7
+ - ConnectionState: Connection state machine states
8
+ """
9
+
10
+ from webtap.rpc.errors import ErrorCode, RPCError
11
+ from webtap.rpc.framework import RPCFramework
12
+ from webtap.rpc.machine import ConnectionState
13
+
14
+ __all__ = ["RPCFramework", "RPCError", "ErrorCode", "ConnectionState"]
webtap/rpc/errors.py ADDED
@@ -0,0 +1,49 @@
1
+ """RPC error definitions.
2
+
3
+ PUBLIC API:
4
+ - ErrorCode: Standard JSON-RPC 2.0 error codes
5
+ - RPCError: Structured exception for RPC errors
6
+ """
7
+
8
+ from typing import Any
9
+
10
+
11
+ class ErrorCode:
12
+ """Standard JSON-RPC 2.0 error codes.
13
+
14
+ Attributes:
15
+ METHOD_NOT_FOUND: RPC method not found
16
+ INVALID_STATE: Operation invalid in current state
17
+ STALE_EPOCH: Request epoch does not match current epoch
18
+ INVALID_PARAMS: Invalid or missing parameters
19
+ INTERNAL_ERROR: Internal server error
20
+ NOT_CONNECTED: Not connected to a Chrome page
21
+ """
22
+
23
+ METHOD_NOT_FOUND = "METHOD_NOT_FOUND"
24
+ INVALID_STATE = "INVALID_STATE"
25
+ STALE_EPOCH = "STALE_EPOCH"
26
+ INVALID_PARAMS = "INVALID_PARAMS"
27
+ INTERNAL_ERROR = "INTERNAL_ERROR"
28
+ NOT_CONNECTED = "NOT_CONNECTED"
29
+
30
+
31
+ class RPCError(Exception):
32
+ """Structured RPC error with code, message, and optional data.
33
+
34
+ Args:
35
+ code: Error code from ErrorCode constants
36
+ message: Human-readable error message
37
+ data: Optional additional error details
38
+
39
+ Attributes:
40
+ code: Error code
41
+ message: Error message
42
+ data: Additional error details
43
+ """
44
+
45
+ def __init__(self, code: str, message: str, data: dict[str, Any] | None = None):
46
+ self.code = code
47
+ self.message = message
48
+ self.data = data or {}
49
+ super().__init__(message)
@@ -0,0 +1,223 @@
1
+ """RPC framework for JSON-RPC 2.0 request handling.
2
+
3
+ PUBLIC API:
4
+ - RPCFramework: Core RPC request/response handler with method registration
5
+ - RPCContext: Context passed to RPC handlers
6
+ - HandlerMeta: Metadata for RPC handler registration
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from webtap.rpc.errors import ErrorCode, RPCError
16
+
17
+ if TYPE_CHECKING:
18
+ from webtap.rpc.machine import ConnectionMachine
19
+ from webtap.services.main import WebTapService
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class RPCContext:
26
+ """Context passed to RPC handlers.
27
+
28
+ Attributes:
29
+ service: WebTapService instance for accessing CDP and domain services
30
+ machine: ConnectionMachine for state management
31
+ epoch: Current connection epoch
32
+ request_id: JSON-RPC request ID
33
+ """
34
+
35
+ service: "WebTapService"
36
+ machine: "ConnectionMachine"
37
+ epoch: int
38
+ request_id: str
39
+
40
+
41
+ @dataclass
42
+ class HandlerMeta:
43
+ """Metadata for RPC handler registration.
44
+
45
+ Attributes:
46
+ requires_state: List of valid connection states for this handler
47
+ broadcasts: Whether to trigger SSE broadcast after successful execution. Defaults to True.
48
+ requires_paused_request: Whether to lookup and inject paused request into kwargs. Defaults to False.
49
+ """
50
+
51
+ requires_state: list[str]
52
+ broadcasts: bool = True
53
+ requires_paused_request: bool = False
54
+
55
+
56
+ class RPCFramework:
57
+ """JSON-RPC 2.0 framework with state machine integration.
58
+
59
+ Handles JSON-RPC 2.0 request routing, validation, and response formatting.
60
+ Integrates with ConnectionMachine for state management.
61
+ """
62
+
63
+ def __init__(self, service: "WebTapService"):
64
+ self.service = service
65
+ from webtap.rpc.machine import ConnectionMachine
66
+
67
+ self.machine = ConnectionMachine()
68
+ self.handlers: dict[str, tuple[Callable, HandlerMeta]] = {}
69
+
70
+ def method(
71
+ self,
72
+ name: str,
73
+ requires_state: list[str] | None = None,
74
+ broadcasts: bool = True,
75
+ requires_paused_request: bool = False,
76
+ ) -> Callable:
77
+ """Decorator to register RPC method handlers.
78
+
79
+ Args:
80
+ name: RPC method name (e.g., "connect", "browser.startInspect")
81
+ requires_state: List of valid states for this method. Defaults to None.
82
+ broadcasts: Whether to trigger SSE broadcast after successful execution. Defaults to True.
83
+ requires_paused_request: Auto-lookup paused request by 'id' param and inject as 'paused'. Defaults to False.
84
+
85
+ Returns:
86
+ Decorator function for handler registration.
87
+
88
+ Example:
89
+ @rpc.method("connect")
90
+ def connect(ctx: RPCContext, page_id: str = None) -> dict:
91
+ return {"connected": True}
92
+ """
93
+
94
+ def decorator(func: Callable) -> Callable:
95
+ meta = HandlerMeta(
96
+ requires_state=requires_state or [],
97
+ broadcasts=broadcasts,
98
+ requires_paused_request=requires_paused_request,
99
+ )
100
+ self.handlers[name] = (func, meta)
101
+ return func
102
+
103
+ return decorator
104
+
105
+ async def handle(self, request: dict) -> dict:
106
+ """Handle JSON-RPC 2.0 request.
107
+
108
+ Validates request format, routes to handler, manages state transitions.
109
+
110
+ Args:
111
+ request: JSON-RPC 2.0 request dict
112
+
113
+ Returns:
114
+ JSON-RPC 2.0 response dict (success or error)
115
+ """
116
+ request_id = request.get("id", "")
117
+
118
+ try:
119
+ # Validate JSON-RPC 2.0 format
120
+ if request.get("jsonrpc") != "2.0":
121
+ return self._error_response(request_id, ErrorCode.INVALID_PARAMS, "Invalid JSON-RPC version")
122
+
123
+ method = request.get("method")
124
+ if not method:
125
+ return self._error_response(request_id, ErrorCode.INVALID_PARAMS, "Missing method")
126
+
127
+ params = request.get("params", {})
128
+
129
+ # Find handler
130
+ if method not in self.handlers:
131
+ return self._error_response(request_id, ErrorCode.METHOD_NOT_FOUND, f"Unknown method: {method}")
132
+
133
+ handler, meta = self.handlers[method]
134
+
135
+ # Validate state requirements
136
+ current_state = self.machine.state
137
+ if meta.requires_state and current_state not in meta.requires_state:
138
+ return self._error_response(
139
+ request_id,
140
+ ErrorCode.INVALID_STATE,
141
+ f"Method {method} requires state {meta.requires_state}, current: {current_state}",
142
+ {"current_state": current_state, "required_states": meta.requires_state},
143
+ )
144
+
145
+ # Validate epoch (if provided)
146
+ request_epoch = request.get("epoch")
147
+ if request_epoch is not None and request_epoch != self.machine.epoch:
148
+ return self._error_response(
149
+ request_id,
150
+ ErrorCode.STALE_EPOCH,
151
+ f"Request epoch {request_epoch} does not match current {self.machine.epoch}",
152
+ {"request_epoch": request_epoch, "current_epoch": self.machine.epoch},
153
+ )
154
+
155
+ # Create context
156
+ ctx = RPCContext(
157
+ service=self.service, machine=self.machine, epoch=self.machine.epoch, request_id=request_id
158
+ )
159
+
160
+ # Auto-lookup paused request if required
161
+ if meta.requires_paused_request:
162
+ request_id_param = params.get("id")
163
+ if request_id_param is None:
164
+ return self._error_response(request_id, ErrorCode.INVALID_PARAMS, "Missing 'id' parameter")
165
+
166
+ network_id = self.service.network.get_request_id(request_id_param)
167
+ if not network_id:
168
+ return self._error_response(
169
+ request_id, ErrorCode.INVALID_PARAMS, f"Request {request_id_param} not found"
170
+ )
171
+
172
+ paused = self.service.fetch.get_paused_by_network_id(network_id)
173
+ if not paused:
174
+ return self._error_response(
175
+ request_id, ErrorCode.INVALID_PARAMS, f"Request {request_id_param} is not paused"
176
+ )
177
+
178
+ params["paused"] = paused
179
+
180
+ # Execute handler in thread pool (service methods are sync)
181
+ try:
182
+ result = await asyncio.to_thread(handler, ctx, **params)
183
+
184
+ # Auto-broadcast for state-modifying handlers
185
+ if meta.broadcasts:
186
+ self.service._trigger_broadcast()
187
+
188
+ return self._success_response(request_id, result)
189
+ except RPCError as e:
190
+ return self._error_response(request_id, e.code, e.message, e.data)
191
+ except TypeError as e:
192
+ # Parameter mismatch (missing/extra params)
193
+ return self._error_response(request_id, ErrorCode.INVALID_PARAMS, f"Invalid parameters: {e}")
194
+ except Exception as e:
195
+ logger.exception(f"RPC handler error: {method}")
196
+ return self._error_response(request_id, ErrorCode.INTERNAL_ERROR, str(e))
197
+
198
+ except Exception as e:
199
+ logger.exception("RPC request processing error")
200
+ return self._error_response(request_id, ErrorCode.INTERNAL_ERROR, str(e))
201
+
202
+ def _success_response(self, request_id: str, result: Any) -> dict:
203
+ """Build JSON-RPC 2.0 success response.
204
+
205
+ Args:
206
+ request_id: JSON-RPC request ID
207
+ result: Result data to return
208
+ """
209
+ return {"jsonrpc": "2.0", "id": request_id, "result": result, "epoch": self.machine.epoch}
210
+
211
+ def _error_response(self, request_id: str, code: str, message: str, data: dict | None = None) -> dict:
212
+ """Build JSON-RPC 2.0 error response.
213
+
214
+ Args:
215
+ request_id: JSON-RPC request ID
216
+ code: Error code
217
+ message: Error message
218
+ data: Optional error data
219
+ """
220
+ error: dict[str, Any] = {"code": code, "message": message}
221
+ if data:
222
+ error["data"] = data
223
+ return {"jsonrpc": "2.0", "id": request_id, "error": error, "epoch": self.machine.epoch}