webtap-tool 0.7.1__py3-none-any.whl → 0.8.1__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 +4 -0
- webtap/api.py +50 -57
- webtap/app.py +5 -0
- webtap/cdp/session.py +77 -25
- webtap/commands/TIPS.md +125 -22
- webtap/commands/_builders.py +7 -1
- webtap/commands/_code_generation.py +110 -0
- webtap/commands/body.py +9 -5
- webtap/commands/connection.py +21 -0
- webtap/commands/javascript.py +13 -25
- webtap/commands/navigation.py +5 -0
- webtap/commands/quicktype.py +268 -0
- webtap/commands/to_model.py +23 -75
- webtap/services/body.py +209 -24
- webtap/services/dom.py +19 -12
- webtap/services/fetch.py +19 -0
- webtap/services/main.py +192 -0
- webtap/services/setup/extension.py +1 -1
- webtap/services/state_snapshot.py +88 -0
- {webtap_tool-0.7.1.dist-info → webtap_tool-0.8.1.dist-info}/METADATA +1 -1
- {webtap_tool-0.7.1.dist-info → webtap_tool-0.8.1.dist-info}/RECORD +23 -20
- {webtap_tool-0.7.1.dist-info → webtap_tool-0.8.1.dist-info}/WHEEL +0 -0
- {webtap_tool-0.7.1.dist-info → webtap_tool-0.8.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Code generation utilities for transforming HTTP bodies into code.
|
|
2
|
+
|
|
3
|
+
Pure transformation functions with no dependencies on services or state.
|
|
4
|
+
Used by to_model(), quicktype(), and future code generation commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_json(content: str) -> tuple[Any, str | None]:
|
|
13
|
+
"""Parse JSON string into Python object.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
content: JSON string to parse.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Tuple of (parsed_data, error_message).
|
|
20
|
+
On success: (data, None)
|
|
21
|
+
On failure: (None, error_string)
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
data, error = parse_json('{"key": "value"}')
|
|
25
|
+
if error:
|
|
26
|
+
return error_response(error)
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(content), None
|
|
30
|
+
except json.JSONDecodeError as e:
|
|
31
|
+
return None, f"Invalid JSON: {e}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_json_path(data: Any, path: str) -> tuple[Any, str | None]:
|
|
35
|
+
"""Extract nested data using simple bracket notation.
|
|
36
|
+
|
|
37
|
+
Supports paths like "data[0]", "results.users", or "data[0].items".
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
data: Dict or list to extract from.
|
|
41
|
+
path: Path using dot and bracket notation.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (extracted_data, error_message).
|
|
45
|
+
On success: (data, None)
|
|
46
|
+
On failure: (None, error_string)
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
result, err = extract_json_path({"data": [1,2,3]}, "data[0]")
|
|
50
|
+
# result = 1, err = None
|
|
51
|
+
|
|
52
|
+
result, err = extract_json_path({"user": {"name": "Bob"}}, "user.name")
|
|
53
|
+
# result = "Bob", err = None
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
parts = path.replace("[", ".").replace("]", "").split(".")
|
|
57
|
+
result = data
|
|
58
|
+
for part in parts:
|
|
59
|
+
if part:
|
|
60
|
+
if part.isdigit():
|
|
61
|
+
result = result[int(part)]
|
|
62
|
+
else:
|
|
63
|
+
result = result[part]
|
|
64
|
+
return result, None
|
|
65
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
66
|
+
return None, f"JSON path '{path}' not found: {e}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_generation_data(data: Any) -> tuple[bool, str | None]:
|
|
70
|
+
"""Validate data structure for code generation.
|
|
71
|
+
|
|
72
|
+
Code generators (Pydantic, quicktype) require dict or list structures.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
data: Data to validate.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tuple of (is_valid, error_message).
|
|
79
|
+
On success: (True, None)
|
|
80
|
+
On failure: (False, error_string)
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
is_valid, error = validate_generation_data({"key": "value"})
|
|
84
|
+
# is_valid = True, error = None
|
|
85
|
+
|
|
86
|
+
is_valid, error = validate_generation_data("string")
|
|
87
|
+
# is_valid = False, error = "Data is str, not dict or list"
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(data, (dict, list)):
|
|
90
|
+
return False, f"Data is {type(data).__name__}, not dict or list"
|
|
91
|
+
return True, None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_output_directory(output: str) -> Path:
|
|
95
|
+
"""Create output directory if needed, return resolved path.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
output: Output file path (can be relative, use ~, etc.).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Resolved absolute Path object.
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
path = ensure_output_directory("~/models/user.py")
|
|
105
|
+
# Creates ~/models/ if it doesn't exist
|
|
106
|
+
# Returns Path("/home/user/models/user.py")
|
|
107
|
+
"""
|
|
108
|
+
output_path = Path(output).expanduser().resolve()
|
|
109
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
return output_path
|
webtap/commands/body.py
CHANGED
|
@@ -11,11 +11,15 @@ mcp_desc = get_mcp_description("body")
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
|
|
14
|
-
def body(state,
|
|
15
|
-
"""Fetch and analyze response body with Python expressions.
|
|
14
|
+
def body(state, event: int, expr: str = None, decode: bool = True, cache: bool = True) -> dict: # pyright: ignore[reportArgumentType]
|
|
15
|
+
"""Fetch and analyze request or response body with Python expressions.
|
|
16
|
+
|
|
17
|
+
Automatically detects event type and fetches appropriate body:
|
|
18
|
+
- Request events (Network.requestWillBeSent): POST/PUT/PATCH request body
|
|
19
|
+
- Response events (Network.responseReceived): response body
|
|
16
20
|
|
|
17
21
|
Args:
|
|
18
|
-
|
|
22
|
+
event: Event row ID from network(), events(), or requests()
|
|
19
23
|
expr: Optional Python expression with 'body' variable
|
|
20
24
|
decode: Auto-decode base64 (default: True)
|
|
21
25
|
cache: Use cached body (default: True)
|
|
@@ -28,7 +32,7 @@ def body(state, response: int, expr: str = None, decode: bool = True, cache: boo
|
|
|
28
32
|
|
|
29
33
|
# Get body from service (with optional caching)
|
|
30
34
|
body_service = state.service.body
|
|
31
|
-
result = body_service.
|
|
35
|
+
result = body_service.get_body(event, use_cache=cache)
|
|
32
36
|
|
|
33
37
|
if "error" in result:
|
|
34
38
|
return error_response(result["error"])
|
|
@@ -66,7 +70,7 @@ def body(state, response: int, expr: str = None, decode: bool = True, cache: boo
|
|
|
66
70
|
# Build markdown response with body in code block
|
|
67
71
|
# DATA-LEVEL TRUNCATION for memory/performance (as per refactor plan)
|
|
68
72
|
MAX_BODY_SIZE = 5000 # Keep data-level truncation for large bodies
|
|
69
|
-
elements = [{"type": "heading", "content": "
|
|
73
|
+
elements = [{"type": "heading", "content": "Body", "level": 2}]
|
|
70
74
|
|
|
71
75
|
# Try to detect content type and format appropriately
|
|
72
76
|
content_preview = body_content[:100]
|
webtap/commands/connection.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from webtap.app import app
|
|
4
4
|
from webtap.commands._builders import check_connection, info_response, table_response, error_response
|
|
5
|
+
from webtap.commands._tips import get_tips
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
@@ -132,12 +133,32 @@ def pages(state) -> dict:
|
|
|
132
133
|
for i, p in enumerate(pages_list)
|
|
133
134
|
]
|
|
134
135
|
|
|
136
|
+
# Get contextual tips
|
|
137
|
+
tips = None
|
|
138
|
+
if rows:
|
|
139
|
+
# Find connected page or first page
|
|
140
|
+
connected_row = next((r for r in rows if r["Connected"] == "Yes"), rows[0])
|
|
141
|
+
page_index = connected_row["Index"]
|
|
142
|
+
|
|
143
|
+
# Get page_id for the example page
|
|
144
|
+
connected_page = next((p for p in pages_list if str(pages_list.index(p)) == page_index), None)
|
|
145
|
+
page_id = connected_page.get("id", "")[:6] if connected_page else ""
|
|
146
|
+
|
|
147
|
+
tips = get_tips("pages", context={"index": page_index, "page_id": page_id})
|
|
148
|
+
|
|
149
|
+
# Build contextual warnings
|
|
150
|
+
warnings = []
|
|
151
|
+
if any(r["Connected"] == "Yes" for r in rows):
|
|
152
|
+
warnings.append("Already connected - call connect(page=N) to switch pages")
|
|
153
|
+
|
|
135
154
|
# Build markdown response
|
|
136
155
|
return table_response(
|
|
137
156
|
title="Chrome Pages",
|
|
138
157
|
headers=["Index", "Title", "URL", "ID", "Connected"],
|
|
139
158
|
rows=rows,
|
|
140
159
|
summary=f"{len(pages_list)} page{'s' if len(pages_list) != 1 else ''} available",
|
|
160
|
+
warnings=warnings if warnings else None,
|
|
161
|
+
tips=tips,
|
|
141
162
|
)
|
|
142
163
|
|
|
143
164
|
|
webtap/commands/javascript.py
CHANGED
|
@@ -23,39 +23,27 @@ def js(
|
|
|
23
23
|
wait_return: bool = True,
|
|
24
24
|
await_promise: bool = False,
|
|
25
25
|
) -> dict:
|
|
26
|
-
"""Execute JavaScript in the browser
|
|
26
|
+
"""Execute JavaScript in the browser.
|
|
27
|
+
|
|
28
|
+
Uses fresh scope by default to avoid redeclaration errors. Set persist=True
|
|
29
|
+
to keep variables across calls. Use selection=N to operate on browser elements.
|
|
27
30
|
|
|
28
31
|
Args:
|
|
29
|
-
code: JavaScript code to execute
|
|
30
|
-
selection: Browser element selection number
|
|
31
|
-
persist: Keep variables in global scope across calls (default: False
|
|
32
|
+
code: JavaScript code to execute
|
|
33
|
+
selection: Browser element selection number - makes 'element' variable available
|
|
34
|
+
persist: Keep variables in global scope across calls (default: False)
|
|
32
35
|
wait_return: Wait for and return result (default: True)
|
|
33
36
|
await_promise: Await promises before returning (default: False)
|
|
34
37
|
|
|
35
38
|
Examples:
|
|
36
|
-
#
|
|
37
|
-
js("
|
|
38
|
-
js("
|
|
39
|
-
js("
|
|
40
|
-
js("
|
|
41
|
-
|
|
42
|
-
# With browser element selection (always fresh scope)
|
|
43
|
-
js("element.offsetWidth", selection=1) # Use element #1
|
|
44
|
-
js("element.classList", selection=2) # Use element #2
|
|
45
|
-
js("element.getBoundingClientRect()", selection=1)
|
|
46
|
-
|
|
47
|
-
# Persistent scope: keep variables across calls
|
|
48
|
-
js("var data = fetch('/api')", persist=True) # data persists
|
|
49
|
-
js("data.json()", persist=True) # Access data from previous call
|
|
50
|
-
|
|
51
|
-
# Async operations
|
|
52
|
-
js("fetch('/api').then(r => r.json())", await_promise=True)
|
|
53
|
-
|
|
54
|
-
# DOM manipulation (no return needed)
|
|
55
|
-
js("document.querySelectorAll('.ad').forEach(e => e.remove())", wait_return=False)
|
|
39
|
+
js("document.title") # Fresh scope (default)
|
|
40
|
+
js("var data = {count: 0}", persist=True) # Persistent state
|
|
41
|
+
js("element.offsetWidth", selection=1) # With browser element
|
|
42
|
+
js("fetch('/api')", await_promise=True) # Async operation
|
|
43
|
+
js("element.remove()", selection=1, wait_return=False) # No return needed
|
|
56
44
|
|
|
57
45
|
Returns:
|
|
58
|
-
|
|
46
|
+
Evaluated result if wait_return=True, otherwise execution status
|
|
59
47
|
"""
|
|
60
48
|
if error := check_connection(state):
|
|
61
49
|
return error
|
webtap/commands/navigation.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from webtap.app import app
|
|
4
4
|
from webtap.commands._builders import check_connection, info_response, table_response, error_response
|
|
5
|
+
from webtap.commands._tips import get_tips
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
@app.command(display="markdown", fastmcp={"type": "tool"})
|
|
@@ -146,6 +147,9 @@ def page(state) -> dict:
|
|
|
146
147
|
except Exception:
|
|
147
148
|
title = current.get("title", "")
|
|
148
149
|
|
|
150
|
+
# Get tips from TIPS.md
|
|
151
|
+
tips = get_tips("page")
|
|
152
|
+
|
|
149
153
|
# Build formatted response
|
|
150
154
|
return info_response(
|
|
151
155
|
title=title or "Untitled Page",
|
|
@@ -154,6 +158,7 @@ def page(state) -> dict:
|
|
|
154
158
|
"ID": current.get("id", ""),
|
|
155
159
|
"Type": current.get("transitionType", ""),
|
|
156
160
|
},
|
|
161
|
+
tips=tips,
|
|
157
162
|
)
|
|
158
163
|
|
|
159
164
|
return error_response("No navigation history available")
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Generate types and schemas from HTTP response bodies using quicktype."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from webtap.app import app
|
|
9
|
+
from webtap.commands._builders import check_connection, success_response, error_response
|
|
10
|
+
from webtap.commands._code_generation import ensure_output_directory
|
|
11
|
+
from webtap.commands._tips import get_mcp_description
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
mcp_desc = get_mcp_description("quicktype")
|
|
15
|
+
|
|
16
|
+
# Header template for generated files
|
|
17
|
+
HEADER_TEMPLATE = """Generated by WebTap from event {event_id}
|
|
18
|
+
Source: {url}
|
|
19
|
+
Method: {method}
|
|
20
|
+
Generated: {timestamp}
|
|
21
|
+
|
|
22
|
+
Do not edit manually."""
|
|
23
|
+
|
|
24
|
+
# Comment syntax per language
|
|
25
|
+
COMMENT_STYLES = {
|
|
26
|
+
"TypeScript": "//",
|
|
27
|
+
"Python": "#",
|
|
28
|
+
"Go": "//",
|
|
29
|
+
"Rust": "//",
|
|
30
|
+
"Java": "//",
|
|
31
|
+
"Kotlin": "//",
|
|
32
|
+
"Swift": "//",
|
|
33
|
+
"C#": "//",
|
|
34
|
+
"C++": "//",
|
|
35
|
+
"Dart": "//",
|
|
36
|
+
"Ruby": "#",
|
|
37
|
+
"JSON Schema": None, # No comments in JSON
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_quicktype(
|
|
42
|
+
json_data: dict | list,
|
|
43
|
+
output: str,
|
|
44
|
+
type_name: str = None, # pyright: ignore[reportArgumentType]
|
|
45
|
+
just_types: bool = True,
|
|
46
|
+
prefer_types: bool = True,
|
|
47
|
+
options: dict = None, # pyright: ignore[reportArgumentType]
|
|
48
|
+
) -> tuple[bool, str]:
|
|
49
|
+
"""Run quicktype CLI with given parameters.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
json_data: JSON data to convert
|
|
53
|
+
output: Output file path
|
|
54
|
+
type_name: Top-level type name
|
|
55
|
+
just_types: Generate only types, no serializers
|
|
56
|
+
prefer_types: Use 'type' instead of 'interface' for TypeScript
|
|
57
|
+
options: Additional quicktype flags
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (success: bool, error_message: str)
|
|
61
|
+
"""
|
|
62
|
+
# Check if quicktype is available
|
|
63
|
+
if not shutil.which("quicktype"):
|
|
64
|
+
return False, "quicktype CLI not found. Install with: npm install -g quicktype"
|
|
65
|
+
|
|
66
|
+
# Determine language from file extension
|
|
67
|
+
output_path = Path(output)
|
|
68
|
+
ext = output_path.suffix.lower()
|
|
69
|
+
|
|
70
|
+
# Build command
|
|
71
|
+
cmd = ["quicktype", "-o", str(output), "--src-lang", "json", "--top-level", type_name]
|
|
72
|
+
|
|
73
|
+
# Add opinionated defaults
|
|
74
|
+
if just_types:
|
|
75
|
+
cmd.append("--just-types")
|
|
76
|
+
|
|
77
|
+
# TypeScript-specific options
|
|
78
|
+
if ext in {".ts", ".tsx"} and prefer_types:
|
|
79
|
+
cmd.append("--prefer-types")
|
|
80
|
+
|
|
81
|
+
# Apply additional options
|
|
82
|
+
for key, val in (options or {}).items():
|
|
83
|
+
flag = f"--{key.replace('_', '-')}"
|
|
84
|
+
if val is True:
|
|
85
|
+
cmd.append(flag)
|
|
86
|
+
elif val is not False and val is not None:
|
|
87
|
+
cmd.extend([flag, str(val)])
|
|
88
|
+
|
|
89
|
+
# Run quicktype
|
|
90
|
+
try:
|
|
91
|
+
subprocess.run(
|
|
92
|
+
cmd,
|
|
93
|
+
input=json.dumps(json_data, indent=2),
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=True,
|
|
97
|
+
timeout=30,
|
|
98
|
+
)
|
|
99
|
+
return True, ""
|
|
100
|
+
except subprocess.CalledProcessError as e:
|
|
101
|
+
return False, f"quicktype failed: {e.stderr.strip() if e.stderr else str(e)}"
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
return False, "quicktype timed out (>30s)"
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return False, f"Unexpected error: {str(e)}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _insert_header(state, event: int, output_path: Path, language: str) -> None:
|
|
109
|
+
"""Insert language-aware header comment into generated file.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
state: WebTap state with CDP session
|
|
113
|
+
event: Event row ID for metadata extraction
|
|
114
|
+
output_path: Path to generated file
|
|
115
|
+
language: Target language (e.g., "TypeScript", "Python")
|
|
116
|
+
"""
|
|
117
|
+
if not HEADER_TEMPLATE or language not in COMMENT_STYLES:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
comment_prefix = COMMENT_STYLES[language]
|
|
121
|
+
if not comment_prefix: # Skip languages without comments (e.g., JSON)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Get event metadata
|
|
126
|
+
event_result = state.cdp.query("SELECT event FROM events WHERE rowid = ?", [event])
|
|
127
|
+
if not event_result:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
event_data = json.loads(event_result[0][0])
|
|
131
|
+
params = event_data.get("params", {})
|
|
132
|
+
|
|
133
|
+
# Extract metadata from event
|
|
134
|
+
request_data = params.get("request", {})
|
|
135
|
+
response_data = params.get("response", {})
|
|
136
|
+
|
|
137
|
+
metadata = {
|
|
138
|
+
"event_id": event,
|
|
139
|
+
"url": request_data.get("url") or response_data.get("url", "N/A"),
|
|
140
|
+
"method": request_data.get("method", "N/A"),
|
|
141
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Format header text with metadata
|
|
145
|
+
header_text = HEADER_TEMPLATE.format(**metadata)
|
|
146
|
+
|
|
147
|
+
# Apply comment syntax to each line
|
|
148
|
+
header_lines = [
|
|
149
|
+
f"{comment_prefix} {line}" if line.strip() else comment_prefix for line in header_text.split("\n")
|
|
150
|
+
]
|
|
151
|
+
header = "\n".join(header_lines)
|
|
152
|
+
|
|
153
|
+
# Prepend header to file
|
|
154
|
+
content = output_path.read_text()
|
|
155
|
+
output_path.write_text(header + "\n\n" + content)
|
|
156
|
+
except Exception:
|
|
157
|
+
# Silent failure - don't break generation if header fails
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
|
|
162
|
+
def quicktype(
|
|
163
|
+
state,
|
|
164
|
+
event: int,
|
|
165
|
+
output: str,
|
|
166
|
+
type_name: str,
|
|
167
|
+
json_path: str = None, # pyright: ignore[reportArgumentType]
|
|
168
|
+
expr: str = None, # pyright: ignore[reportArgumentType]
|
|
169
|
+
just_types: bool = True,
|
|
170
|
+
prefer_types: bool = True,
|
|
171
|
+
options: dict = None, # pyright: ignore[reportArgumentType]
|
|
172
|
+
) -> dict: # pyright: ignore[reportArgumentType]
|
|
173
|
+
"""Generate types/schemas from request or response body using quicktype CLI.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
event: Event row ID from network() or events()
|
|
177
|
+
output: Output file path (extension determines language: .ts, .py, .go, etc.)
|
|
178
|
+
type_name: Top-level type name (e.g., "User", "ApiResponse")
|
|
179
|
+
json_path: Optional JSON path to extract nested data (e.g., "data[0]")
|
|
180
|
+
expr: Optional Python expression to transform data (has 'body' and 'event' variables)
|
|
181
|
+
just_types: Generate only types, no serializers (default: True)
|
|
182
|
+
prefer_types: Use 'type' instead of 'interface' for TypeScript (default: True)
|
|
183
|
+
options: Additional quicktype flags as dict (e.g., {"readonly": True, "nice_property_names": True})
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
quicktype(123, "types/User.ts", "User") # TypeScript interface
|
|
187
|
+
quicktype(123, "models/customer.py", "Customer") # Python dataclass
|
|
188
|
+
quicktype(123, "api.go", "ApiResponse") # Go struct
|
|
189
|
+
quicktype(123, "schema.json", "Schema") # JSON Schema
|
|
190
|
+
quicktype(123, "types.ts", "User", json_path="data[0]") # Extract nested
|
|
191
|
+
quicktype(172, "types/Form.ts", "Form", expr="dict(urllib.parse.parse_qsl(body))") # Parse form data
|
|
192
|
+
quicktype(123, "types.ts", "User", options={"readonly": True}) # Advanced options
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Success message with generation details
|
|
196
|
+
"""
|
|
197
|
+
if error := check_connection(state):
|
|
198
|
+
return error
|
|
199
|
+
|
|
200
|
+
# Prepare data via service layer
|
|
201
|
+
result = state.service.body.prepare_for_generation(event, json_path, expr)
|
|
202
|
+
if result.get("error"):
|
|
203
|
+
return error_response(result["error"], suggestions=result.get("suggestions", []))
|
|
204
|
+
|
|
205
|
+
data = result["data"]
|
|
206
|
+
|
|
207
|
+
# Ensure output directory exists
|
|
208
|
+
output_path = ensure_output_directory(output)
|
|
209
|
+
|
|
210
|
+
# Run quicktype
|
|
211
|
+
success, error_msg = _run_quicktype(
|
|
212
|
+
json_data=data,
|
|
213
|
+
output=str(output_path),
|
|
214
|
+
type_name=type_name,
|
|
215
|
+
just_types=just_types,
|
|
216
|
+
prefer_types=prefer_types,
|
|
217
|
+
options=options,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if not success:
|
|
221
|
+
return error_response(
|
|
222
|
+
error_msg,
|
|
223
|
+
suggestions=[
|
|
224
|
+
"Check that quicktype is installed: npm install -g quicktype",
|
|
225
|
+
"Verify the JSON structure is valid",
|
|
226
|
+
"Try simplifying the data with json_path",
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Detect language from extension
|
|
231
|
+
ext = output_path.suffix.lower()
|
|
232
|
+
lang_map = {
|
|
233
|
+
".ts": "TypeScript",
|
|
234
|
+
".tsx": "TypeScript",
|
|
235
|
+
".py": "Python",
|
|
236
|
+
".go": "Go",
|
|
237
|
+
".rs": "Rust",
|
|
238
|
+
".java": "Java",
|
|
239
|
+
".kt": "Kotlin",
|
|
240
|
+
".swift": "Swift",
|
|
241
|
+
".cs": "C#",
|
|
242
|
+
".cpp": "C++",
|
|
243
|
+
".dart": "Dart",
|
|
244
|
+
".rb": "Ruby",
|
|
245
|
+
".json": "JSON Schema",
|
|
246
|
+
}
|
|
247
|
+
language = lang_map.get(ext, "Unknown")
|
|
248
|
+
|
|
249
|
+
# Add header comment with event metadata
|
|
250
|
+
_insert_header(state, event, output_path, language)
|
|
251
|
+
|
|
252
|
+
# Count lines in generated file
|
|
253
|
+
try:
|
|
254
|
+
file_content = output_path.read_text()
|
|
255
|
+
line_count = len(file_content.splitlines())
|
|
256
|
+
except Exception:
|
|
257
|
+
line_count = "unknown"
|
|
258
|
+
|
|
259
|
+
return success_response(
|
|
260
|
+
"Types generated successfully",
|
|
261
|
+
details={
|
|
262
|
+
"Output": str(output_path),
|
|
263
|
+
"Language": language,
|
|
264
|
+
"Type Name": type_name,
|
|
265
|
+
"Lines": line_count,
|
|
266
|
+
"Size": f"{output_path.stat().st_size} bytes",
|
|
267
|
+
},
|
|
268
|
+
)
|
webtap/commands/to_model.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Generate Pydantic models from HTTP response bodies."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from pathlib import Path
|
|
5
4
|
from datamodel_code_generator import generate, InputFileType, DataModelType
|
|
6
5
|
from webtap.app import app
|
|
7
6
|
from webtap.commands._builders import check_connection, success_response, error_response
|
|
7
|
+
from webtap.commands._code_generation import ensure_output_directory
|
|
8
8
|
from webtap.commands._tips import get_mcp_description
|
|
9
9
|
|
|
10
10
|
|
|
@@ -12,14 +12,20 @@ mcp_desc = get_mcp_description("to_model")
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
|
|
15
|
-
def to_model(state,
|
|
16
|
-
"""Generate Pydantic model from response body using datamodel-codegen.
|
|
15
|
+
def to_model(state, event: int, output: str, model_name: str, json_path: str = None, expr: str = None) -> dict: # pyright: ignore[reportArgumentType]
|
|
16
|
+
"""Generate Pydantic model from request or response body using datamodel-codegen.
|
|
17
17
|
|
|
18
18
|
Args:
|
|
19
|
-
|
|
19
|
+
event: Event row ID from network() or events()
|
|
20
20
|
output: Output file path for generated model (e.g., "models/customers/group.py")
|
|
21
21
|
model_name: Class name for generated model (e.g., "CustomerGroup")
|
|
22
|
-
json_path: Optional JSON path to extract nested data (e.g., "
|
|
22
|
+
json_path: Optional JSON path to extract nested data (e.g., "data[0]")
|
|
23
|
+
expr: Optional Python expression to transform data (has 'body' and 'event' variables)
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
to_model(123, "models/user.py", "User", json_path="data[0]")
|
|
27
|
+
to_model(172, "models/form.py", "Form", expr="dict(urllib.parse.parse_qsl(body))")
|
|
28
|
+
to_model(123, "models/clean.py", "Clean", expr="{k: v for k, v in json.loads(body).items() if k != 'meta'}")
|
|
23
29
|
|
|
24
30
|
Returns:
|
|
25
31
|
Success message with generation details
|
|
@@ -27,73 +33,15 @@ def to_model(state, response: int, output: str, model_name: str, json_path: str
|
|
|
27
33
|
if error := check_connection(state):
|
|
28
34
|
return error
|
|
29
35
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
result
|
|
33
|
-
|
|
34
|
-
if "error" in result:
|
|
35
|
-
return error_response(result["error"])
|
|
36
|
-
|
|
37
|
-
body_content = result.get("body", "")
|
|
38
|
-
is_base64 = result.get("base64Encoded", False)
|
|
39
|
-
|
|
40
|
-
# Decode if needed
|
|
41
|
-
if is_base64:
|
|
42
|
-
decoded = body_service.decode_body(body_content, is_base64)
|
|
43
|
-
if isinstance(decoded, bytes):
|
|
44
|
-
return error_response(
|
|
45
|
-
"Response body is binary",
|
|
46
|
-
suggestions=["Only JSON responses can be converted to models", "Try a different response"],
|
|
47
|
-
)
|
|
48
|
-
body_content = decoded
|
|
36
|
+
# Prepare data via service layer
|
|
37
|
+
result = state.service.body.prepare_for_generation(event, json_path, expr)
|
|
38
|
+
if result.get("error"):
|
|
39
|
+
return error_response(result["error"], suggestions=result.get("suggestions", []))
|
|
49
40
|
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
data = json.loads(body_content)
|
|
53
|
-
except json.JSONDecodeError as e:
|
|
54
|
-
return error_response(
|
|
55
|
-
f"Invalid JSON: {e}",
|
|
56
|
-
suggestions=[
|
|
57
|
-
"Response must be valid JSON",
|
|
58
|
-
"Check the response with body() first",
|
|
59
|
-
"Try a different response",
|
|
60
|
-
],
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
# Extract JSON path if specified
|
|
64
|
-
if json_path:
|
|
65
|
-
try:
|
|
66
|
-
# Support simple bracket notation like "Data[0]"
|
|
67
|
-
parts = json_path.replace("[", ".").replace("]", "").split(".")
|
|
68
|
-
for part in parts:
|
|
69
|
-
if part:
|
|
70
|
-
if part.isdigit():
|
|
71
|
-
data = data[int(part)]
|
|
72
|
-
else:
|
|
73
|
-
data = data[part]
|
|
74
|
-
except (KeyError, IndexError, TypeError) as e:
|
|
75
|
-
return error_response(
|
|
76
|
-
f"JSON path extraction failed: {e}",
|
|
77
|
-
suggestions=[
|
|
78
|
-
f"Path '{json_path}' not found in response",
|
|
79
|
-
"Check the response structure with body()",
|
|
80
|
-
'Try a simpler path like "Data" or "Data[0]"',
|
|
81
|
-
],
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
# Ensure data is dict or list for model generation
|
|
85
|
-
if not isinstance(data, (dict, list)):
|
|
86
|
-
return error_response(
|
|
87
|
-
f"Extracted data is {type(data).__name__}, not dict or list",
|
|
88
|
-
suggestions=[
|
|
89
|
-
"Model generation requires dict or list structure",
|
|
90
|
-
"Adjust json_path to extract a complex object",
|
|
91
|
-
],
|
|
92
|
-
)
|
|
41
|
+
data = result["data"]
|
|
93
42
|
|
|
94
|
-
#
|
|
95
|
-
output_path =
|
|
96
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
# Ensure output directory exists
|
|
44
|
+
output_path = ensure_output_directory(output)
|
|
97
45
|
|
|
98
46
|
# Generate model using datamodel-codegen Python API
|
|
99
47
|
try:
|
|
@@ -103,10 +51,10 @@ def to_model(state, response: int, output: str, model_name: str, json_path: str
|
|
|
103
51
|
input_filename="response.json",
|
|
104
52
|
output=output_path,
|
|
105
53
|
output_model_type=DataModelType.PydanticV2BaseModel,
|
|
106
|
-
class_name=model_name,
|
|
107
|
-
snake_case_field=True,
|
|
108
|
-
use_standard_collections=True,
|
|
109
|
-
use_union_operator=True,
|
|
54
|
+
class_name=model_name,
|
|
55
|
+
snake_case_field=True,
|
|
56
|
+
use_standard_collections=True,
|
|
57
|
+
use_union_operator=True,
|
|
110
58
|
)
|
|
111
59
|
except Exception as e:
|
|
112
60
|
return error_response(
|
|
@@ -121,7 +69,7 @@ def to_model(state, response: int, output: str, model_name: str, json_path: str
|
|
|
121
69
|
# Count fields in generated model
|
|
122
70
|
try:
|
|
123
71
|
model_content = output_path.read_text()
|
|
124
|
-
field_count = model_content.count(": ")
|
|
72
|
+
field_count = model_content.count(": ")
|
|
125
73
|
except Exception:
|
|
126
74
|
field_count = "unknown"
|
|
127
75
|
|