webtap-tool 0.2.3__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of webtap-tool might be problematic. Click here for more details.
- webtap/__init__.py +2 -27
- webtap/api.py +0 -15
- webtap/app.py +4 -4
- webtap/commands/filters.py +95 -62
- webtap/commands/network.py +30 -8
- webtap/commands/server.py +180 -0
- webtap/filters.py +116 -56
- {webtap_tool-0.2.3.dist-info → webtap_tool-0.4.0.dist-info}/METADATA +2 -2
- {webtap_tool-0.2.3.dist-info → webtap_tool-0.4.0.dist-info}/RECORD +11 -10
- {webtap_tool-0.2.3.dist-info → webtap_tool-0.4.0.dist-info}/WHEEL +0 -0
- {webtap_tool-0.2.3.dist-info → webtap_tool-0.4.0.dist-info}/entry_points.txt +0 -0
webtap/__init__.py
CHANGED
|
@@ -9,14 +9,9 @@ PUBLIC API:
|
|
|
9
9
|
- main: Entry point function for CLI
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
import atexit
|
|
13
12
|
import sys
|
|
14
|
-
import logging
|
|
15
13
|
|
|
16
14
|
from webtap.app import app
|
|
17
|
-
from webtap.api import start_api_server
|
|
18
|
-
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
15
|
|
|
21
16
|
|
|
22
17
|
def main():
|
|
@@ -27,13 +22,9 @@ def main():
|
|
|
27
22
|
- MCP mode (with --mcp flag) for Model Context Protocol server
|
|
28
23
|
- REPL mode (default) for interactive shell
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
The API server for Chrome extension communication must be started
|
|
26
|
+
explicitly using the server('start') command.
|
|
32
27
|
"""
|
|
33
|
-
# Start API server for Chrome extension (except in CLI mode)
|
|
34
|
-
if "--cli" not in sys.argv:
|
|
35
|
-
_start_api_server_safely()
|
|
36
|
-
|
|
37
28
|
if "--mcp" in sys.argv:
|
|
38
29
|
app.mcp.run()
|
|
39
30
|
elif "--cli" in sys.argv:
|
|
@@ -45,20 +36,4 @@ def main():
|
|
|
45
36
|
app.run(title="WebTap - Chrome DevTools Protocol REPL")
|
|
46
37
|
|
|
47
38
|
|
|
48
|
-
def _start_api_server_safely():
|
|
49
|
-
"""Start API server with error handling and cleanup registration."""
|
|
50
|
-
try:
|
|
51
|
-
thread = start_api_server(app.state)
|
|
52
|
-
if thread and app.state:
|
|
53
|
-
app.state.api_thread = thread
|
|
54
|
-
logger.info("API server started on port 8765")
|
|
55
|
-
|
|
56
|
-
# Register cleanup to shut down API server on exit
|
|
57
|
-
atexit.register(lambda: app.state.cleanup() if app.state else None)
|
|
58
|
-
else:
|
|
59
|
-
logger.info("Port 8765 in use by another instance")
|
|
60
|
-
except Exception as e:
|
|
61
|
-
logger.warning(f"Failed to start API server: {e}")
|
|
62
|
-
|
|
63
|
-
|
|
64
39
|
__all__ = ["app", "main"]
|
webtap/api.py
CHANGED
|
@@ -199,21 +199,6 @@ async def disable_all_filters() -> Dict[str, Any]:
|
|
|
199
199
|
return {"enabled": [], "total": 0}
|
|
200
200
|
|
|
201
201
|
|
|
202
|
-
@api.post("/release")
|
|
203
|
-
async def release_port() -> Dict[str, Any]:
|
|
204
|
-
"""Release API port for another WebTap instance."""
|
|
205
|
-
logger.info("Releasing API port for another instance")
|
|
206
|
-
|
|
207
|
-
# Schedule graceful shutdown after response
|
|
208
|
-
def shutdown():
|
|
209
|
-
# Just set the flag to stop uvicorn, don't kill the whole process
|
|
210
|
-
global _shutdown_requested
|
|
211
|
-
_shutdown_requested = True
|
|
212
|
-
|
|
213
|
-
threading.Timer(0.5, shutdown).start()
|
|
214
|
-
return {"message": "Releasing port 8765"}
|
|
215
|
-
|
|
216
|
-
|
|
217
202
|
# Flag to signal shutdown
|
|
218
203
|
_shutdown_requested = False
|
|
219
204
|
|
webtap/app.py
CHANGED
|
@@ -56,10 +56,9 @@ class WebTapState:
|
|
|
56
56
|
app = App(
|
|
57
57
|
"webtap",
|
|
58
58
|
WebTapState,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"tags": {"browser", "debugging", "chrome", "cdp"},
|
|
59
|
+
mcp_config={
|
|
60
|
+
"uri_scheme": "webtap",
|
|
61
|
+
"instructions": "Chrome DevTools Protocol debugger",
|
|
63
62
|
},
|
|
64
63
|
typer_config={
|
|
65
64
|
"add_completion": False, # Hide shell completion options
|
|
@@ -84,6 +83,7 @@ else:
|
|
|
84
83
|
from webtap.commands import inspect # noqa: E402, F401
|
|
85
84
|
from webtap.commands import fetch # noqa: E402, F401
|
|
86
85
|
from webtap.commands import body # noqa: E402, F401
|
|
86
|
+
from webtap.commands import server # noqa: E402, F401
|
|
87
87
|
from webtap.commands import setup # noqa: E402, F401
|
|
88
88
|
from webtap.commands import launch # noqa: E402, F401
|
|
89
89
|
|
webtap/commands/filters.py
CHANGED
|
@@ -7,46 +7,19 @@ from webtap.commands._builders import info_response, error_response
|
|
|
7
7
|
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
8
8
|
def filters(state, action: str = "list", config: dict = None) -> dict: # pyright: ignore[reportArgumentType]
|
|
9
9
|
"""
|
|
10
|
-
Manage network request filters.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- "disable" - Disable category
|
|
24
|
-
- "save" - Save filters to disk
|
|
25
|
-
- "load" - Load filters from disk
|
|
26
|
-
config: Action-specific configuration
|
|
27
|
-
- For show: {"category": "ads"}
|
|
28
|
-
- For add: {"category": "ads", "patterns": ["*ad*"], "type": "domain"}
|
|
29
|
-
- For remove: {"patterns": ["*ad*"], "type": "domain"}
|
|
30
|
-
- For update: {"category": "ads", "domains": [...], "types": [...]}
|
|
31
|
-
- For delete/enable/disable: {"category": "ads"}
|
|
32
|
-
|
|
33
|
-
Examples:
|
|
34
|
-
filters() # List all categories
|
|
35
|
-
filters("list") # Same as above
|
|
36
|
-
filters("show", {"category": "ads"}) # Show ads category details
|
|
37
|
-
filters("add", {"category": "ads",
|
|
38
|
-
"patterns": ["*doubleclick*"]}) # Add domain pattern
|
|
39
|
-
filters("add", {"category": "tracking",
|
|
40
|
-
"patterns": ["Ping"], "type": "type"}) # Add type pattern
|
|
41
|
-
filters("remove", {"patterns": ["*doubleclick*"]}) # Remove pattern
|
|
42
|
-
filters("update", {"category": "ads",
|
|
43
|
-
"domains": ["*google*", "*facebook*"]}) # Replace patterns
|
|
44
|
-
filters("delete", {"category": "ads"}) # Delete category
|
|
45
|
-
filters("save") # Persist to disk
|
|
46
|
-
filters("load") # Load from disk
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Current filter configuration or operation result
|
|
10
|
+
Manage network request filters with include/exclude modes.
|
|
11
|
+
|
|
12
|
+
CDP Types: Document, XHR, Fetch, Image, Stylesheet, Script, Font, Media, Other, Ping
|
|
13
|
+
Domains filter URLs, types filter Chrome's resource loading mechanism.
|
|
14
|
+
|
|
15
|
+
Actions:
|
|
16
|
+
list: Show all categories (default)
|
|
17
|
+
show: Display category - config: {"category": "api"}
|
|
18
|
+
add: Add patterns - config: {"category": "api", "patterns": ["*api*"], "type": "domain", "mode": "include"}
|
|
19
|
+
remove: Remove patterns - config: {"patterns": ["*api*"], "type": "domain"}
|
|
20
|
+
update: Replace category - config: {"category": "api", "mode": "include", "domains": [...], "types": ["XHR", "Fetch"]}
|
|
21
|
+
delete/enable/disable: Manage category - config: {"category": "api"}
|
|
22
|
+
save/load: Persist to/from disk
|
|
50
23
|
"""
|
|
51
24
|
fm = state.service.filters
|
|
52
25
|
cfg = config or {}
|
|
@@ -54,14 +27,35 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
54
27
|
# Handle load operation
|
|
55
28
|
if action == "load":
|
|
56
29
|
if fm.load():
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
]
|
|
64
|
-
|
|
30
|
+
# Build table data
|
|
31
|
+
categories = fm.get_categories_summary()
|
|
32
|
+
rows = []
|
|
33
|
+
for cat in categories:
|
|
34
|
+
mode = cat["mode"] or "exclude"
|
|
35
|
+
mode_display = "include" if mode == "include" else "exclude"
|
|
36
|
+
if cat["mode"] is None:
|
|
37
|
+
mode_display = "exclude*" # Asterisk indicates missing mode field
|
|
38
|
+
|
|
39
|
+
rows.append(
|
|
40
|
+
{
|
|
41
|
+
"Category": cat["name"],
|
|
42
|
+
"Status": "enabled" if cat["enabled"] else "disabled",
|
|
43
|
+
"Mode": mode_display,
|
|
44
|
+
"Domains": str(cat["domain_count"]),
|
|
45
|
+
"Types": str(cat["type_count"]),
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
elements = [
|
|
50
|
+
{"type": "heading", "content": "Filters Loaded", "level": 2},
|
|
51
|
+
{"type": "text", "content": f"From: `{fm.filter_path}`"},
|
|
52
|
+
{"type": "table", "headers": ["Category", "Status", "Mode", "Domains", "Types"], "rows": rows},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
if any(cat["mode"] is None for cat in categories):
|
|
56
|
+
elements.append({"type": "text", "content": "_* Mode not specified, defaulting to exclude_"})
|
|
57
|
+
|
|
58
|
+
return {"elements": elements}
|
|
65
59
|
else:
|
|
66
60
|
return error_response(f"No filters found at {fm.filter_path}")
|
|
67
61
|
|
|
@@ -82,6 +76,7 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
82
76
|
category = cfg.get("category", "custom")
|
|
83
77
|
patterns = cfg.get("patterns", [])
|
|
84
78
|
pattern_type = cfg.get("type", "domain")
|
|
79
|
+
mode = cfg.get("mode") # Required, no default
|
|
85
80
|
|
|
86
81
|
if not patterns:
|
|
87
82
|
# Legacy single pattern support
|
|
@@ -94,11 +89,14 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
94
89
|
|
|
95
90
|
added = []
|
|
96
91
|
failed = []
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
92
|
+
try:
|
|
93
|
+
for pattern in patterns:
|
|
94
|
+
if fm.add_pattern(pattern, category, pattern_type, mode):
|
|
95
|
+
added.append(pattern)
|
|
96
|
+
else:
|
|
97
|
+
failed.append(pattern)
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
return error_response(str(e))
|
|
102
100
|
|
|
103
101
|
if added and not failed:
|
|
104
102
|
return info_response(
|
|
@@ -107,6 +105,7 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
107
105
|
"Type": "Domain pattern" if pattern_type == "domain" else "Resource type",
|
|
108
106
|
"Patterns": ", ".join(added),
|
|
109
107
|
"Category": category,
|
|
108
|
+
"Mode": mode,
|
|
110
109
|
},
|
|
111
110
|
)
|
|
112
111
|
elif failed:
|
|
@@ -149,8 +148,10 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
149
148
|
return error_response("'category' required for update action")
|
|
150
149
|
|
|
151
150
|
category = cfg["category"]
|
|
152
|
-
fm.update_category(category, domains=cfg.get("domains"), types=cfg.get("types"))
|
|
153
|
-
return info_response(
|
|
151
|
+
fm.update_category(category, domains=cfg.get("domains"), types=cfg.get("types"), mode=cfg.get("mode"))
|
|
152
|
+
return info_response(
|
|
153
|
+
title="Category Updated", fields={"Category": category, "Mode": cfg.get("mode", "exclude")}
|
|
154
|
+
)
|
|
154
155
|
|
|
155
156
|
# Handle delete operation
|
|
156
157
|
elif action == "delete":
|
|
@@ -193,10 +194,12 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
193
194
|
if category in fm.filters:
|
|
194
195
|
filters = fm.filters[category]
|
|
195
196
|
enabled = "Enabled" if category in fm.enabled_categories else "Disabled"
|
|
197
|
+
mode = filters.get("mode", "exclude")
|
|
196
198
|
|
|
197
199
|
elements = [
|
|
198
200
|
{"type": "heading", "content": f"Category: {category}", "level": 2},
|
|
199
201
|
{"type": "text", "content": f"**Status:** {enabled}"},
|
|
202
|
+
{"type": "text", "content": f"**Mode:** {mode}"},
|
|
200
203
|
]
|
|
201
204
|
|
|
202
205
|
if filters.get("domains"):
|
|
@@ -212,13 +215,43 @@ def filters(state, action: str = "list", config: dict = None) -> dict: # pyrigh
|
|
|
212
215
|
|
|
213
216
|
# Default list action: show all filters
|
|
214
217
|
elif action == "list" or action == "":
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
218
|
+
if not fm.filters:
|
|
219
|
+
return {
|
|
220
|
+
"elements": [
|
|
221
|
+
{"type": "heading", "content": "Filter Configuration", "level": 2},
|
|
222
|
+
{"type": "text", "content": f"No filters loaded (would load from `{fm.filter_path}`)"},
|
|
223
|
+
{"type": "text", "content": "Use `filters('load')` to load filters from disk"},
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Build table data
|
|
228
|
+
categories = fm.get_categories_summary()
|
|
229
|
+
rows = []
|
|
230
|
+
for cat in categories:
|
|
231
|
+
mode = cat["mode"] or "exclude"
|
|
232
|
+
mode_display = "include" if mode == "include" else "exclude"
|
|
233
|
+
if cat["mode"] is None:
|
|
234
|
+
mode_display = "exclude*"
|
|
235
|
+
|
|
236
|
+
rows.append(
|
|
237
|
+
{
|
|
238
|
+
"Category": cat["name"],
|
|
239
|
+
"Status": "enabled" if cat["enabled"] else "disabled",
|
|
240
|
+
"Mode": mode_display,
|
|
241
|
+
"Domains": str(cat["domain_count"]),
|
|
242
|
+
"Types": str(cat["type_count"]),
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
elements = [
|
|
247
|
+
{"type": "heading", "content": "Filter Configuration", "level": 2},
|
|
248
|
+
{"type": "table", "headers": ["Category", "Status", "Mode", "Domains", "Types"], "rows": rows},
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
if any(cat["mode"] is None for cat in categories):
|
|
252
|
+
elements.append({"type": "text", "content": "_* Mode not specified, defaulting to exclude_"})
|
|
253
|
+
|
|
254
|
+
return {"elements": elements}
|
|
222
255
|
|
|
223
256
|
else:
|
|
224
257
|
return error_response(f"Unknown action: {action}")
|
webtap/commands/network.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from typing import List
|
|
4
4
|
|
|
5
5
|
from webtap.app import app
|
|
6
|
-
from webtap.commands._builders import table_response
|
|
7
6
|
from webtap.commands._errors import check_connection
|
|
8
7
|
from webtap.commands._tips import get_tips
|
|
9
8
|
|
|
@@ -75,11 +74,34 @@ def network(state, limit: int = 20, filters: List[str] = None, no_filters: bool
|
|
|
75
74
|
example_id = rows[0]["ID"]
|
|
76
75
|
tips = get_tips("network", context={"id": example_id})
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
# Build response elements manually to add filter tip
|
|
78
|
+
elements = []
|
|
79
|
+
elements.append({"type": "heading", "content": "Network Requests", "level": 2})
|
|
80
|
+
|
|
81
|
+
for warning in warnings or []:
|
|
82
|
+
elements.append({"type": "alert", "message": warning, "level": "warning"})
|
|
83
|
+
|
|
84
|
+
if rows:
|
|
85
|
+
elements.append(
|
|
86
|
+
{"type": "table", "headers": ["ID", "ReqID", "Method", "Status", "URL", "Type", "Size"], "rows": rows}
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
elements.append({"type": "text", "content": "_No data available_"})
|
|
90
|
+
|
|
91
|
+
if f"{len(rows)} requests":
|
|
92
|
+
elements.append({"type": "text", "content": f"_{len(rows)} requests_"})
|
|
93
|
+
|
|
94
|
+
# Always show filter tip demonstrating both type AND domain filtering
|
|
95
|
+
elements.append(
|
|
96
|
+
{
|
|
97
|
+
"type": "alert",
|
|
98
|
+
"message": "Tip: Reduce noise with filters by type or domain. Example: `filters('update', {'category': 'api-only', 'mode': 'include', 'types': ['XHR', 'Fetch'], 'domains': ['*/api/*', '*/graphql/*']})`",
|
|
99
|
+
"level": "info",
|
|
100
|
+
}
|
|
85
101
|
)
|
|
102
|
+
|
|
103
|
+
if tips:
|
|
104
|
+
elements.append({"type": "heading", "content": "Next Steps", "level": 3})
|
|
105
|
+
elements.append({"type": "list", "items": tips})
|
|
106
|
+
|
|
107
|
+
return {"elements": elements}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""API server management command.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- server: Manage API server (status/start/stop/restart)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from webtap.app import app
|
|
12
|
+
from webtap.api import start_api_server
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Fixed port for API server
|
|
17
|
+
API_PORT = 8765
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_port() -> bool:
|
|
21
|
+
"""Check if API port is in use."""
|
|
22
|
+
with socket.socket() as s:
|
|
23
|
+
try:
|
|
24
|
+
s.bind(("127.0.0.1", API_PORT))
|
|
25
|
+
return False # Port is free
|
|
26
|
+
except OSError:
|
|
27
|
+
return True # Port is in use
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _stop_server(state) -> tuple[bool, str]:
|
|
31
|
+
"""Stop the server if this instance owns it."""
|
|
32
|
+
if not state.api_thread or not state.api_thread.is_alive():
|
|
33
|
+
return False, "Not running"
|
|
34
|
+
|
|
35
|
+
import webtap.api
|
|
36
|
+
|
|
37
|
+
webtap.api._shutdown_requested = True
|
|
38
|
+
state.api_thread.join(timeout=2.0)
|
|
39
|
+
state.api_thread = None
|
|
40
|
+
|
|
41
|
+
return True, "Server stopped"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _start_server(state) -> tuple[bool, str]:
|
|
45
|
+
"""Start the server on port 8765."""
|
|
46
|
+
# Check if already running
|
|
47
|
+
if state.api_thread and state.api_thread.is_alive():
|
|
48
|
+
return True, f"Already running on port {API_PORT}"
|
|
49
|
+
|
|
50
|
+
# Check if port is in use
|
|
51
|
+
if _check_port():
|
|
52
|
+
return False, f"Port {API_PORT} already in use by another process"
|
|
53
|
+
|
|
54
|
+
# Start the server
|
|
55
|
+
thread = start_api_server(state, port=API_PORT)
|
|
56
|
+
if thread:
|
|
57
|
+
state.api_thread = thread
|
|
58
|
+
|
|
59
|
+
# Register cleanup
|
|
60
|
+
import atexit
|
|
61
|
+
|
|
62
|
+
atexit.register(lambda: state.cleanup() if state else None)
|
|
63
|
+
|
|
64
|
+
return True, f"Server started on port {API_PORT}"
|
|
65
|
+
else:
|
|
66
|
+
return False, "Failed to start server"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command(
|
|
70
|
+
display="markdown",
|
|
71
|
+
fastmcp={
|
|
72
|
+
"type": "prompt",
|
|
73
|
+
"description": "API server control: status (default), start, stop, restart",
|
|
74
|
+
"arg_descriptions": {"action": "Server action: status (default), start, stop, or restart"},
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
def server(state, action: str = None) -> dict: # pyright: ignore[reportArgumentType]
|
|
78
|
+
"""API server status and management information.
|
|
79
|
+
|
|
80
|
+
Returns current server state and available actions.
|
|
81
|
+
"""
|
|
82
|
+
if action is None:
|
|
83
|
+
action = "status"
|
|
84
|
+
|
|
85
|
+
action = action.lower()
|
|
86
|
+
owns_server = bool(state.api_thread and state.api_thread.is_alive())
|
|
87
|
+
|
|
88
|
+
# Build markdown elements based on action
|
|
89
|
+
elements = []
|
|
90
|
+
|
|
91
|
+
if action == "status" or action not in ["start", "stop", "restart"]:
|
|
92
|
+
# Status information
|
|
93
|
+
elements.append({"type": "heading", "content": "API Server Status", "level": 2})
|
|
94
|
+
|
|
95
|
+
if owns_server:
|
|
96
|
+
elements.append({"type": "text", "content": "**Status:** Running"})
|
|
97
|
+
elements.append({"type": "text", "content": f"**Port:** {API_PORT}"})
|
|
98
|
+
elements.append({"type": "text", "content": f"**URL:** http://127.0.0.1:{API_PORT}"})
|
|
99
|
+
elements.append({"type": "text", "content": f"**Health:** http://127.0.0.1:{API_PORT}/health"})
|
|
100
|
+
elements.append({"type": "text", "content": "**Extension:** Ready to connect"})
|
|
101
|
+
else:
|
|
102
|
+
port_in_use = _check_port()
|
|
103
|
+
if port_in_use:
|
|
104
|
+
elements.append({"type": "alert", "message": "Port 8765 in use by another process", "level": "warning"})
|
|
105
|
+
elements.append({"type": "text", "content": "Cannot start server until port is free"})
|
|
106
|
+
else:
|
|
107
|
+
elements.append({"type": "text", "content": "**Status:** Not running"})
|
|
108
|
+
elements.append({"type": "text", "content": f"**Port:** {API_PORT} (available)"})
|
|
109
|
+
elements.append({"type": "text", "content": "Use `server('start')` to start the API server"})
|
|
110
|
+
|
|
111
|
+
# Available actions
|
|
112
|
+
elements.append({"type": "heading", "content": "Available Actions", "level": 3})
|
|
113
|
+
actions = [
|
|
114
|
+
"`server('start')` - Start the API server",
|
|
115
|
+
"`server('stop')` - Stop the API server",
|
|
116
|
+
"`server('restart')` - Restart the API server",
|
|
117
|
+
"`server('status')` or `server()` - Show this status",
|
|
118
|
+
]
|
|
119
|
+
elements.append({"type": "list", "items": actions})
|
|
120
|
+
|
|
121
|
+
elif action == "start":
|
|
122
|
+
if owns_server:
|
|
123
|
+
elements.append({"type": "alert", "message": f"Server already running on port {API_PORT}", "level": "info"})
|
|
124
|
+
else:
|
|
125
|
+
success, message = _start_server(state)
|
|
126
|
+
if success:
|
|
127
|
+
elements.append({"type": "alert", "message": message, "level": "success"})
|
|
128
|
+
elements.append({"type": "text", "content": f"**URL:** http://127.0.0.1:{API_PORT}"})
|
|
129
|
+
elements.append({"type": "text", "content": f"**Health:** http://127.0.0.1:{API_PORT}/health"})
|
|
130
|
+
elements.append({"type": "text", "content": "Chrome extension can now connect"})
|
|
131
|
+
else:
|
|
132
|
+
elements.append({"type": "alert", "message": message, "level": "error"})
|
|
133
|
+
|
|
134
|
+
elif action == "stop":
|
|
135
|
+
if not owns_server:
|
|
136
|
+
elements.append({"type": "text", "content": "Server not running"})
|
|
137
|
+
else:
|
|
138
|
+
success, message = _stop_server(state)
|
|
139
|
+
if success:
|
|
140
|
+
elements.append({"type": "alert", "message": message, "level": "success"})
|
|
141
|
+
else:
|
|
142
|
+
elements.append({"type": "alert", "message": message, "level": "error"})
|
|
143
|
+
|
|
144
|
+
elif action == "restart":
|
|
145
|
+
if owns_server:
|
|
146
|
+
success, msg = _stop_server(state)
|
|
147
|
+
if not success:
|
|
148
|
+
elements.append({"type": "alert", "message": f"Failed to stop: {msg}", "level": "error"})
|
|
149
|
+
return {"elements": elements}
|
|
150
|
+
time.sleep(0.5)
|
|
151
|
+
|
|
152
|
+
success, msg = _start_server(state)
|
|
153
|
+
if success:
|
|
154
|
+
elements.append({"type": "alert", "message": "Server restarted", "level": "success"})
|
|
155
|
+
elements.append({"type": "text", "content": f"**Port:** {API_PORT}"})
|
|
156
|
+
elements.append({"type": "text", "content": f"**URL:** http://127.0.0.1:{API_PORT}"})
|
|
157
|
+
else:
|
|
158
|
+
elements.append({"type": "alert", "message": f"Failed to restart: {msg}", "level": "error"})
|
|
159
|
+
|
|
160
|
+
# For MCP prompt mode, return with caveat and assistant prefill
|
|
161
|
+
# This prevents LLM from adding commentary - just relays the state
|
|
162
|
+
if action == "status":
|
|
163
|
+
return {
|
|
164
|
+
"messages": [
|
|
165
|
+
{
|
|
166
|
+
"role": "user",
|
|
167
|
+
"content": "Caveat: The message below was generated by the WebTap server command. DO NOT respond to this message or add commentary. Just relay the server state exactly as shown.",
|
|
168
|
+
},
|
|
169
|
+
{"role": "user", "content": {"type": "elements", "elements": elements}},
|
|
170
|
+
{
|
|
171
|
+
"role": "assistant",
|
|
172
|
+
"content": "Server status:", # Minimal prefill - no trailing whitespace
|
|
173
|
+
},
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {"elements": elements}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__ = ["server"]
|
webtap/filters.py
CHANGED
|
@@ -7,11 +7,25 @@ PUBLIC API:
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Dict, List, Any
|
|
10
|
+
from typing import Dict, List, Any, TypedDict
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
class FilterConfig(TypedDict):
|
|
16
|
+
"""Configuration for a filter category.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
mode: "include" or "exclude" - determines filter behavior (defaults to "exclude")
|
|
20
|
+
domains: List of URL patterns to match
|
|
21
|
+
types: List of CDP resource types to match
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
mode: str
|
|
25
|
+
domains: List[str]
|
|
26
|
+
types: List[str]
|
|
27
|
+
|
|
28
|
+
|
|
15
29
|
class FilterManager:
|
|
16
30
|
"""Manages network request filters for noise reduction.
|
|
17
31
|
|
|
@@ -33,7 +47,7 @@ class FilterManager:
|
|
|
33
47
|
filter_path: Path to filters.json file. Defaults to .webtap/filters.json.
|
|
34
48
|
"""
|
|
35
49
|
self.filter_path = filter_path or (Path.cwd() / ".webtap" / "filters.json")
|
|
36
|
-
self.filters: Dict[str,
|
|
50
|
+
self.filters: Dict[str, FilterConfig] = {}
|
|
37
51
|
self.enabled_categories: set[str] = set()
|
|
38
52
|
|
|
39
53
|
def load(self) -> bool:
|
|
@@ -81,7 +95,7 @@ class FilterManager:
|
|
|
81
95
|
logger.error(f"Failed to save filters: {e}")
|
|
82
96
|
return False
|
|
83
97
|
|
|
84
|
-
def add_pattern(self, pattern: str, category: str, pattern_type: str = "domain") -> bool:
|
|
98
|
+
def add_pattern(self, pattern: str, category: str, pattern_type: str = "domain", mode: str | None = None) -> bool:
|
|
85
99
|
"""Add a filter pattern to a category.
|
|
86
100
|
|
|
87
101
|
Creates the category if it doesn't exist and enables it. Supports wildcard
|
|
@@ -91,13 +105,17 @@ class FilterManager:
|
|
|
91
105
|
pattern: Pattern to add (e.g., "*ads*", "googletagmanager.com").
|
|
92
106
|
category: Category name (e.g., "ads", "tracking").
|
|
93
107
|
pattern_type: "domain" or "type". Defaults to "domain".
|
|
108
|
+
mode: "include" or "exclude". Required for new categories.
|
|
94
109
|
|
|
95
110
|
Returns:
|
|
96
111
|
True if pattern was added, False if it already existed.
|
|
97
112
|
"""
|
|
98
113
|
if category not in self.filters:
|
|
99
|
-
|
|
114
|
+
if mode is None:
|
|
115
|
+
raise ValueError(f"Mode required when creating new category '{category}'")
|
|
116
|
+
self.filters[category] = {"mode": mode, "domains": [], "types": []}
|
|
100
117
|
self.enabled_categories.add(category)
|
|
118
|
+
# Existing category keeps its mode
|
|
101
119
|
|
|
102
120
|
key = "domains" if pattern_type == "domain" else "types"
|
|
103
121
|
if pattern not in self.filters[category][key]:
|
|
@@ -125,7 +143,9 @@ class FilterManager:
|
|
|
125
143
|
return category
|
|
126
144
|
return ""
|
|
127
145
|
|
|
128
|
-
def update_category(
|
|
146
|
+
def update_category(
|
|
147
|
+
self, category: str, domains: List[str] | None = None, types: List[str] | None = None, mode: str | None = None
|
|
148
|
+
):
|
|
129
149
|
"""Update or create a category with new patterns.
|
|
130
150
|
|
|
131
151
|
Creates the category if it doesn't exist and enables it. If patterns are
|
|
@@ -135,10 +155,15 @@ class FilterManager:
|
|
|
135
155
|
category: Category name.
|
|
136
156
|
domains: List of domain patterns. None leaves existing unchanged.
|
|
137
157
|
types: List of type patterns. None leaves existing unchanged.
|
|
158
|
+
mode: "include" or "exclude". None leaves existing unchanged.
|
|
138
159
|
"""
|
|
139
160
|
if category not in self.filters:
|
|
140
|
-
|
|
161
|
+
if mode is None:
|
|
162
|
+
raise ValueError(f"Mode required when creating new category '{category}'")
|
|
163
|
+
self.filters[category] = {"mode": mode, "domains": [], "types": []}
|
|
141
164
|
|
|
165
|
+
if mode is not None:
|
|
166
|
+
self.filters[category]["mode"] = mode
|
|
142
167
|
if domains is not None:
|
|
143
168
|
self.filters[category]["domains"] = domains
|
|
144
169
|
if types is not None:
|
|
@@ -181,8 +206,8 @@ class FilterManager:
|
|
|
181
206
|
def get_filter_sql(self, use_all: bool = True, categories: List[str] | None = None) -> str:
|
|
182
207
|
"""Generate SQL WHERE clause for filtering CDP events.
|
|
183
208
|
|
|
184
|
-
Creates SQL conditions
|
|
185
|
-
|
|
209
|
+
Creates SQL conditions based on filter mode (include/exclude) for network requests.
|
|
210
|
+
Handles wildcard patterns by converting them to SQL LIKE patterns
|
|
186
211
|
and properly escapes SQL strings.
|
|
187
212
|
|
|
188
213
|
Args:
|
|
@@ -206,41 +231,77 @@ class FilterManager:
|
|
|
206
231
|
if not active_categories:
|
|
207
232
|
return ""
|
|
208
233
|
|
|
209
|
-
|
|
210
|
-
all_domains = []
|
|
211
|
-
all_types = []
|
|
212
|
-
|
|
213
|
-
for category in active_categories:
|
|
214
|
-
all_domains.extend(self.filters[category].get("domains", []))
|
|
215
|
-
all_types.extend(self.filters[category].get("types", []))
|
|
216
|
-
|
|
217
|
-
# Build filter conditions - exclude matching items
|
|
234
|
+
include_conditions = []
|
|
218
235
|
exclude_conditions = []
|
|
219
236
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
for category in active_categories:
|
|
238
|
+
config = self.filters[category]
|
|
239
|
+
mode = config.get("mode")
|
|
240
|
+
if mode is None:
|
|
241
|
+
logger.error(f"Filter category '{category}' missing required 'mode' field. Skipping.")
|
|
242
|
+
continue # Skip this category entirely
|
|
243
|
+
domains = config.get("domains", [])
|
|
244
|
+
types = config.get("types", [])
|
|
245
|
+
|
|
246
|
+
category_conditions = []
|
|
247
|
+
|
|
248
|
+
# Domain filtering
|
|
249
|
+
if domains:
|
|
250
|
+
domain_conditions = []
|
|
251
|
+
for pattern in domains:
|
|
252
|
+
sql_pattern = pattern.replace("'", "''").replace("*", "%")
|
|
253
|
+
if mode == "include":
|
|
254
|
+
domain_conditions.append(
|
|
255
|
+
f"json_extract_string(event, '$.params.response.url') LIKE '{sql_pattern}'"
|
|
256
|
+
)
|
|
257
|
+
else: # exclude
|
|
258
|
+
domain_conditions.append(
|
|
259
|
+
f"json_extract_string(event, '$.params.response.url') NOT LIKE '{sql_pattern}'"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# For include: OR (match any pattern), for exclude: AND (match none)
|
|
263
|
+
if mode == "include":
|
|
264
|
+
if domain_conditions:
|
|
265
|
+
category_conditions.append(f"({' OR '.join(domain_conditions)})")
|
|
266
|
+
else:
|
|
267
|
+
if domain_conditions:
|
|
268
|
+
category_conditions.append(f"({' AND '.join(domain_conditions)})")
|
|
269
|
+
|
|
270
|
+
# Type filtering
|
|
271
|
+
if types:
|
|
272
|
+
escaped_types = [t.replace("'", "''") for t in types]
|
|
273
|
+
type_list = ", ".join(f"'{t}'" for t in escaped_types)
|
|
274
|
+
|
|
275
|
+
if mode == "include":
|
|
276
|
+
category_conditions.append(f"json_extract_string(event, '$.params.type') IN ({type_list})")
|
|
277
|
+
else: # exclude
|
|
278
|
+
category_conditions.append(
|
|
279
|
+
f"(COALESCE(json_extract_string(event, '$.params.type'), '') NOT IN ({type_list}) OR "
|
|
280
|
+
f"json_extract_string(event, '$.params.type') IS NULL)"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Combine domain and type conditions for this category
|
|
284
|
+
if category_conditions:
|
|
285
|
+
category_sql = f"({' AND '.join(category_conditions)})"
|
|
286
|
+
if mode == "include":
|
|
287
|
+
include_conditions.append(category_sql)
|
|
288
|
+
else:
|
|
289
|
+
exclude_conditions.append(category_sql)
|
|
290
|
+
|
|
291
|
+
# Combine all conditions: (include1 OR include2) AND exclude1 AND exclude2
|
|
292
|
+
final_parts = []
|
|
293
|
+
|
|
294
|
+
if include_conditions:
|
|
295
|
+
if len(include_conditions) > 1:
|
|
296
|
+
final_parts.append(f"({' OR '.join(include_conditions)})")
|
|
297
|
+
else:
|
|
298
|
+
final_parts.append(include_conditions[0])
|
|
240
299
|
|
|
241
300
|
if exclude_conditions:
|
|
242
|
-
|
|
243
|
-
|
|
301
|
+
final_parts.extend(exclude_conditions)
|
|
302
|
+
|
|
303
|
+
if final_parts:
|
|
304
|
+
return f"({' AND '.join(final_parts)})"
|
|
244
305
|
|
|
245
306
|
return ""
|
|
246
307
|
|
|
@@ -263,27 +324,26 @@ class FilterManager:
|
|
|
263
324
|
"path": str(self.filter_path),
|
|
264
325
|
}
|
|
265
326
|
|
|
266
|
-
def
|
|
267
|
-
"""Get
|
|
268
|
-
|
|
269
|
-
Creates a human-readable summary of all filter categories with their
|
|
270
|
-
enabled status and pattern counts.
|
|
327
|
+
def get_categories_summary(self) -> List[Dict[str, Any]]:
|
|
328
|
+
"""Get summary data for all filter categories.
|
|
271
329
|
|
|
272
330
|
Returns:
|
|
273
|
-
|
|
331
|
+
List of dicts with category information including name, enabled status,
|
|
332
|
+
mode, and pattern counts.
|
|
274
333
|
"""
|
|
275
|
-
|
|
276
|
-
return f"No filters loaded (would load from {self.filter_path})"
|
|
277
|
-
|
|
278
|
-
lines = [f"Loaded filters from {self.filter_path}:"]
|
|
334
|
+
categories = []
|
|
279
335
|
for category in sorted(self.filters.keys()):
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
336
|
+
config = self.filters[category]
|
|
337
|
+
categories.append(
|
|
338
|
+
{
|
|
339
|
+
"name": category,
|
|
340
|
+
"enabled": category in self.enabled_categories,
|
|
341
|
+
"mode": config.get("mode"), # None if missing
|
|
342
|
+
"domain_count": len(config.get("domains", [])),
|
|
343
|
+
"type_count": len(config.get("types", [])),
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
return categories
|
|
287
347
|
|
|
288
348
|
|
|
289
349
|
__all__ = ["FilterManager"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webtap-tool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Terminal-based web page inspector for AI debugging sessions
|
|
5
5
|
Author-email: Fredrik Angelsen <fredrikangelsen@gmail.com>
|
|
6
6
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -21,7 +21,7 @@ Requires-Dist: platformdirs>=4.4.0
|
|
|
21
21
|
Requires-Dist: protobuf>=6.32.0
|
|
22
22
|
Requires-Dist: pyjwt>=2.10.1
|
|
23
23
|
Requires-Dist: pyyaml>=6.0.2
|
|
24
|
-
Requires-Dist: replkit2[all]>=0.
|
|
24
|
+
Requires-Dist: replkit2[all]>=0.12.0
|
|
25
25
|
Requires-Dist: requests>=2.32.4
|
|
26
26
|
Requires-Dist: uvicorn>=0.35.0
|
|
27
27
|
Requires-Dist: websocket-client>=1.8.0
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
webtap/VISION.md,sha256=kfoJfEPVc4chOrD9tNMDmYBY9rX9KB-286oZj70ALCE,7681
|
|
2
|
-
webtap/__init__.py,sha256=
|
|
3
|
-
webtap/api.py,sha256=
|
|
4
|
-
webtap/app.py,sha256=
|
|
5
|
-
webtap/filters.py,sha256=
|
|
2
|
+
webtap/__init__.py,sha256=DFWJGmqZfX8_h4csLA5pKPR4SkaHBMlUgU-WQIE96Gw,1092
|
|
3
|
+
webtap/api.py,sha256=QLfwO_21uSyxBCsqei45c5Uyg7OVfaVopmBncx9ZRfw,8018
|
|
4
|
+
webtap/app.py,sha256=ViWDB1Opt3Z7UMiT-p1chl3vGj8-h_WiCQdIIR05s6o,3257
|
|
5
|
+
webtap/filters.py,sha256=kRCicGMSV3R_zSvwzqZqksnry6jxJNdXRcgWvpoBLfc,13323
|
|
6
6
|
webtap/cdp/README.md,sha256=0TS0V_dRgRAzBqhddpXWD4S0YVi5wI4JgFJSll_KUBE,5660
|
|
7
7
|
webtap/cdp/__init__.py,sha256=c6NFG0XJnAa5GTe9MLr9mDZcLZqoTQN7A1cvvOfLcgY,453
|
|
8
8
|
webtap/cdp/query.py,sha256=x2Cy7KMolYkTelpROGezOfMFgYnbSlCvHkvvW1v_gLI,4229
|
|
@@ -22,12 +22,13 @@ webtap/commands/connection.py,sha256=ZYV2TmK1LRVFyMneNYswJmnaoi45rFTApQew5Gm-CC0
|
|
|
22
22
|
webtap/commands/console.py,sha256=moGLsZ-k5wtukjrPFkEXjBl-Jj_yj4bHEArPXVmZLUc,2180
|
|
23
23
|
webtap/commands/events.py,sha256=yx3iJgTANKsoGXBMu1WfBOjEW_thmNKMmUTXtamqRtQ,4093
|
|
24
24
|
webtap/commands/fetch.py,sha256=_TzOvJfVzPaw4ZmyI95Qb7rS3iKx2nmp_IL3jaQO_6g,7772
|
|
25
|
-
webtap/commands/filters.py,sha256=
|
|
25
|
+
webtap/commands/filters.py,sha256=jDZ8JcYIZv_K6aupwuAo9uqAi85e3EIKbf38BXz5nnI,10316
|
|
26
26
|
webtap/commands/inspect.py,sha256=6PGN7iDT1oLzQJboNeYozLILrW2VsAzmtMpF3_XhD30,5746
|
|
27
27
|
webtap/commands/javascript.py,sha256=QpQdqqoQwwTyz1lpibZ92XKOL89scu_ndgSjkhaYuDk,3195
|
|
28
28
|
webtap/commands/launch.py,sha256=iZDLundKlxKRLKf3Vz5at42-tp2f-Uj5wZf7fbhBfA0,2202
|
|
29
29
|
webtap/commands/navigation.py,sha256=Mapawp2AZTJQaws2uwlTgMUhqz7HlVTLxiZ06n_MQc0,6071
|
|
30
|
-
webtap/commands/network.py,sha256=
|
|
30
|
+
webtap/commands/network.py,sha256=hli5Z0k3J_dn55IN9l72LiVs--zGWxSdLucJGTraNkY,3833
|
|
31
|
+
webtap/commands/server.py,sha256=5WDNJGCd0Uw-SL0I02iTvC6vP1s97_5f5rEdQzx_smg,6884
|
|
31
32
|
webtap/commands/setup.py,sha256=dov1LaN50nAEMNIuBLSK7mcnwhfn9rtqdTopBm1-PhA,9648
|
|
32
33
|
webtap/services/README.md,sha256=rala_jtnNgSiQ1lFLM7x_UQ4SJZDceAm7dpkQMRTYaI,2346
|
|
33
34
|
webtap/services/__init__.py,sha256=IjFqu0Ak6D-r18aokcQMtenDV3fbelvfjTCejGv6CZ0,570
|
|
@@ -42,7 +43,7 @@ webtap/services/setup/desktop.py,sha256=fXwQa201W-s2mengm_dJZ9BigJopVrO9YFUQcW_T
|
|
|
42
43
|
webtap/services/setup/extension.py,sha256=OvTLuSi5u-kBAkqWAzfYt5lTNZrduXoCMZhFCuMisew,3318
|
|
43
44
|
webtap/services/setup/filters.py,sha256=lAPSLMH_KZQO-7bRkmURwzforx7C3SDrKEw2ZogN-Lo,3220
|
|
44
45
|
webtap/services/setup/platform.py,sha256=7yn-7LQFffgerWzWRtOG-yNEsR36ICThYUAu_N2FAso,4532
|
|
45
|
-
webtap_tool-0.
|
|
46
|
-
webtap_tool-0.
|
|
47
|
-
webtap_tool-0.
|
|
48
|
-
webtap_tool-0.
|
|
46
|
+
webtap_tool-0.4.0.dist-info/METADATA,sha256=NFGGGMTKNUI-nmCWUPX4Sgo0nNCbI_SDqCtE43mfXhk,17588
|
|
47
|
+
webtap_tool-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
48
|
+
webtap_tool-0.4.0.dist-info/entry_points.txt,sha256=iFe575I0CIb1MbfPt0oX2VYyY5gSU_dA551PKVR83TU,39
|
|
49
|
+
webtap_tool-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|