oed-cli 0.1.0__tar.gz → 0.1.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -97,7 +97,7 @@ pip install -e .
97
97
  Verify:
98
98
 
99
99
  ```bash
100
- oed --version # → oed, version 0.2.0
100
+ oed --version # → oed, version 0.1.2
101
101
  ```
102
102
 
103
103
  ---
@@ -248,7 +248,7 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
248
248
  python -m pytest -q
249
249
  ```
250
250
 
251
- 39 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
251
+ 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
252
252
  per-parameter flag coercion, the `API_`-prefix alias, the
253
253
  `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
254
254
  They monkeypatch the discovery layer so no gateway access is needed.
@@ -63,7 +63,7 @@ pip install -e .
63
63
  Verify:
64
64
 
65
65
  ```bash
66
- oed --version # → oed, version 0.2.0
66
+ oed --version # → oed, version 0.1.2
67
67
  ```
68
68
 
69
69
  ---
@@ -214,7 +214,7 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
214
214
  python -m pytest -q
215
215
  ```
216
216
 
217
- 39 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
217
+ 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
218
218
  per-parameter flag coercion, the `API_`-prefix alias, the
219
219
  `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
220
220
  They monkeypatch the discovery layer so no gateway access is needed.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oed-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "oed — openEuler Infra command line. Auto-discovered, AI-friendly."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.1.2"
6
6
  __all__ = ["__version__"]
@@ -149,6 +149,13 @@ class Operation:
149
149
  description: str = ""
150
150
  parameters: tuple[dict[str, Any], ...] = field(default_factory=tuple)
151
151
  body_required: bool = False
152
+ # Raw ``requestBody`` block (so we don't lose the OpenAPI-level metadata)
153
+ # plus the inlined ``body_schema`` resolved against ``components/schemas``
154
+ # for the ``application/json`` content type — the latter is what agents
155
+ # consume to learn how to build a ``--json`` body. ``None`` when the op
156
+ # has no JSON body.
157
+ request_body: dict[str, Any] | None = None
158
+ body_schema: dict[str, Any] | None = None
152
159
  operation_id: str = ""
153
160
  # Resolved runtime base URL — read from ``ServiceMeta.base_url`` via
154
161
  # :func:`resolve_runtime_gateway`. Empty by default so callers that
@@ -166,7 +173,12 @@ class Operation:
166
173
  op: dict[str, Any],
167
174
  backend: Backend,
168
175
  base_url: str = "",
176
+ spec: dict[str, Any] | None = None,
169
177
  ) -> Operation:
178
+ request_body = op.get("requestBody") if isinstance(op.get("requestBody"), dict) else None
179
+ body_schema: dict[str, Any] | None = None
180
+ if request_body is not None and spec is not None:
181
+ body_schema = _resolve_json_body_schema(request_body, spec)
170
182
  return cls(
171
183
  service_name=service_name,
172
184
  path=path,
@@ -176,6 +188,8 @@ class Operation:
176
188
  description=op.get("description", ""),
177
189
  parameters=tuple(op.get("parameters", []) or ()),
178
190
  body_required=bool(op.get("requestBody", {}).get("required")),
191
+ request_body=request_body,
192
+ body_schema=body_schema,
179
193
  operation_id=op.get("operationId") or f"{verb.upper()} {path}",
180
194
  base_url=base_url,
181
195
  )
@@ -224,6 +238,60 @@ def _parse_backend(op: dict[str, Any], *, path: str, verb: str) -> Backend:
224
238
  )
225
239
 
226
240
 
241
+ # --------------------------------------------------------------------------- #
242
+ # $ref resolver for request-body schemas
243
+ # --------------------------------------------------------------------------- #
244
+
245
+
246
+ def _inline_refs(node: Any, *, spec: dict[str, Any], _seen: frozenset[str] = frozenset()) -> Any:
247
+ """Recursively inline ``$ref: #/components/schemas/X`` in ``node``.
248
+
249
+ Only resolves refs the openEuler gateway actually emits (a single schema
250
+ bag under ``components.schemas``); external ``$ref``s and non-JSON-Reference
251
+ forms are left alone so the original spec stays recoverable for
252
+ diagnostics. Recursion depth is bounded by the ref-graph so a cyclic
253
+ schema can't blow the stack.
254
+ """
255
+
256
+ if isinstance(node, dict):
257
+ if "$ref" in node and isinstance(node["$ref"], str):
258
+ ref = node["$ref"]
259
+ if ref.startswith("#/components/schemas/") and ref not in _seen:
260
+ key = ref.rsplit("/", 1)[-1]
261
+ schemas = (spec.get("components") or {}).get("schemas") or {}
262
+ target = schemas.get(key)
263
+ if isinstance(target, dict):
264
+ return _inline_refs(
265
+ target, spec=spec, _seen=_seen | {ref}
266
+ )
267
+ return node
268
+ return {k: _inline_refs(v, spec=spec, _seen=_seen) for k, v in node.items()}
269
+ if isinstance(node, list):
270
+ return [_inline_refs(v, spec=spec, _seen=_seen) for v in node]
271
+ return node
272
+
273
+
274
+ def _resolve_json_body_schema(
275
+ request_body: dict[str, Any], spec: dict[str, Any]
276
+ ) -> dict[str, Any] | None:
277
+ """Return the inlined JSON-body schema for a requestBody block.
278
+
279
+ Prefers the ``application/json`` content type; falls back to the first
280
+ declared content type when JSON isn't there. Returns ``None`` if the
281
+ block has no schema to resolve.
282
+ """
283
+
284
+ content = request_body.get("content") or {}
285
+ media = content.get("application/json") or next(iter(content.values()), None)
286
+ if not isinstance(media, dict):
287
+ return None
288
+ schema = media.get("schema")
289
+ if not isinstance(schema, dict):
290
+ return None
291
+ resolved = _inline_refs(schema, spec=spec)
292
+ return resolved if isinstance(resolved, dict) else None
293
+
294
+
227
295
  def collect_operations(
228
296
  spec: dict[str, Any], service_name: str, *, base_url: str = ""
229
297
  ) -> list[Operation]:
@@ -232,7 +300,9 @@ def collect_operations(
232
300
  ``base_url`` is the resolved runtime URL (output of
233
301
  :func:`resolve_runtime_gateway`); it is baked into every returned
234
302
  :class:`Operation` so :mod:`oed_cli.invoke` does not need to know
235
- which service produced the operation.
303
+ which service produced the operation. The ``spec`` is also passed
304
+ through so each operation's ``requestBody`` can be resolved against
305
+ ``components/schemas``.
236
306
  """
237
307
 
238
308
  out: list[Operation] = []
@@ -251,6 +321,7 @@ def collect_operations(
251
321
  op=op,
252
322
  backend=_parse_backend(op, path=path, verb=verb),
253
323
  base_url=base_url,
324
+ spec=spec,
254
325
  )
255
326
  )
256
327
  return out
@@ -34,6 +34,12 @@ def _fill_path(template: str, params: dict[str, Any]) -> tuple[str, list[str]]:
34
34
 
35
35
  Returns the rendered path and the list of placeholder names that were
36
36
  not provided, so the caller can raise a precise error.
37
+
38
+ Huawei APIG marks required path params with a trailing ``+`` in the
39
+ template (``/t/{id+}``); the ``+`` is a spec-side marker that never
40
+ appears in the real request URL or in the declared
41
+ ``parameters[].name``. Strip it so the lookup matches what the
42
+ caller actually provided under ``--<flag>`` / ``--params``.
37
43
  """
38
44
 
39
45
  missing: list[str] = []
@@ -48,13 +54,14 @@ def _fill_path(template: str, params: dict[str, Any]) -> tuple[str, list[str]]:
48
54
  if name_end == -1:
49
55
  parts.append(head + sep + chunk)
50
56
  break
51
- name = chunk[:name_end]
57
+ raw_name = chunk[:name_end]
58
+ name = raw_name.rstrip("+")
52
59
  parts.append(head)
53
60
  if name in params:
54
61
  parts.append(str(params[name]))
55
62
  else:
56
63
  missing.append(name)
57
- parts.append("{" + name + "}")
64
+ parts.append("{" + raw_name + "}")
58
65
  rest = chunk[name_end + 1 :]
59
66
  return "".join(parts), missing
60
67
 
@@ -75,6 +82,36 @@ def _select_params(
75
82
  return path_params, query_params, unused
76
83
 
77
84
 
85
+ def _body_field_summary(
86
+ schema: dict[str, Any] | None,
87
+ ) -> list[dict[str, Any]]:
88
+ """Project a resolved body schema down to ``[{name, type, required}]``.
89
+
90
+ Listing views use this so the per-operation summary stays compact
91
+ while still telling an agent which fields exist and which are
92
+ mandatory. The full schema is exposed separately under
93
+ ``body_schema`` on ``oed <service> <op> --help``.
94
+ """
95
+
96
+ if not isinstance(schema, dict):
97
+ return []
98
+ properties = schema.get("properties") or {}
99
+ required = set(schema.get("required") or [])
100
+ out: list[dict[str, Any]] = []
101
+ for name, prop in properties.items():
102
+ if not isinstance(prop, dict):
103
+ continue
104
+ entry: dict[str, Any] = {
105
+ "name": name,
106
+ "type": prop.get("type", "object"),
107
+ "required": name in required,
108
+ }
109
+ if prop.get("description"):
110
+ entry["description"] = prop["description"]
111
+ out.append(entry)
112
+ return out
113
+
114
+
78
115
  def _render_response(resp: httpx.Response) -> Any:
79
116
  """Parse the JSON body of ``resp``, returning a wrapped string for non-JSON."""
80
117
 
@@ -207,6 +244,8 @@ def describe_operation(op: Operation) -> dict[str, Any]:
207
244
  "query_params": [p["name"] for p in op.query_params],
208
245
  "body_required": op.body_required,
209
246
  }
247
+ if op.body_required:
248
+ out["body_required_fields"] = _body_field_summary(op.body_schema)
210
249
  if op.display_name != op.operation_id:
211
250
  out["operation_id_raw"] = op.operation_id
212
251
  return out
@@ -273,6 +312,9 @@ def describe_operation_help(op: Operation, service) -> dict[str, Any]:
273
312
  }
274
313
  if name != op.operation_id:
275
314
  out["operation_id_raw"] = op.operation_id
315
+ if op.body_required:
316
+ out["body_required_fields"] = _body_field_summary(op.body_schema)
317
+ out["body_schema"] = op.body_schema
276
318
  return out
277
319
 
278
320
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -97,7 +97,7 @@ pip install -e .
97
97
  Verify:
98
98
 
99
99
  ```bash
100
- oed --version # → oed, version 0.2.0
100
+ oed --version # → oed, version 0.1.2
101
101
  ```
102
102
 
103
103
  ---
@@ -248,7 +248,7 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
248
248
  python -m pytest -q
249
249
  ```
250
250
 
251
- 39 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
251
+ 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
252
252
  per-parameter flag coercion, the `API_`-prefix alias, the
253
253
  `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
254
254
  They monkeypatch the discovery layer so no gateway access is needed.
@@ -277,6 +277,106 @@ def test_collect_operations_without_backend_block():
277
277
  assert op.base_url == "https://apig.osinfra.cn"
278
278
 
279
279
 
280
+ def test_request_body_schema_resolves_refs():
281
+ """POST bodies that use ``$ref: #/components/schemas/X`` must surface
282
+ the resolved schema in ``Operation.body_schema`` so ``--help`` can
283
+ tell an agent how to construct the ``--json`` body."""
284
+
285
+ from oed_cli.dynamic import collect_operations
286
+
287
+ spec = {
288
+ "openapi": "3.0.1",
289
+ "components": {
290
+ "schemas": {
291
+ "SearchCondition": {
292
+ "type": "object",
293
+ "required": ["keyword", "lang"],
294
+ "properties": {
295
+ "keyword": {"type": "string", "description": "search kw"},
296
+ "lang": {"type": "string", "description": "zh|en"},
297
+ "page": {"type": "integer", "default": 1},
298
+ },
299
+ },
300
+ "Pages": {"type": "object", "properties": {"size": {"type": "integer"}}},
301
+ },
302
+ },
303
+ "paths": {
304
+ "/search/multitimodal": {
305
+ "post": {
306
+ "operationId": "multitimodalSearchDoc",
307
+ "requestBody": {
308
+ "required": True,
309
+ "content": {
310
+ "application/json": {
311
+ "schema": {"$ref": "#/components/schemas/SearchCondition"}
312
+ }
313
+ },
314
+ },
315
+ },
316
+ },
317
+ "/cve/findAll": {
318
+ "post": {
319
+ "operationId": "findAllCVEDatabase",
320
+ "requestBody": {
321
+ "required": True,
322
+ "content": {
323
+ "application/json": {
324
+ "schema": {
325
+ "type": "object",
326
+ "properties": {
327
+ "keyword": {"type": "string"},
328
+ "pages": {"$ref": "#/components/schemas/Pages"},
329
+ },
330
+ }
331
+ }
332
+ },
333
+ },
334
+ },
335
+ },
336
+ },
337
+ }
338
+ ops = collect_operations(spec, "search", base_url="https://apig.osinfra.cn")
339
+ by_op = {op.operation_id: op for op in ops}
340
+
341
+ # Top-level $ref resolves to the schema body
342
+ sc = by_op["multitimodalSearchDoc"]
343
+ assert sc.body_required is True
344
+ assert sc.body_schema["required"] == ["keyword", "lang"]
345
+ assert set(sc.body_schema["properties"]) == {"keyword", "lang", "page"}
346
+ # The resolved schema no longer carries the $ref
347
+ assert "$ref" not in sc.body_schema
348
+
349
+ # Nested $ref (inside a property) inlines too
350
+ cve = by_op["findAllCVEDatabase"]
351
+ assert cve.body_schema["properties"]["pages"]["type"] == "object"
352
+ assert "size" in cve.body_schema["properties"]["pages"]["properties"]
353
+
354
+
355
+ def test_request_body_schema_cycles_do_not_blow_stack():
356
+ """Cyclic ``$ref`` graphs (rare but possible) must terminate."""
357
+
358
+ from oed_cli.dynamic import _inline_refs
359
+
360
+ spec = {
361
+ "components": {
362
+ "schemas": {
363
+ "A": {
364
+ "type": "object",
365
+ "properties": {"next": {"$ref": "#/components/schemas/B"}},
366
+ },
367
+ "B": {
368
+ "type": "object",
369
+ "properties": {"next": {"$ref": "#/components/schemas/A"}},
370
+ },
371
+ }
372
+ }
373
+ }
374
+ out = _inline_refs({"$ref": "#/components/schemas/A"}, spec=spec)
375
+ # First level inlined; the cycle is broken at A's re-entry
376
+ assert out["type"] == "object"
377
+ assert "next" in out["properties"]
378
+
379
+
280
380
  def test_parse_json_arg_rejects_garbage():
281
381
  from oed_cli.dynamic import parse_json_arg
282
382
  from oed_cli.errors import UserError
@@ -426,6 +526,49 @@ def test_call_operation_missing_path_param_raises_user_error(patched):
426
526
  call_operation(op) # no id
427
527
 
428
528
 
529
+ def test_call_operation_strips_trailing_plus_in_path_template(patched):
530
+ """Huawei APIG marks required path params with ``{name+}`` in the path
531
+ template (``/t/{id+}``). The declared ``parameters[].name`` is plain
532
+ ``id`` — the ``+`` is a spec-side marker. ``oed forum getTopic --id 19308``
533
+ must therefore resolve the user's ``--id`` value into the rendered URL,
534
+ not complain that ``id+`` is missing."""
535
+
536
+ from oed_cli.dynamic import operations_table
537
+ from oed_cli.invoke import call_operation
538
+
539
+ spec = {
540
+ "openapi": "3.0.1",
541
+ "paths": {
542
+ "/t/{id+}": {
543
+ "get": {
544
+ "operationId": "getTopic",
545
+ "parameters": [
546
+ {"in": "path", "name": "id",
547
+ "required": True, "schema": {"type": "string"}},
548
+ ],
549
+ }
550
+ }
551
+ },
552
+ }
553
+ table = operations_table(spec, "forum", base_url="https://apig.osinfra.cn")
554
+ op = table["getTopic"]
555
+
556
+ payload = call_operation(op, params={"id": "19308"})
557
+ assert patched["captured"]["url"].endswith("/t/19308")
558
+ assert payload["ok"] is True
559
+ assert payload["status"] == 200
560
+
561
+ # Missing-param error must use the clean name (what the user types),
562
+ # not the APIG-side ``id+`` form.
563
+ from oed_cli.errors import UserError
564
+ try:
565
+ call_operation(op)
566
+ except UserError as exc:
567
+ assert exc.to_dict()["message"] == "missing path params: ['id']"
568
+ else:
569
+ pytest.fail("expected UserError for missing path param")
570
+
571
+
429
572
  # ---------- main.py dispatch ----------
430
573
 
431
574
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes