web2cli 0.2.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.
- web2cli/__init__.py +3 -0
- web2cli/__main__.py +5 -0
- web2cli/adapter/__init__.py +0 -0
- web2cli/adapter/lint.py +667 -0
- web2cli/adapter/loader.py +157 -0
- web2cli/adapter/validator.py +127 -0
- web2cli/adapters/discord.com/web2cli.yaml +476 -0
- web2cli/adapters/mail.google.com/parsers/inbox.py +200 -0
- web2cli/adapters/mail.google.com/web2cli.yaml +52 -0
- web2cli/adapters/news.ycombinator.com/web2cli.yaml +356 -0
- web2cli/adapters/reddit.com/web2cli.yaml +233 -0
- web2cli/adapters/slack.com/web2cli.yaml +445 -0
- web2cli/adapters/stackoverflow.com/web2cli.yaml +257 -0
- web2cli/adapters/x.com/providers/x_graphql.py +299 -0
- web2cli/adapters/x.com/web2cli.yaml +449 -0
- web2cli/auth/__init__.py +0 -0
- web2cli/auth/browser_login.py +820 -0
- web2cli/auth/manager.py +166 -0
- web2cli/auth/store.py +68 -0
- web2cli/cli.py +1286 -0
- web2cli/executor/__init__.py +0 -0
- web2cli/executor/http.py +113 -0
- web2cli/output/__init__.py +0 -0
- web2cli/output/formatter.py +116 -0
- web2cli/parser/__init__.py +0 -0
- web2cli/parser/custom.py +21 -0
- web2cli/parser/html_parser.py +111 -0
- web2cli/parser/transforms.py +127 -0
- web2cli/pipe.py +10 -0
- web2cli/providers/__init__.py +6 -0
- web2cli/providers/base.py +22 -0
- web2cli/providers/registry.py +86 -0
- web2cli/runtime/__init__.py +1 -0
- web2cli/runtime/cache.py +42 -0
- web2cli/runtime/engine.py +743 -0
- web2cli/runtime/parser.py +398 -0
- web2cli/runtime/template.py +52 -0
- web2cli/types.py +71 -0
- web2cli-0.2.0.dist-info/METADATA +467 -0
- web2cli-0.2.0.dist-info/RECORD +44 -0
- web2cli-0.2.0.dist-info/WHEEL +5 -0
- web2cli-0.2.0.dist-info/entry_points.txt +2 -0
- web2cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- web2cli-0.2.0.dist-info/top_level.txt +1 -0
web2cli/__init__.py
ADDED
web2cli/__main__.py
ADDED
|
File without changes
|
web2cli/adapter/lint.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Semantic adapter linter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from web2cli.types import AdapterSpec
|
|
10
|
+
from web2cli.providers import get_provider
|
|
11
|
+
|
|
12
|
+
_TPL_RE = re.compile(r"\{\{([^{}]+)\}\}")
|
|
13
|
+
|
|
14
|
+
_VALID_AUTH_TYPES = {"cookies", "token"}
|
|
15
|
+
_VALID_AUTH_INJECT_TARGETS = {"header", "query", "cookie", "form"}
|
|
16
|
+
_VALID_AUTH_CAPTURE_SOURCES = {"request.header", "request.form"}
|
|
17
|
+
_VALID_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
|
18
|
+
_VALID_BODY_ENCODINGS = {"json", "form", "text", "bytes"}
|
|
19
|
+
_VALID_PARSE_FORMATS = {"json", "json_list", "html"}
|
|
20
|
+
_VALID_FIELD_OPS = {"map_lookup", "regex_replace", "append_urls", "join", "add", "template"}
|
|
21
|
+
_VALID_ITEM_OPS = {"flatten_tree"}
|
|
22
|
+
_VALID_POST_OPS = {"reverse", "sort", "limit", "filter_not_empty", "concat"}
|
|
23
|
+
_VALID_TRANSFORMS = {
|
|
24
|
+
"round",
|
|
25
|
+
"int",
|
|
26
|
+
"lowercase",
|
|
27
|
+
"uppercase",
|
|
28
|
+
"strip_html",
|
|
29
|
+
"timestamp",
|
|
30
|
+
"x_datetime",
|
|
31
|
+
"x_date",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LintIssue:
|
|
37
|
+
level: str # error | warning
|
|
38
|
+
path: str
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lint_adapter(spec: AdapterSpec) -> list[LintIssue]:
|
|
43
|
+
"""Run semantic lint checks for a loaded adapter."""
|
|
44
|
+
issues: list[LintIssue] = []
|
|
45
|
+
|
|
46
|
+
_lint_meta(spec, issues)
|
|
47
|
+
_lint_auth(spec, issues)
|
|
48
|
+
_lint_resources_structure(spec, issues)
|
|
49
|
+
_lint_commands(spec, issues)
|
|
50
|
+
return issues
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lint_meta(spec: AdapterSpec, issues: list[LintIssue]) -> None:
|
|
54
|
+
if not spec.meta.spec_version.startswith("0.2"):
|
|
55
|
+
_err(
|
|
56
|
+
issues,
|
|
57
|
+
"meta.spec_version",
|
|
58
|
+
f"expected spec_version 0.2, got '{spec.meta.spec_version}'",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lint_auth(spec: AdapterSpec, issues: list[LintIssue]) -> None:
|
|
63
|
+
if not spec.auth:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
methods = spec.auth.get("methods", [])
|
|
67
|
+
if not isinstance(methods, list):
|
|
68
|
+
_err(issues, "auth.methods", "must be a list")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
for idx, method in enumerate(methods):
|
|
72
|
+
path = f"auth.methods[{idx}]"
|
|
73
|
+
if not isinstance(method, dict):
|
|
74
|
+
_err(issues, path, "must be an object")
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
mtype = str(method.get("type", "")).lower()
|
|
78
|
+
if mtype and mtype not in _VALID_AUTH_TYPES:
|
|
79
|
+
_err(issues, f"{path}.type", f"unsupported type '{mtype}'")
|
|
80
|
+
|
|
81
|
+
inject = method.get("inject")
|
|
82
|
+
if inject is not None:
|
|
83
|
+
if not isinstance(inject, dict):
|
|
84
|
+
_err(issues, f"{path}.inject", "must be an object")
|
|
85
|
+
else:
|
|
86
|
+
target = str(inject.get("target", "")).lower()
|
|
87
|
+
if target not in _VALID_AUTH_INJECT_TARGETS:
|
|
88
|
+
_err(
|
|
89
|
+
issues,
|
|
90
|
+
f"{path}.inject.target",
|
|
91
|
+
f"unsupported target '{target}', expected one of "
|
|
92
|
+
f"{sorted(_VALID_AUTH_INJECT_TARGETS)}",
|
|
93
|
+
)
|
|
94
|
+
if not inject.get("key"):
|
|
95
|
+
_err(issues, f"{path}.inject.key", "is required when inject is set")
|
|
96
|
+
|
|
97
|
+
capture = method.get("capture")
|
|
98
|
+
if capture is None:
|
|
99
|
+
continue
|
|
100
|
+
if not isinstance(capture, dict):
|
|
101
|
+
_err(issues, f"{path}.capture", "must be an object")
|
|
102
|
+
continue
|
|
103
|
+
if mtype != "token":
|
|
104
|
+
_err(issues, f"{path}.capture", "is supported only for token auth methods")
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
source = str(capture.get("from", "")).lower()
|
|
108
|
+
if source not in _VALID_AUTH_CAPTURE_SOURCES:
|
|
109
|
+
_err(
|
|
110
|
+
issues,
|
|
111
|
+
f"{path}.capture.from",
|
|
112
|
+
f"unsupported source '{source}', expected one of "
|
|
113
|
+
f"{sorted(_VALID_AUTH_CAPTURE_SOURCES)}",
|
|
114
|
+
)
|
|
115
|
+
key = capture.get("key")
|
|
116
|
+
if not isinstance(key, str) or not key.strip():
|
|
117
|
+
_err(issues, f"{path}.capture.key", "is required")
|
|
118
|
+
|
|
119
|
+
match = capture.get("match")
|
|
120
|
+
if match is not None and not isinstance(match, dict):
|
|
121
|
+
_err(issues, f"{path}.capture.match", "must be an object")
|
|
122
|
+
continue
|
|
123
|
+
if isinstance(match, dict):
|
|
124
|
+
host = match.get("host")
|
|
125
|
+
if host is not None and (not isinstance(host, str) or not host.strip()):
|
|
126
|
+
_err(issues, f"{path}.capture.match.host", "must be a non-empty string")
|
|
127
|
+
|
|
128
|
+
path_regex = match.get("path_regex")
|
|
129
|
+
if path_regex is not None:
|
|
130
|
+
if not isinstance(path_regex, str) or not path_regex.strip():
|
|
131
|
+
_err(
|
|
132
|
+
issues,
|
|
133
|
+
f"{path}.capture.match.path_regex",
|
|
134
|
+
"must be a non-empty string",
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
re.compile(path_regex)
|
|
139
|
+
except re.error:
|
|
140
|
+
_err(
|
|
141
|
+
issues,
|
|
142
|
+
f"{path}.capture.match.path_regex",
|
|
143
|
+
"must be a valid regex",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
method_name = match.get("method")
|
|
147
|
+
if method_name is not None:
|
|
148
|
+
normalized_method = str(method_name).upper()
|
|
149
|
+
if normalized_method not in _VALID_HTTP_METHODS:
|
|
150
|
+
_err(
|
|
151
|
+
issues,
|
|
152
|
+
f"{path}.capture.match.method",
|
|
153
|
+
f"unsupported HTTP method '{method_name}'",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _lint_resources_structure(spec: AdapterSpec, issues: list[LintIssue]) -> None:
|
|
158
|
+
for resource_name, resource_spec in spec.resources.items():
|
|
159
|
+
path = f"resources.{resource_name}"
|
|
160
|
+
if not isinstance(resource_spec, dict):
|
|
161
|
+
_err(issues, path, "must be an object")
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
request_spec = resource_spec.get("request")
|
|
165
|
+
if isinstance(request_spec, dict):
|
|
166
|
+
_lint_provider(request_spec, f"{path}.request", issues)
|
|
167
|
+
_lint_request_encoding(request_spec, f"{path}.request", issues)
|
|
168
|
+
|
|
169
|
+
paginate = resource_spec.get("paginate")
|
|
170
|
+
if isinstance(paginate, dict):
|
|
171
|
+
location = paginate.get("cursor_location")
|
|
172
|
+
if location and str(location).lower() not in {"params", "body"}:
|
|
173
|
+
_err(
|
|
174
|
+
issues,
|
|
175
|
+
f"{path}.paginate.cursor_location",
|
|
176
|
+
"must be params or body",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _lint_commands(spec: AdapterSpec, issues: list[LintIssue]) -> None:
|
|
181
|
+
for cmd_name, cmd in spec.commands.items():
|
|
182
|
+
cmd_path = f"commands.{cmd_name}"
|
|
183
|
+
arg_names = set(cmd.args.keys())
|
|
184
|
+
known_steps: set[str] = set()
|
|
185
|
+
all_steps: set[str] = set()
|
|
186
|
+
|
|
187
|
+
for idx, raw_step in enumerate(cmd.pipeline):
|
|
188
|
+
step_path = f"{cmd_path}.pipeline[{idx}]"
|
|
189
|
+
if not isinstance(raw_step, dict):
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
step_type = _step_type(raw_step)
|
|
193
|
+
if step_type is None:
|
|
194
|
+
continue
|
|
195
|
+
step_spec = raw_step.get(step_type) or {}
|
|
196
|
+
if not isinstance(step_spec, dict):
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
step_name = str(step_spec.get("name") or raw_step.get("name") or f"{step_type}_{idx}")
|
|
200
|
+
if step_name in all_steps:
|
|
201
|
+
_err(issues, f"{step_path}.{step_type}.name", f"duplicate step name '{step_name}'")
|
|
202
|
+
|
|
203
|
+
if step_type == "request":
|
|
204
|
+
_lint_request_spec(
|
|
205
|
+
step_spec, f"{step_path}.request", issues, arg_names, known_steps
|
|
206
|
+
)
|
|
207
|
+
elif step_type == "resolve":
|
|
208
|
+
_lint_resolve_step(
|
|
209
|
+
spec,
|
|
210
|
+
step_spec,
|
|
211
|
+
f"{step_path}.resolve",
|
|
212
|
+
issues,
|
|
213
|
+
arg_names,
|
|
214
|
+
known_steps,
|
|
215
|
+
)
|
|
216
|
+
elif step_type == "fanout":
|
|
217
|
+
_lint_fanout_step(
|
|
218
|
+
step_spec,
|
|
219
|
+
f"{step_path}.fanout",
|
|
220
|
+
issues,
|
|
221
|
+
arg_names,
|
|
222
|
+
known_steps,
|
|
223
|
+
)
|
|
224
|
+
elif step_type == "parse":
|
|
225
|
+
_lint_parse_step(
|
|
226
|
+
step_spec,
|
|
227
|
+
f"{step_path}.parse",
|
|
228
|
+
issues,
|
|
229
|
+
arg_names,
|
|
230
|
+
known_steps,
|
|
231
|
+
)
|
|
232
|
+
elif step_type == "transform":
|
|
233
|
+
_lint_transform_step(
|
|
234
|
+
step_spec,
|
|
235
|
+
f"{step_path}.transform",
|
|
236
|
+
issues,
|
|
237
|
+
arg_names,
|
|
238
|
+
known_steps,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
all_steps.add(step_name)
|
|
242
|
+
known_steps.add(step_name)
|
|
243
|
+
|
|
244
|
+
from_step = cmd.output.get("from_step")
|
|
245
|
+
if from_step and from_step not in all_steps:
|
|
246
|
+
_err(
|
|
247
|
+
issues,
|
|
248
|
+
f"{cmd_path}.output.from_step",
|
|
249
|
+
f"unknown step '{from_step}'",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _lint_resolve_step(
|
|
254
|
+
spec: AdapterSpec,
|
|
255
|
+
step_spec: dict[str, Any],
|
|
256
|
+
path: str,
|
|
257
|
+
issues: list[LintIssue],
|
|
258
|
+
arg_names: set[str],
|
|
259
|
+
known_steps: set[str],
|
|
260
|
+
) -> None:
|
|
261
|
+
_lint_templates(step_spec, path, issues, arg_names, known_steps)
|
|
262
|
+
|
|
263
|
+
resource_name = step_spec.get("resource")
|
|
264
|
+
if not isinstance(resource_name, str) or not resource_name:
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
resource_spec = spec.resources.get(resource_name)
|
|
268
|
+
if not isinstance(resource_spec, dict):
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
response_spec = resource_spec.get("response") or resource_spec.get("parse")
|
|
272
|
+
if isinstance(response_spec, dict):
|
|
273
|
+
field_names = _field_names(response_spec)
|
|
274
|
+
by = step_spec.get("by")
|
|
275
|
+
value = step_spec.get("value")
|
|
276
|
+
if field_names and isinstance(by, str) and by not in field_names:
|
|
277
|
+
_err(
|
|
278
|
+
issues,
|
|
279
|
+
f"{path}.by",
|
|
280
|
+
f"field '{by}' is not produced by resource '{resource_name}'",
|
|
281
|
+
)
|
|
282
|
+
if field_names and isinstance(value, str) and value not in field_names:
|
|
283
|
+
_err(
|
|
284
|
+
issues,
|
|
285
|
+
f"{path}.value",
|
|
286
|
+
f"field '{value}' is not produced by resource '{resource_name}'",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
_lint_parse_spec(
|
|
290
|
+
response_spec,
|
|
291
|
+
f"resources.{resource_name}.response",
|
|
292
|
+
issues,
|
|
293
|
+
arg_names,
|
|
294
|
+
known_steps,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
request_spec = resource_spec.get("request")
|
|
298
|
+
if isinstance(request_spec, dict):
|
|
299
|
+
_lint_request_spec(
|
|
300
|
+
request_spec,
|
|
301
|
+
f"resources.{resource_name}.request",
|
|
302
|
+
issues,
|
|
303
|
+
arg_names,
|
|
304
|
+
known_steps,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _lint_fanout_step(
|
|
309
|
+
step_spec: dict[str, Any],
|
|
310
|
+
path: str,
|
|
311
|
+
issues: list[LintIssue],
|
|
312
|
+
arg_names: set[str],
|
|
313
|
+
known_steps: set[str],
|
|
314
|
+
) -> None:
|
|
315
|
+
_lint_templates(step_spec, path, issues, arg_names, known_steps)
|
|
316
|
+
request_spec = step_spec.get("request")
|
|
317
|
+
if isinstance(request_spec, dict):
|
|
318
|
+
_lint_request_spec(
|
|
319
|
+
request_spec, f"{path}.request", issues, arg_names, known_steps
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _lint_parse_step(
|
|
324
|
+
step_spec: dict[str, Any],
|
|
325
|
+
path: str,
|
|
326
|
+
issues: list[LintIssue],
|
|
327
|
+
arg_names: set[str],
|
|
328
|
+
known_steps: set[str],
|
|
329
|
+
) -> None:
|
|
330
|
+
from_step = step_spec.get("from")
|
|
331
|
+
if from_step and from_step not in known_steps:
|
|
332
|
+
_err(
|
|
333
|
+
issues,
|
|
334
|
+
f"{path}.from",
|
|
335
|
+
f"unknown step '{from_step}'",
|
|
336
|
+
)
|
|
337
|
+
_lint_parse_spec(step_spec, path, issues, arg_names, known_steps)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _lint_transform_step(
|
|
341
|
+
step_spec: dict[str, Any],
|
|
342
|
+
path: str,
|
|
343
|
+
issues: list[LintIssue],
|
|
344
|
+
arg_names: set[str],
|
|
345
|
+
known_steps: set[str],
|
|
346
|
+
) -> None:
|
|
347
|
+
from_step = step_spec.get("from")
|
|
348
|
+
if from_step and from_step not in known_steps:
|
|
349
|
+
_err(
|
|
350
|
+
issues,
|
|
351
|
+
f"{path}.from",
|
|
352
|
+
f"unknown step '{from_step}'",
|
|
353
|
+
)
|
|
354
|
+
_lint_templates(step_spec, path, issues, arg_names, known_steps)
|
|
355
|
+
_lint_post_ops(step_spec.get("ops"), f"{path}.ops", issues, known_steps)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _lint_request_spec(
|
|
359
|
+
request_spec: dict[str, Any],
|
|
360
|
+
path: str,
|
|
361
|
+
issues: list[LintIssue],
|
|
362
|
+
arg_names: set[str],
|
|
363
|
+
known_steps: set[str],
|
|
364
|
+
) -> None:
|
|
365
|
+
_lint_provider(request_spec, path, issues)
|
|
366
|
+
_lint_request_encoding(request_spec, path, issues)
|
|
367
|
+
_lint_templates(request_spec, path, issues, arg_names, known_steps)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _lint_provider(
|
|
371
|
+
request_spec: dict[str, Any],
|
|
372
|
+
path: str,
|
|
373
|
+
issues: list[LintIssue],
|
|
374
|
+
) -> None:
|
|
375
|
+
provider_name = request_spec.get("provider")
|
|
376
|
+
if not provider_name:
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
get_provider(str(provider_name))
|
|
380
|
+
except Exception as e:
|
|
381
|
+
_err(issues, f"{path}.provider", str(e))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _lint_request_encoding(
|
|
385
|
+
request_spec: dict[str, Any],
|
|
386
|
+
path: str,
|
|
387
|
+
issues: list[LintIssue],
|
|
388
|
+
) -> None:
|
|
389
|
+
body = request_spec.get("body")
|
|
390
|
+
if not isinstance(body, dict):
|
|
391
|
+
return
|
|
392
|
+
if "encoding" not in body:
|
|
393
|
+
return
|
|
394
|
+
encoding = str(body.get("encoding", "")).lower()
|
|
395
|
+
if encoding not in _VALID_BODY_ENCODINGS:
|
|
396
|
+
_err(
|
|
397
|
+
issues,
|
|
398
|
+
f"{path}.body.encoding",
|
|
399
|
+
f"unsupported encoding '{encoding}'",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _lint_parse_spec(
|
|
404
|
+
parse_spec: dict[str, Any],
|
|
405
|
+
path: str,
|
|
406
|
+
issues: list[LintIssue],
|
|
407
|
+
arg_names: set[str],
|
|
408
|
+
known_steps: set[str],
|
|
409
|
+
) -> None:
|
|
410
|
+
parser = parse_spec.get("parser")
|
|
411
|
+
if parser == "custom":
|
|
412
|
+
_warn(
|
|
413
|
+
issues,
|
|
414
|
+
f"{path}.parser",
|
|
415
|
+
"custom parser reduces portability; prefer declarative parse",
|
|
416
|
+
)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
fmt = str(parse_spec.get("format", "json")).lower()
|
|
420
|
+
if fmt not in _VALID_PARSE_FORMATS:
|
|
421
|
+
_err(issues, f"{path}.format", f"unsupported format '{fmt}'")
|
|
422
|
+
|
|
423
|
+
_lint_templates(parse_spec, path, issues, arg_names, known_steps)
|
|
424
|
+
_lint_item_ops(parse_spec.get("item_ops"), f"{path}.item_ops", issues)
|
|
425
|
+
_lint_fields(parse_spec.get("fields"), f"{path}.fields", issues, known_steps)
|
|
426
|
+
_lint_post_ops(parse_spec.get("post_ops"), f"{path}.post_ops", issues, known_steps)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _lint_fields(
|
|
430
|
+
fields: Any,
|
|
431
|
+
path: str,
|
|
432
|
+
issues: list[LintIssue],
|
|
433
|
+
known_steps: set[str],
|
|
434
|
+
) -> None:
|
|
435
|
+
if fields is None:
|
|
436
|
+
return
|
|
437
|
+
if not isinstance(fields, list):
|
|
438
|
+
_err(issues, path, "must be a list")
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
for idx, field in enumerate(fields):
|
|
442
|
+
fpath = f"{path}[{idx}]"
|
|
443
|
+
if not isinstance(field, dict):
|
|
444
|
+
_err(issues, fpath, "must be an object")
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
transform = field.get("transform")
|
|
448
|
+
if isinstance(transform, str) and not _is_known_transform(transform):
|
|
449
|
+
_err(
|
|
450
|
+
issues,
|
|
451
|
+
f"{fpath}.transform",
|
|
452
|
+
f"unsupported transform '{transform}'",
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
ops = field.get("ops")
|
|
456
|
+
if ops is not None:
|
|
457
|
+
if not isinstance(ops, list):
|
|
458
|
+
_err(issues, f"{fpath}.ops", "must be a list")
|
|
459
|
+
else:
|
|
460
|
+
for op_idx, op in enumerate(ops):
|
|
461
|
+
_lint_field_op(op, f"{fpath}.ops[{op_idx}]", issues, known_steps)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _lint_field_op(
|
|
465
|
+
op: Any,
|
|
466
|
+
path: str,
|
|
467
|
+
issues: list[LintIssue],
|
|
468
|
+
known_steps: set[str],
|
|
469
|
+
) -> None:
|
|
470
|
+
if isinstance(op, str):
|
|
471
|
+
if not _is_known_transform(op):
|
|
472
|
+
_err(issues, path, f"unsupported transform '{op}'")
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
if not isinstance(op, dict) or len(op) != 1:
|
|
476
|
+
_err(issues, path, "must be either a transform name or single-key object")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
op_name, cfg = next(iter(op.items()))
|
|
480
|
+
if op_name not in _VALID_FIELD_OPS:
|
|
481
|
+
_err(issues, path, f"unsupported field op '{op_name}'")
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
if op_name == "map_lookup" and isinstance(cfg, dict):
|
|
485
|
+
expr = cfg.get("from")
|
|
486
|
+
if isinstance(expr, str):
|
|
487
|
+
_lint_ctx_expression(expr, f"{path}.map_lookup.from", issues, known_steps)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _lint_item_ops(
|
|
491
|
+
ops: Any,
|
|
492
|
+
path: str,
|
|
493
|
+
issues: list[LintIssue],
|
|
494
|
+
) -> None:
|
|
495
|
+
if ops is None:
|
|
496
|
+
return
|
|
497
|
+
if not isinstance(ops, list):
|
|
498
|
+
_err(issues, path, "must be a list")
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
for idx, op in enumerate(ops):
|
|
502
|
+
ipath = f"{path}[{idx}]"
|
|
503
|
+
if not isinstance(op, dict) or len(op) != 1:
|
|
504
|
+
_err(issues, ipath, "must be a single-key object")
|
|
505
|
+
continue
|
|
506
|
+
name = next(iter(op.keys()))
|
|
507
|
+
if name not in _VALID_ITEM_OPS:
|
|
508
|
+
_err(issues, ipath, f"unsupported item op '{name}'")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _lint_post_ops(
|
|
512
|
+
ops: Any,
|
|
513
|
+
path: str,
|
|
514
|
+
issues: list[LintIssue],
|
|
515
|
+
known_steps: set[str],
|
|
516
|
+
) -> None:
|
|
517
|
+
if ops is None:
|
|
518
|
+
return
|
|
519
|
+
if not isinstance(ops, list):
|
|
520
|
+
_err(issues, path, "must be a list")
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
for idx, op in enumerate(ops):
|
|
524
|
+
opath = f"{path}[{idx}]"
|
|
525
|
+
if isinstance(op, str):
|
|
526
|
+
if op != "reverse":
|
|
527
|
+
_err(issues, opath, f"unsupported post op '{op}'")
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
if not isinstance(op, dict) or len(op) != 1:
|
|
531
|
+
_err(issues, opath, "must be 'reverse' or single-key object")
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
name, cfg = next(iter(op.items()))
|
|
535
|
+
if name not in _VALID_POST_OPS:
|
|
536
|
+
_err(issues, opath, f"unsupported post op '{name}'")
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
if name == "concat":
|
|
540
|
+
if not isinstance(cfg, dict):
|
|
541
|
+
_err(issues, f"{opath}.concat", "must be an object")
|
|
542
|
+
continue
|
|
543
|
+
step_names = cfg.get("steps", [])
|
|
544
|
+
if isinstance(step_names, str):
|
|
545
|
+
step_names = [step_names]
|
|
546
|
+
if not isinstance(step_names, list):
|
|
547
|
+
_err(issues, f"{opath}.concat.steps", "must be a list of step names")
|
|
548
|
+
continue
|
|
549
|
+
for sid, step_name in enumerate(step_names):
|
|
550
|
+
if not isinstance(step_name, str):
|
|
551
|
+
_err(issues, f"{opath}.concat.steps[{sid}]", "must be a string")
|
|
552
|
+
continue
|
|
553
|
+
if step_name not in known_steps:
|
|
554
|
+
_err(
|
|
555
|
+
issues,
|
|
556
|
+
f"{opath}.concat.steps[{sid}]",
|
|
557
|
+
f"unknown step '{step_name}'",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _lint_templates(
|
|
562
|
+
value: Any,
|
|
563
|
+
path: str,
|
|
564
|
+
issues: list[LintIssue],
|
|
565
|
+
arg_names: set[str],
|
|
566
|
+
known_steps: set[str],
|
|
567
|
+
) -> None:
|
|
568
|
+
for expr, expr_path in _iter_templates(value, path):
|
|
569
|
+
_lint_template_expr(expr, expr_path, issues, arg_names, known_steps)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _iter_templates(value: Any, path: str):
|
|
573
|
+
if isinstance(value, str):
|
|
574
|
+
for match in _TPL_RE.finditer(value):
|
|
575
|
+
yield match.group(1).strip(), path
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
if isinstance(value, list):
|
|
579
|
+
for idx, item in enumerate(value):
|
|
580
|
+
yield from _iter_templates(item, f"{path}[{idx}]")
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
if isinstance(value, dict):
|
|
584
|
+
for key, item in value.items():
|
|
585
|
+
key_path = f"{path}.{key}" if path else str(key)
|
|
586
|
+
yield from _iter_templates(item, key_path)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _lint_template_expr(
|
|
590
|
+
expr: str,
|
|
591
|
+
path: str,
|
|
592
|
+
issues: list[LintIssue],
|
|
593
|
+
arg_names: set[str],
|
|
594
|
+
known_steps: set[str],
|
|
595
|
+
) -> None:
|
|
596
|
+
root = _expr_root(expr)
|
|
597
|
+
if root is None:
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
if root == "args":
|
|
601
|
+
match = re.match(r"^\s*args\.([A-Za-z_][A-Za-z0-9_]*)", expr)
|
|
602
|
+
if match and match.group(1) not in arg_names:
|
|
603
|
+
_err(issues, path, f"template references unknown arg '{match.group(1)}'")
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
if root == "steps":
|
|
607
|
+
match = re.match(r"^\s*steps\.([A-Za-z_][A-Za-z0-9_]*)", expr)
|
|
608
|
+
if match and match.group(1) not in known_steps:
|
|
609
|
+
_err(issues, path, f"template references unknown step '{match.group(1)}'")
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
if root in {"item", "index", "auth", "value"}:
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
# Short-form arg expression: {{query}}
|
|
616
|
+
if root in arg_names:
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _lint_ctx_expression(
|
|
621
|
+
expr: str,
|
|
622
|
+
path: str,
|
|
623
|
+
issues: list[LintIssue],
|
|
624
|
+
known_steps: set[str],
|
|
625
|
+
) -> None:
|
|
626
|
+
match = re.match(r"^\s*steps\.([A-Za-z_][A-Za-z0-9_]*)", expr)
|
|
627
|
+
if match and match.group(1) not in known_steps:
|
|
628
|
+
_err(issues, path, f"references unknown step '{match.group(1)}'")
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _expr_root(expr: str) -> str | None:
|
|
632
|
+
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)", expr)
|
|
633
|
+
return match.group(1) if match else None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _is_known_transform(name: str) -> bool:
|
|
637
|
+
if name in _VALID_TRANSFORMS:
|
|
638
|
+
return True
|
|
639
|
+
if name.startswith("truncate:"):
|
|
640
|
+
return True
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _field_names(parse_spec: dict[str, Any]) -> set[str]:
|
|
645
|
+
fields = parse_spec.get("fields")
|
|
646
|
+
if not isinstance(fields, list):
|
|
647
|
+
return set()
|
|
648
|
+
names = set()
|
|
649
|
+
for field in fields:
|
|
650
|
+
if isinstance(field, dict) and isinstance(field.get("name"), str):
|
|
651
|
+
names.add(field["name"])
|
|
652
|
+
return names
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _step_type(raw_step: dict[str, Any]) -> str | None:
|
|
656
|
+
for key in ("request", "resolve", "fanout", "parse", "transform"):
|
|
657
|
+
if key in raw_step:
|
|
658
|
+
return key
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _err(issues: list[LintIssue], path: str, message: str) -> None:
|
|
663
|
+
issues.append(LintIssue(level="error", path=path, message=message))
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _warn(issues: list[LintIssue], path: str, message: str) -> None:
|
|
667
|
+
issues.append(LintIssue(level="warning", path=path, message=message))
|