oed-cli 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.
- oed_cli/__init__.py +6 -0
- oed_cli/__main__.py +6 -0
- oed_cli/cli.py +278 -0
- oed_cli/discovery.py +168 -0
- oed_cli/dynamic.py +473 -0
- oed_cli/errors.py +45 -0
- oed_cli/http.py +150 -0
- oed_cli/invoke.py +305 -0
- oed_cli/main.py +409 -0
- oed_cli/py.typed +0 -0
- oed_cli-0.2.0.dist-info/METADATA +373 -0
- oed_cli-0.2.0.dist-info/RECORD +16 -0
- oed_cli-0.2.0.dist-info/WHEEL +5 -0
- oed_cli-0.2.0.dist-info/entry_points.txt +2 -0
- oed_cli-0.2.0.dist-info/licenses/LICENSE +17 -0
- oed_cli-0.2.0.dist-info/top_level.txt +1 -0
oed_cli/main.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Entry point for the ``oed`` console script.
|
|
2
|
+
|
|
3
|
+
This module implements the top-level dispatch:
|
|
4
|
+
|
|
5
|
+
* Reserved sub-commands (``info``, ``services``, ``schema``, ``cache``,
|
|
6
|
+
``completion``, ``--version``, ``--help`` …) go to the click tree in
|
|
7
|
+
:mod:`oed_cli.cli`.
|
|
8
|
+
* Anything else is treated as ``oed <service> [<method>] [flags]`` and
|
|
9
|
+
resolved dynamically via :mod:`oed_cli.dynamic`.
|
|
10
|
+
|
|
11
|
+
Dynamic dispatch is what makes ``oed`` interesting — a new service shows
|
|
12
|
+
up in the discovery feed, and ``oed <new-service> -- ...`` works on the
|
|
13
|
+
next TTL refresh without any code change.
|
|
14
|
+
|
|
15
|
+
Per-parameter flag surface
|
|
16
|
+
--------------------------
|
|
17
|
+
|
|
18
|
+
For each declared ``query`` / ``path`` parameter on an operation,
|
|
19
|
+
``oed`` exposes a dedicated ``--<kebab-case>`` flag — derived from the
|
|
20
|
+
spec name (``cveId`` → ``--cve-id``, ``page_num`` → ``--page-num``).
|
|
21
|
+
``oed <service> <operation> --help`` enumerates them all.
|
|
22
|
+
|
|
23
|
+
The legacy ``--params '{...}'`` JSON form is preserved as an escape
|
|
24
|
+
hatch: useful for rarely-used parameters and for piping bulk data.
|
|
25
|
+
Per-parameter flags and ``--params`` can be mixed; per-parameter flags
|
|
26
|
+
override matching keys from ``--params``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import sys
|
|
33
|
+
from collections.abc import Sequence
|
|
34
|
+
|
|
35
|
+
import click
|
|
36
|
+
|
|
37
|
+
from .cli import cli as click_cli
|
|
38
|
+
from .dynamic import (
|
|
39
|
+
coerce_flag_value,
|
|
40
|
+
coerce_param_types,
|
|
41
|
+
collect_operations,
|
|
42
|
+
fetch_service_spec,
|
|
43
|
+
operations_table,
|
|
44
|
+
param_flag_index,
|
|
45
|
+
parse_json_arg,
|
|
46
|
+
resolve_operation,
|
|
47
|
+
resolve_service_by_name,
|
|
48
|
+
)
|
|
49
|
+
from .errors import OedError
|
|
50
|
+
from .invoke import (
|
|
51
|
+
call_operation,
|
|
52
|
+
describe_operation_help,
|
|
53
|
+
describe_service,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Tokens that always go through the click sub-tree, regardless of whether
|
|
57
|
+
# they happen to match a discovered service. Recognised single tokens:
|
|
58
|
+
RESERVED_FIRST_TOKENS: frozenset[str] = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"info",
|
|
61
|
+
"services",
|
|
62
|
+
"schema",
|
|
63
|
+
"cache",
|
|
64
|
+
"completion",
|
|
65
|
+
"help",
|
|
66
|
+
# the user typed only the binary with no args
|
|
67
|
+
"",
|
|
68
|
+
# passthrough flags
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
# Click passes these verbatim when they're the first argv item
|
|
72
|
+
LEADING_FLAGS: frozenset[str] = frozenset({"-h", "--help", "-V", "--version"})
|
|
73
|
+
|
|
74
|
+
# Built-in control flags handled by the dispatcher itself. Everything
|
|
75
|
+
# else is treated as a candidate per-parameter flag, validated later
|
|
76
|
+
# against the resolved operation's declared parameters.
|
|
77
|
+
_VALUE_FLAGS: frozenset[str] = frozenset({"params", "json", "path"})
|
|
78
|
+
_BOOL_FLAGS: frozenset[str] = frozenset({"dry-run"})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _looks_like_reserved(argv: Sequence[str]) -> bool:
|
|
82
|
+
if not argv:
|
|
83
|
+
return True
|
|
84
|
+
head = argv[0]
|
|
85
|
+
if head in LEADING_FLAGS:
|
|
86
|
+
return True
|
|
87
|
+
if head.startswith("-"):
|
|
88
|
+
return True
|
|
89
|
+
return head in RESERVED_FIRST_TOKENS
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _split_dispatch_argv(rest: list[str]) -> tuple[dict[str, str], set[str], bool, list[str]]:
|
|
93
|
+
"""First-pass split of argv (after ``service_name``) into:
|
|
94
|
+
|
|
95
|
+
- ``raw_flags``: ``--key value`` (or ``--key=value``) pairs
|
|
96
|
+
- ``bool_flags``: ``--key`` with no value (e.g. ``--dry-run``)
|
|
97
|
+
- ``help_requested``: ``--help`` / ``-h`` was seen
|
|
98
|
+
- ``positional``: non-flag arguments
|
|
99
|
+
|
|
100
|
+
Unknown ``--<key>`` flags are kept in ``raw_flags`` so they can be
|
|
101
|
+
matched against the resolved operation's declared parameters before
|
|
102
|
+
being rejected.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
raw_flags: dict[str, str] = {}
|
|
106
|
+
bool_flags: set[str] = set()
|
|
107
|
+
help_requested = False
|
|
108
|
+
positional: list[str] = []
|
|
109
|
+
|
|
110
|
+
while rest:
|
|
111
|
+
tok = rest.pop(0)
|
|
112
|
+
if tok == "--":
|
|
113
|
+
positional.extend(rest)
|
|
114
|
+
break
|
|
115
|
+
if tok in ("-h", "--help"):
|
|
116
|
+
help_requested = True
|
|
117
|
+
continue
|
|
118
|
+
if tok.startswith("--"):
|
|
119
|
+
body = tok[2:]
|
|
120
|
+
if "=" in body:
|
|
121
|
+
k, v = body.split("=", 1)
|
|
122
|
+
if k in _BOOL_FLAGS:
|
|
123
|
+
bool_flags.add(k)
|
|
124
|
+
else:
|
|
125
|
+
raw_flags[k] = v
|
|
126
|
+
continue
|
|
127
|
+
if body in _VALUE_FLAGS:
|
|
128
|
+
if not rest:
|
|
129
|
+
raise OedError(f"--{body} requires a value", kind="missing_flag_value")
|
|
130
|
+
raw_flags[body] = rest.pop(0)
|
|
131
|
+
continue
|
|
132
|
+
if body in _BOOL_FLAGS:
|
|
133
|
+
bool_flags.add(body)
|
|
134
|
+
continue
|
|
135
|
+
# Candidate per-parameter flag: consume the next token as its
|
|
136
|
+
# value unless that token is itself a flag.
|
|
137
|
+
if rest and not rest[0].startswith("-"):
|
|
138
|
+
raw_flags[body] = rest.pop(0)
|
|
139
|
+
else:
|
|
140
|
+
bool_flags.add(body)
|
|
141
|
+
continue
|
|
142
|
+
if tok.startswith("-"):
|
|
143
|
+
raise OedError(f"unknown short flag: {tok}", kind="unknown_flag")
|
|
144
|
+
positional.append(tok)
|
|
145
|
+
|
|
146
|
+
return raw_flags, bool_flags, help_requested, positional
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _merge_params(
|
|
150
|
+
op,
|
|
151
|
+
raw_flags: dict[str, str],
|
|
152
|
+
) -> tuple[dict, dict | None, int]:
|
|
153
|
+
"""Build the final params + body from ``--params`` JSON and per-param flags.
|
|
154
|
+
|
|
155
|
+
Returns ``(params, body, exit_code)`` — exit_code is non-zero when a
|
|
156
|
+
JSON parse error short-circuits the call.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
params: dict = {}
|
|
160
|
+
if "params" in raw_flags:
|
|
161
|
+
try:
|
|
162
|
+
parsed = parse_json_arg(raw_flags["params"], flag="params") or {}
|
|
163
|
+
except OedError as exc:
|
|
164
|
+
return {}, None, exc.code
|
|
165
|
+
if not isinstance(parsed, dict):
|
|
166
|
+
return {}, None, 1
|
|
167
|
+
params.update(parsed)
|
|
168
|
+
|
|
169
|
+
body = None
|
|
170
|
+
if "json" in raw_flags:
|
|
171
|
+
try:
|
|
172
|
+
body = parse_json_arg(raw_flags["json"], flag="json")
|
|
173
|
+
except OedError as exc:
|
|
174
|
+
return params, None, exc.code
|
|
175
|
+
|
|
176
|
+
# Per-parameter flags override matching keys from --params.
|
|
177
|
+
declared: dict[str, dict] = param_flag_index(op)
|
|
178
|
+
declared_names = {
|
|
179
|
+
p["name"] for p in op.parameters if p.get("in") in {"query", "path"}
|
|
180
|
+
}
|
|
181
|
+
for flag_key, value in raw_flags.items():
|
|
182
|
+
if flag_key in _VALUE_FLAGS or flag_key in _BOOL_FLAGS:
|
|
183
|
+
continue
|
|
184
|
+
if flag_key not in declared:
|
|
185
|
+
raise OedError(
|
|
186
|
+
f"unknown flag: --{flag_key}",
|
|
187
|
+
kind="unknown_flag",
|
|
188
|
+
hint=(
|
|
189
|
+
f"Declared parameters for {op.operation_id}: "
|
|
190
|
+
f"{sorted(declared_names) or '(none)'}. "
|
|
191
|
+
"Use `--params '{...}'` for arbitrary JSON."
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
param_def = declared[flag_key]
|
|
195
|
+
params[param_def["name"]] = coerce_flag_value(param_def, value)
|
|
196
|
+
|
|
197
|
+
# Final pass for any --params-derived values that need string→int coercion.
|
|
198
|
+
params = coerce_param_types(op, params)
|
|
199
|
+
return params, body, 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _dispatch_dynamic(argv: Sequence[str]) -> int:
|
|
203
|
+
"""Parse ``oed <service> [<method>] [flags]`` and run the resolved call."""
|
|
204
|
+
|
|
205
|
+
if not argv:
|
|
206
|
+
click.echo(_usage_dynamic(), err=True)
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
service_name = argv[0]
|
|
210
|
+
rest = list(argv[1:])
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
raw_flags, bool_flags, help_requested, positional = _split_dispatch_argv(rest)
|
|
214
|
+
except OedError as exc:
|
|
215
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
216
|
+
return exc.code
|
|
217
|
+
|
|
218
|
+
method = positional[0] if positional else None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
service = resolve_service_by_name(service_name)
|
|
222
|
+
except OedError as exc:
|
|
223
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
224
|
+
return exc.code
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
spec = fetch_service_spec(service)
|
|
228
|
+
except OedError as exc:
|
|
229
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
230
|
+
return exc.code
|
|
231
|
+
|
|
232
|
+
ops = collect_operations(spec, service.service_name)
|
|
233
|
+
|
|
234
|
+
if not method:
|
|
235
|
+
# ``oed <service>`` alone → list every available operation.
|
|
236
|
+
if help_requested:
|
|
237
|
+
click.echo(
|
|
238
|
+
json.dumps(_service_help_payload(service, ops), ensure_ascii=False, indent=2)
|
|
239
|
+
)
|
|
240
|
+
return 0
|
|
241
|
+
click.echo(json.dumps(describe_service(service, ops), ensure_ascii=False, indent=2))
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
table = operations_table(spec, service.service_name)
|
|
245
|
+
if method not in table:
|
|
246
|
+
for key in table:
|
|
247
|
+
if key.lower() == method.lower():
|
|
248
|
+
method = key
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
op = resolve_operation(table, method)
|
|
253
|
+
except OedError as exc:
|
|
254
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
255
|
+
return exc.code
|
|
256
|
+
|
|
257
|
+
if help_requested:
|
|
258
|
+
click.echo(
|
|
259
|
+
json.dumps(describe_operation_help(op, service), ensure_ascii=False, indent=2)
|
|
260
|
+
)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
params, body, exit_code = _merge_params(op, raw_flags)
|
|
265
|
+
except OedError as exc:
|
|
266
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
267
|
+
return exc.code
|
|
268
|
+
if exit_code:
|
|
269
|
+
# Re-parse the params we tried to load so the user sees the error.
|
|
270
|
+
if "params" in raw_flags:
|
|
271
|
+
try:
|
|
272
|
+
parse_json_arg(raw_flags["params"], flag="params")
|
|
273
|
+
except OedError as exc:
|
|
274
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
275
|
+
return exc.code
|
|
276
|
+
if "json" in raw_flags:
|
|
277
|
+
try:
|
|
278
|
+
parse_json_arg(raw_flags["json"], flag="json")
|
|
279
|
+
except OedError as exc:
|
|
280
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
281
|
+
return exc.code
|
|
282
|
+
return exit_code
|
|
283
|
+
|
|
284
|
+
dry_run = "dry-run" in bool_flags
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
result = call_operation(
|
|
288
|
+
op,
|
|
289
|
+
params=params,
|
|
290
|
+
body=body,
|
|
291
|
+
dry_run=dry_run,
|
|
292
|
+
include_request=True,
|
|
293
|
+
)
|
|
294
|
+
except OedError as exc:
|
|
295
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
296
|
+
return exc.code
|
|
297
|
+
|
|
298
|
+
click.echo(json.dumps(result, ensure_ascii=False, indent=2))
|
|
299
|
+
return 0 if result.get("ok") else 3
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _usage_dynamic() -> str:
|
|
303
|
+
return json.dumps(
|
|
304
|
+
{
|
|
305
|
+
"ok": False,
|
|
306
|
+
"code": 1,
|
|
307
|
+
"error": "missing_service",
|
|
308
|
+
"message": "dynamic dispatch: pass <service> as the first argument",
|
|
309
|
+
"examples": [
|
|
310
|
+
"oed <service> # list operations",
|
|
311
|
+
"oed <service> <operation> # operation-level help",
|
|
312
|
+
"oed <service> <operation> --<flag> <value> # call with one flag",
|
|
313
|
+
"oed <service> <operation> --params '{...}' # bulk JSON params",
|
|
314
|
+
"oed <service> <operation> --dry-run # preview only",
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
ensure_ascii=False,
|
|
318
|
+
indent=2,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _service_help_payload(service, ops: list) -> dict:
|
|
323
|
+
"""Payload for ``oed <service> --help``: enumerate operations + flag cheatsheet."""
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
"ok": True,
|
|
327
|
+
"help_for": service.name,
|
|
328
|
+
"title": service.title,
|
|
329
|
+
"operations": [op.display_name for op in ops],
|
|
330
|
+
"operation_aliases": {
|
|
331
|
+
op.display_name: op.operation_id
|
|
332
|
+
for op in ops
|
|
333
|
+
if op.display_name != op.operation_id
|
|
334
|
+
} or None,
|
|
335
|
+
"usage": (
|
|
336
|
+
"oed <service> <operation> --<flag> <value>\n"
|
|
337
|
+
"Each declared query / path parameter is exposed as its own "
|
|
338
|
+
"--<kebab-case> flag. Use `oed <service> <operation> --help` "
|
|
339
|
+
"to list them, or pass `--params '{...}'` for bulk JSON."
|
|
340
|
+
),
|
|
341
|
+
"examples": [
|
|
342
|
+
f"oed {service.service_name} {ops[0].display_name} --help",
|
|
343
|
+
f"oed {service.service_name} {ops[0].display_name} --dry-run",
|
|
344
|
+
],
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _dispatch_dynamic_help(service_name: str, positional: list[str]) -> int:
|
|
349
|
+
"""Legacy ``oed <service> --help`` entry point — kept for tests.
|
|
350
|
+
|
|
351
|
+
New code path goes through :func:`_service_help_payload` directly
|
|
352
|
+
inside :func:`_dispatch_dynamic`; this thin wrapper preserves the
|
|
353
|
+
public function name so existing tests stay in sync.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
if positional:
|
|
357
|
+
click.echo(
|
|
358
|
+
json.dumps(
|
|
359
|
+
{
|
|
360
|
+
"ok": False,
|
|
361
|
+
"code": 1,
|
|
362
|
+
"error": "too_many_positional",
|
|
363
|
+
"message": "`oed <service> --help` lists operations; remove the extra argument",
|
|
364
|
+
"extra_args": positional,
|
|
365
|
+
},
|
|
366
|
+
ensure_ascii=False,
|
|
367
|
+
),
|
|
368
|
+
err=True,
|
|
369
|
+
)
|
|
370
|
+
return 1
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
service = resolve_service_by_name(service_name)
|
|
374
|
+
except OedError as exc:
|
|
375
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
376
|
+
return exc.code
|
|
377
|
+
try:
|
|
378
|
+
spec = fetch_service_spec(service)
|
|
379
|
+
except OedError as exc:
|
|
380
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
381
|
+
return exc.code
|
|
382
|
+
|
|
383
|
+
ops = collect_operations(spec, service.service_name)
|
|
384
|
+
click.echo(json.dumps(_service_help_payload(service, ops), ensure_ascii=False, indent=2))
|
|
385
|
+
return 0
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
389
|
+
"""Top-level entry point. Returns a Unix-style exit code."""
|
|
390
|
+
|
|
391
|
+
raw = list(argv if argv is not None else sys.argv[1:])
|
|
392
|
+
try:
|
|
393
|
+
if _looks_like_reserved(raw):
|
|
394
|
+
try:
|
|
395
|
+
click_cli.main(args=raw, standalone_mode=False)
|
|
396
|
+
except click.exceptions.ClickException as exc:
|
|
397
|
+
exc.show()
|
|
398
|
+
return exc.exit_code
|
|
399
|
+
except SystemExit as exc:
|
|
400
|
+
return int(exc.code or 0)
|
|
401
|
+
return 0
|
|
402
|
+
return _dispatch_dynamic(raw)
|
|
403
|
+
except OedError as exc:
|
|
404
|
+
click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
|
|
405
|
+
return exc.code
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
sys.exit(main())
|
oed_cli/py.typed
ADDED
|
File without changes
|