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