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/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