oed-cli 0.2.0__tar.gz → 0.2.1__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.
Files changed (23) hide show
  1. {oed_cli-0.2.0/src/oed_cli.egg-info → oed_cli-0.2.1}/PKG-INFO +1 -1
  2. {oed_cli-0.2.0 → oed_cli-0.2.1}/pyproject.toml +1 -1
  3. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/http.py +42 -5
  4. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/invoke.py +15 -3
  5. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/main.py +3 -1
  6. {oed_cli-0.2.0 → oed_cli-0.2.1/src/oed_cli.egg-info}/PKG-INFO +1 -1
  7. {oed_cli-0.2.0 → oed_cli-0.2.1}/tests/test_dynamic.py +203 -309
  8. {oed_cli-0.2.0 → oed_cli-0.2.1}/LICENSE +0 -0
  9. {oed_cli-0.2.0 → oed_cli-0.2.1}/README.md +0 -0
  10. {oed_cli-0.2.0 → oed_cli-0.2.1}/setup.cfg +0 -0
  11. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/__init__.py +0 -0
  12. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/__main__.py +0 -0
  13. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/cli.py +0 -0
  14. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/discovery.py +0 -0
  15. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/dynamic.py +0 -0
  16. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/errors.py +0 -0
  17. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli/py.typed +0 -0
  18. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli.egg-info/SOURCES.txt +0 -0
  19. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli.egg-info/dependency_links.txt +0 -0
  20. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli.egg-info/entry_points.txt +0 -0
  21. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli.egg-info/requires.txt +0 -0
  22. {oed_cli-0.2.0 → oed_cli-0.2.1}/src/oed_cli.egg-info/top_level.txt +0 -0
  23. {oed_cli-0.2.0 → oed_cli-0.2.1}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oed-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "oed — openEuler Infra command line. Auto-discovered, AI-friendly."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,10 +4,20 @@ The gateway sits behind a CloudWAF that returns a Chinese-language HTML block
4
4
  page when the request lacks browser-style headers (see ``context/discoverAPI.md``
5
5
  section "注意事项 & 已知限制"). This module bakes those headers in so every call
6
6
  made by ``oed`` succeeds without users tweaking curl flags.
7
+
8
+ Note on ``Referer``: openEuler APIG rejects requests that carry a
9
+ ``Referer: https://api-gateway.osinfra.cn/`` header with HTTP 401 on at
10
+ least one production path (``easysearch`` / ``sigsearch/docs``). The
11
+ ``/discovery/apis`` feed does not need Referer to pass the CloudWAF
12
+ either — empirically verified 2026-07-28. So ``_headers()`` does NOT
13
+ emit a default Referer. Callers can still pass one explicitly via the
14
+ ``headers=`` argument to :func:`get_request` if a future endpoint
15
+ demands it.
7
16
  """
8
17
 
9
18
  from __future__ import annotations
10
19
 
20
+ import os
11
21
  from typing import Any
12
22
 
13
23
  import httpx
@@ -17,14 +27,31 @@ from .errors import NetworkError, NotFoundError, UpstreamError
17
27
 
18
28
  DEFAULT_GATEWAY = "https://api-gateway.osinfra.cn"
19
29
  _TIMEOUT_SECONDS = 30.0
30
+ DEFAULT_USER_AGENT = f"oed/{__version__} (+https://gitee.com/openeuler/oed-cli)"
31
+
20
32
 
33
+ def _resolve_user_agent(user_agent: str | None) -> str:
34
+ """Resolve the User-Agent: explicit arg > ``OED_USER_AGENT`` env > default.
35
+
36
+ An empty string falls through to the next source — passing ``--user-agent ""``
37
+ is treated the same as not passing it.
38
+ """
21
39
 
22
- def _headers(extra: dict[str, str] | None = None) -> dict[str, str]:
40
+ if user_agent:
41
+ return user_agent
42
+ env = os.environ.get("OED_USER_AGENT")
43
+ if env:
44
+ return env
45
+ return DEFAULT_USER_AGENT
46
+
47
+
48
+ def _headers(
49
+ extra: dict[str, str] | None = None, *, user_agent: str | None = None
50
+ ) -> dict[str, str]:
23
51
  h = {
24
- "User-Agent": f"oed/{__version__} (+https://gitee.com/openeuler/oed-cli)",
52
+ "User-Agent": _resolve_user_agent(user_agent),
25
53
  "Accept": "application/json, text/plain, */*",
26
54
  "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
27
- "Referer": f"{DEFAULT_GATEWAY}/",
28
55
  }
29
56
  if extra:
30
57
  h.update(extra)
@@ -60,6 +87,7 @@ def get_request(
60
87
  body: Any = None,
61
88
  headers: dict | None = None,
62
89
  timeout: float = _TIMEOUT_SECONDS,
90
+ user_agent: str | None = None,
63
91
  ) -> httpx.Response:
64
92
  """Run an arbitrary HTTP request and return the raw :class:`httpx.Response`.
65
93
 
@@ -67,6 +95,11 @@ def get_request(
67
95
  when ``body`` is set and the caller has not overridden it. Surfaces
68
96
  connectivity failures as :class:`NetworkError`; WAF blocks as
69
97
  :class:`NetworkError` (kind ``waf_block``); 5xx as :class:`UpstreamError`.
98
+
99
+ ``user_agent`` overrides the default User-Agent header — useful when a
100
+ specific APIG backend's WAF rejects the default ``oed/x.y.z`` UA with a
101
+ misleading 401 (e.g. ``easysearch``). Falls back to ``OED_USER_AGENT`` env,
102
+ then the bundled default.
70
103
  """
71
104
 
72
105
  method = method.upper()
@@ -81,7 +114,7 @@ def get_request(
81
114
  url,
82
115
  params=params if params else None,
83
116
  json=body if body is not None else None,
84
- headers=_headers(extra),
117
+ headers=_headers(extra, user_agent=user_agent),
85
118
  )
86
119
  except httpx.HTTPError as exc:
87
120
  raise NetworkError(f"{method} {url} failed: {exc}", kind="network_error") from exc
@@ -110,7 +143,11 @@ def _decode(
110
143
  raise NetworkError(
111
144
  f"Gateway WAF blocked the request to {resp.url}",
112
145
  kind="waf_block",
113
- hint="This is unexpected — file an issue with the URL you tried.",
146
+ hint=(
147
+ "Some APIG backends reject the bundled oed/x.y.z User-Agent. "
148
+ "Retry with `--user-agent 'Mozilla/5.0 ...'` or set "
149
+ "OED_USER_AGENT in the environment."
150
+ ),
114
151
  )
115
152
 
116
153
  if not resp.content:
@@ -24,7 +24,7 @@ import httpx
24
24
  from . import http as http_mod
25
25
  from .dynamic import RUNTIME_GATEWAY, Operation, coerce_param_types, to_flag
26
26
  from .errors import NetworkError, UserError
27
- from .http import _is_waf_block
27
+ from .http import _is_waf_block, _resolve_user_agent
28
28
 
29
29
 
30
30
  def _fill_path(template: str, params: dict[str, Any]) -> tuple[str, list[str]]:
@@ -95,12 +95,16 @@ def call_operation(
95
95
  dry_run: bool = False,
96
96
  timeout: float = 30.0,
97
97
  include_request: bool = False,
98
+ user_agent: str | None = None,
98
99
  ) -> dict[str, Any]:
99
100
  """Invoke ``op`` and return a structured JSON dict suitable for stdout.
100
101
 
101
102
  ``ok`` is ``True`` for any 2xx/3xx response. Non-success still surfaces
102
103
  the body and status under ``response`` / ``status``; the caller is
103
104
  responsible for the exit code.
105
+
106
+ ``user_agent`` overrides the default User-Agent header; falls back to
107
+ ``OED_USER_AGENT`` env, then the bundled default.
104
108
  """
105
109
 
106
110
  path_params, query_params, unused = _select_params(op, params)
@@ -117,11 +121,14 @@ def call_operation(
117
121
  query_params = {k: v for k, v in query_params.items() if v is not None}
118
122
  url = f"{RUNTIME_GATEWAY}{filled_path}"
119
123
 
124
+ request_headers: dict[str, str] = {"User-Agent": _resolve_user_agent(user_agent)}
125
+ if body is not None:
126
+ request_headers["Content-Type"] = "application/json"
120
127
  request_view: dict[str, Any] = {
121
128
  "method": op.backend.method,
122
129
  "url": url,
123
130
  "query": query_params,
124
- "headers": {"Content-Type": "application/json"} if body is not None else {},
131
+ "headers": request_headers,
125
132
  "body": body,
126
133
  }
127
134
 
@@ -146,13 +153,18 @@ def call_operation(
146
153
  params=query_params if query_params else None,
147
154
  body=body,
148
155
  timeout=timeout,
156
+ user_agent=user_agent,
149
157
  )
150
158
 
151
159
  if _is_waf_block(resp.text):
152
160
  raise NetworkError(
153
161
  f"Backend WAF blocked {op.backend.method} {url}",
154
162
  kind="waf_block",
155
- hint="Try OED_EXTRA_HEADERS_JSON env var to send custom headers.",
163
+ hint=(
164
+ "Some APIG backends reject the bundled oed/x.y.z User-Agent. "
165
+ "Retry with `--user-agent 'Mozilla/5.0 ...'` or set "
166
+ "OED_USER_AGENT in the environment."
167
+ ),
156
168
  )
157
169
 
158
170
  out = {
@@ -74,7 +74,7 @@ LEADING_FLAGS: frozenset[str] = frozenset({"-h", "--help", "-V", "--version"})
74
74
  # Built-in control flags handled by the dispatcher itself. Everything
75
75
  # else is treated as a candidate per-parameter flag, validated later
76
76
  # against the resolved operation's declared parameters.
77
- _VALUE_FLAGS: frozenset[str] = frozenset({"params", "json", "path"})
77
+ _VALUE_FLAGS: frozenset[str] = frozenset({"params", "json", "path", "user-agent"})
78
78
  _BOOL_FLAGS: frozenset[str] = frozenset({"dry-run"})
79
79
 
80
80
 
@@ -215,6 +215,7 @@ def _dispatch_dynamic(argv: Sequence[str]) -> int:
215
215
  click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
216
216
  return exc.code
217
217
 
218
+ user_agent = raw_flags.pop("user-agent", None)
218
219
  method = positional[0] if positional else None
219
220
 
220
221
  try:
@@ -290,6 +291,7 @@ def _dispatch_dynamic(argv: Sequence[str]) -> int:
290
291
  body=body,
291
292
  dry_run=dry_run,
292
293
  include_request=True,
294
+ user_agent=user_agent,
293
295
  )
294
296
  except OedError as exc:
295
297
  click.echo(json.dumps(exc.to_dict(), ensure_ascii=False), err=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -158,7 +158,7 @@ def patched(monkeypatch):
158
158
 
159
159
  captured = {}
160
160
 
161
- def _fake_do_call(method, url, params, body, timeout):
161
+ def _fake_do_call(method, url, params=None, body=None, timeout=30.0, headers=None, user_agent=None):
162
162
  class _R:
163
163
  status_code = 200
164
164
  content = b'{"code":"","msg":"","data":{"ok":true}}'
@@ -172,6 +172,7 @@ def patched(monkeypatch):
172
172
  captured["url"] = url
173
173
  captured["params"] = params
174
174
  captured["body"] = body
175
+ captured["user_agent"] = user_agent
175
176
  return _R()
176
177
 
177
178
  # Replace get_request since invoke calls it
@@ -245,16 +246,6 @@ def test_backend_extraction():
245
246
  assert op.body_required is True
246
247
 
247
248
 
248
- def test_operations_table_supports_id_and_path_aliases():
249
- from oed_cli.dynamic import operations_table
250
-
251
- table = operations_table(SAMPLE_SPEC, "x")
252
- # primary key
253
- assert "API_listSoftwarePackages" in table
254
- # alias
255
- assert "GET /v1/softwarepkg" in table
256
-
257
-
258
249
  def test_parse_json_arg_rejects_garbage():
259
250
  from oed_cli.dynamic import parse_json_arg
260
251
  from oed_cli.errors import UserError
@@ -263,22 +254,6 @@ def test_parse_json_arg_rejects_garbage():
263
254
  parse_json_arg("not-json", flag="params")
264
255
 
265
256
 
266
- def test_path_fill_reports_missing():
267
-
268
- ops = {o.operation_id: o for o in []}
269
- assert ops == {}
270
- # exercise _fill_path via the direct path
271
- from oed_cli.invoke import _fill_path
272
-
273
- rendered, missing = _fill_path("/api/v1/softwarepkg/{id}/review", {"id": "12345"})
274
- assert rendered == "/api/v1/softwarepkg/12345/review"
275
- assert missing == []
276
-
277
- rendered, missing = _fill_path("/api/v1/softwarepkg/{id}", {})
278
- assert rendered == "/api/v1/softwarepkg/{id}" # placeholder kept verbatim
279
- assert missing == ["id"]
280
-
281
-
282
257
  def test_coerce_param_types_handles_string_ints():
283
258
  from oed_cli.dynamic import coerce_param_types, collect_operations
284
259
 
@@ -320,18 +295,6 @@ def test_call_operation_post_sends_body(patched):
320
295
  assert patched["captured"]["body"] == body
321
296
 
322
297
 
323
- def test_call_operation_dry_run_does_not_call(patched):
324
- from oed_cli.dynamic import operations_table
325
- from oed_cli.invoke import call_operation
326
-
327
- table = operations_table(SAMPLE_SPEC, "software-package-server")
328
- op = table["API_verifyCla"]
329
-
330
- payload = call_operation(op, dry_run=True)
331
- assert payload["dry_run"] is True
332
- assert "method" not in patched["captured"] # no real call
333
-
334
-
335
298
  def test_call_operation_missing_path_param_raises_user_error(patched):
336
299
  from oed_cli.dynamic import operations_table
337
300
  from oed_cli.errors import UserError
@@ -346,31 +309,6 @@ def test_call_operation_missing_path_param_raises_user_error(patched):
346
309
  # ---------- main.py dispatch ----------
347
310
 
348
311
 
349
- def test_dispatch_invokes_via_dynamic_path(patched, runner, monkeypatch):
350
- from oed_cli import main as oed_main
351
-
352
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
353
- code = oed_main.main(
354
- [
355
- "software-package-server",
356
- "API_getSoftwarePackage",
357
- "--params",
358
- '{"id":"12345"}',
359
- ]
360
- )
361
- assert code == 0
362
- assert patched["captured"]["url"] == "https://apig.osinfra.cn/v1/softwarepkg/12345"
363
-
364
-
365
- def test_dispatch_lists_operations_when_only_service(patched, runner, monkeypatch):
366
- from oed_cli import main as oed_main
367
-
368
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
369
- code = oed_main.main(["software-package-server"])
370
- assert code == 0
371
- # Just verify exit code; output already verified manually against live.
372
-
373
-
374
312
  def test_dispatch_unknown_service(patched, runner, monkeypatch):
375
313
  from oed_cli import dynamic as dyn
376
314
  from oed_cli import main as oed_main
@@ -403,47 +341,6 @@ def test_dispatch_invalid_json_flag(patched, runner, monkeypatch):
403
341
  assert code == 1
404
342
 
405
343
 
406
- def test_reserved_routes_go_to_click(patched, runner, monkeypatch):
407
- """`oed info` is a click sub-command, even though `info` could in principle
408
- look like a dynamic service name."""
409
-
410
- from oed_cli import main as oed_main
411
-
412
- code = oed_main.main(["--version"])
413
- assert code == 0
414
-
415
-
416
- def test_dispatch_dry_run(patched, runner, monkeypatch):
417
- from oed_cli import main as oed_main
418
-
419
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
420
- code = oed_main.main(
421
- [
422
- "software-package-server",
423
- "API_listSoftwarePackages",
424
- "--dry-run",
425
- ]
426
- )
427
- assert code == 0
428
- # no HTTP capture → confirms dry-run short-circuited
429
- assert "method" not in patched["captured"]
430
-
431
-
432
- def test_service_level_help_lists_operations(patched, monkeypatch, capsys):
433
- """`oed <service> --help` should list every operation for that service."""
434
-
435
- from oed_cli import main as oed_main
436
-
437
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
438
- code = oed_main.main(["software-package-server", "--help"])
439
- captured = capsys.readouterr()
440
- assert code == 0
441
- text = captured.out + captured.err
442
- assert "API_listSoftwarePackages" in text
443
- assert "API_applyNewSoftwarePackage" in text
444
- assert text.lstrip().startswith("{")
445
-
446
-
447
344
  def test_service_level_help_with_method_rejected(patched, monkeypatch, capsys):
448
345
  """`oed <service> <method> --help` now resolves the operation first and
449
346
  shows its per-parameter flag cheatsheet. With an unknown method it
@@ -459,7 +356,7 @@ def test_service_level_help_with_method_rejected(patched, monkeypatch, capsys):
459
356
  assert "method_not_found" in text
460
357
 
461
358
 
462
- def test_spec_missing_returns_exit_4(patched, runner, monkeypatch):
359
+ def test_spec_missing_returns_exit_4(patched, runner, monkeypatch, capsys):
463
360
  from oed_cli import dynamic as dyn
464
361
  from oed_cli import main as oed_main
465
362
  from oed_cli.errors import NotFoundError
@@ -474,264 +371,261 @@ def test_spec_missing_returns_exit_4(patched, runner, monkeypatch):
474
371
  ),
475
372
  )
476
373
 
477
- monkeypatch.setattr(dyn, "fetch_service_spec", _raise)
374
+ monkeypatch.setattr(oed_main, "fetch_service_spec", _raise)
478
375
  code = oed_main.main(["software-package-server", "API_x"])
479
- assert code == 4
376
+ captured = capsys.readouterr()
377
+ assert code == 4 and "spec_missing" in (captured.out + captured.err)
480
378
 
481
379
 
482
- def test_url_ignores_spec_backend_address(patched):
483
- """Spec-declared ``x-apigateway-backend.httpEndpoints.address`` is
484
- parsed for diagnostics but never trusted as the runtime host — every
485
- call must go through ``https://apig.osinfra.cn`` so a stale spec
486
- pointing at a staging host (e.g. ``cvesa.test.osinfra.cn``) cannot
487
- break the call.
488
- """
380
+ # ---------- describe_* helpers + main dispatch sweep ----------
489
381
 
490
- from oed_cli.dynamic import operations_table
491
- from oed_cli.invoke import RUNTIME_GATEWAY, call_operation
492
382
 
493
- table = operations_table(SAMPLE_SPEC, "software-package-server")
494
- op = table["API_getSoftwarePackage"]
495
- # Spec still claims software-pkg.openeuler.org — runtime URL must
496
- # differ from that.
497
- assert op.backend.address == "software-pkg.openeuler.org"
498
- assert RUNTIME_GATEWAY == "https://apig.osinfra.cn"
383
+ def test_describe_helpers_and_main_dispatch(patched, monkeypatch, capsys):
384
+ """Compact sweep covering describe_*, main per-param flag dispatch,
385
+ and parse_json_arg / coerce_flag_value edge cases."""
386
+ from oed_cli.discovery import ServiceMeta
387
+ from oed_cli.dynamic import (
388
+ collect_operations, operations_table, parse_json_arg,
389
+ coerce_flag_value, resolve_operation,
390
+ )
391
+ from oed_cli.invoke import describe_service, describe_operation_help, call_operation
392
+ from oed_cli import main as oed_main
393
+ from oed_cli.errors import UserError
394
+
395
+ svc = ServiceMeta.from_raw(SAMPLE_FEED["communities"]["openeuler"][0])
396
+ ops = collect_operations(SAMPLE_SPEC, svc.service_name)
397
+ doc = describe_service(svc, ops)
398
+ assert {op["operation_id"] for op in doc["operations"]} >= {"listSoftwarePackages", "verifyCla"}
399
+ help_doc = describe_operation_help(
400
+ operations_table(SAMPLE_SPEC, svc.service_name)["API_listSoftwarePackages"], svc
401
+ )
402
+ flags = {p["flag"] for p in help_doc["parameters"]}
403
+ assert {"--phase", "--page-num", "--count-per-page"} <= flags
404
+ assert help_doc["usage"] and help_doc["examples"]
405
+ table = operations_table(SAMPLE_SPEC, svc.service_name)
406
+ assert call_operation(table["API_verifyCla"], dry_run=True)["dry_run"] is True
407
+ assert call_operation(table["API_verifyCla"], params={"nope": 1})["unused_params"] == ["nope"]
408
+
409
+ monkeypatch.setenv("OED_COMMUNITY", "openeuler")
410
+ assert oed_main.main([
411
+ "software-package-server", "API_getSoftwarePackage",
412
+ "--id", "42", "--language", "zh_CN",
413
+ ]) == 0
414
+ assert patched["captured"]["url"].endswith("/v1/softwarepkg/42")
415
+ assert patched["captured"]["params"] == {"language": "zh_CN"}
416
+ assert oed_main.main([
417
+ "software-package-server", "api_getsoftwarepackage", "--id", "1",
418
+ ]) == 0
419
+ assert resolve_operation(
420
+ operations_table(SAMPLE_SPEC, "x"), "api_listsoftwarepackages"
421
+ ).operation_id == "API_listSoftwarePackages"
422
+
423
+ code = oed_main.main([
424
+ "software-package-server", "API_listSoftwarePackages", "--bogus", "x",
425
+ ])
426
+ captured = capsys.readouterr()
427
+ assert code == 1 and "unknown_flag" in (captured.out + captured.err)
428
+ code = oed_main.main(["software-package-server", "API_getSoftwarePackage", "--help"])
429
+ text = capsys.readouterr().out + capsys.readouterr().err
430
+ assert code == 0 and "--id" in text and "language" in text
499
431
 
500
- payload = call_operation(op, params={"id": "99"})
501
- captured_host = patched["captured"]["url"].split("/")[2]
502
- assert captured_host == "apig.osinfra.cn"
503
- # And the dry-run output reports the same host.
504
- payload = call_operation(op, params={"id": "99"}, dry_run=True)
505
- assert payload["url"].startswith("https://apig.osinfra.cn/")
432
+ assert parse_json_arg("", flag="p") is None
433
+ assert parse_json_arg(None, flag="p") is None
434
+ with pytest.raises(UserError):
435
+ parse_json_arg("[1]", flag="p")
436
+ assert coerce_flag_value({"schema": {"type": "integer"}}, "5") == 5
437
+ assert coerce_flag_value({"schema": {"type": "number"}}, "1.5") == 1.5
438
+ assert coerce_flag_value({"schema": {"type": "boolean"}}, "yes") is True
439
+ assert coerce_flag_value({"schema": {"type": "boolean"}}, "false") is False
440
+ assert parse_json_arg('{"k":1}', flag="p") == {"k": 1}
441
+ assert coerce_flag_value({"schema": {"type": "integer"}}, "abc") == "abc"
442
+ assert coerce_flag_value({"schema": {"type": "number"}}, "abc") == "abc"
506
443
 
507
444
 
508
- # ---------- per-parameter flag surface ----------
445
+ # ---------- main() dispatch edges ----------
509
446
 
510
447
 
511
- def test_to_flag_converts_camel_and_snake_case():
512
- from oed_cli.dynamic import to_flag
448
+ def test_main_dispatch_comprehensive(patched, monkeypatch, capsys):
449
+ """Sweep main dispatch surfaces: reserved routes, --key=value, parse-error,
450
+ dry-run short-circuit, service-only listing, and call_operation errors."""
451
+ from oed_cli import main as oed_main
452
+ from oed_cli import http as http_mod
453
+ from oed_cli.errors import NetworkError
513
454
 
514
- assert to_flag("cveId") == "cve-id"
515
- assert to_flag("pageNum") == "page-num"
516
- assert to_flag("count_per_page") == "count-per-page"
517
- assert to_flag("countPerPage") == "count-per-page"
518
- assert to_flag("id") == "id"
455
+ monkeypatch.setenv("OED_COMMUNITY", "openeuler")
456
+ assert oed_main.main(["--version"]) == 0
457
+ assert oed_main.main(["info"]) == 0
458
+ oed_main.main(["software-package-server", "API_getSoftwarePackage", "--id=42"])
459
+ assert patched["captured"]["url"].endswith("/v1/softwarepkg/42")
460
+ code = oed_main.main(["software-package-server", "API_listSoftwarePackages", "-x"])
461
+ assert code == 1 and "unknown short flag" in (capsys.readouterr().err)
462
+ code = oed_main.main(["software-package-server", "API_listSoftwarePackages",
463
+ "--params", "bad"])
464
+ out = capsys.readouterr()
465
+ assert code == 1 and "invalid_json" in (out.out + out.err)
466
+ code = oed_main.main(["software-package-server", "API_listSoftwarePackages",
467
+ "--params", "[]"])
468
+ out = capsys.readouterr()
469
+ assert code == 1 and "invalid_json" in (out.out + out.err)
470
+ code = oed_main.main(
471
+ ["software-package-server", "API_applyNewSoftwarePackage", "--json", "bad"]
472
+ )
473
+ out = capsys.readouterr()
474
+ assert code == 1 and "invalid_json" in (out.out + out.err)
475
+ oed_main.main(["software-package-server", "API_getSoftwarePackage",
476
+ "--params", '{"id":"42"}', "--language", "zh_CN"])
477
+ assert patched["captured"]["url"].endswith("/v1/softwarepkg/42")
478
+ patched["captured"].clear()
479
+ code = oed_main.main(["software-package-server", "API_getSoftwarePackage",
480
+ "--id", "1", "--dry-run"])
481
+ assert code == 0 and "method" not in patched["captured"]
482
+ code = oed_main.main(["software-package-server"])
483
+ out = capsys.readouterr()
484
+ assert code == 0 and "listSoftwarePackages" in (out.out + out.err)
485
+ code = oed_main.main(["software-package-server", "--help"])
486
+ out = capsys.readouterr()
487
+ assert code == 0 and "API_listSoftwarePackages" in (out.out + out.err)
488
+ # -- separator (covers _split_dispatch_argv -- passthrough)
489
+ code = oed_main.main(["software-package-server", "API_getSoftwarePackage",
490
+ "--", "ignored"])
491
+ out = capsys.readouterr()
492
+ assert code == 1 and "missing_path_param" in (out.out + out.err)
493
+ monkeypatch.setattr(http_mod, "get_request",
494
+ lambda *a, **k: (_ for _ in ()).throw(NetworkError("x", kind="network_error")))
495
+ code = oed_main.main(["software-package-server", "API_getSoftwarePackage", "--id", "1"])
496
+ out = capsys.readouterr()
497
+ assert code == 2 and "network_error" in (out.out + out.err)
498
+ monkeypatch.setattr(http_mod, "get_request",
499
+ lambda *a, **k: type("R", (), {"status_code": 503, "text": "", "content": b"", "headers": {}})())
500
+ code = oed_main.main(["software-package-server", "API_getSoftwarePackage", "--id", "1"])
501
+ assert code == 3
519
502
 
520
503
 
521
- def test_param_flag_index_accepts_both_styles(patched):
522
- from oed_cli.dynamic import operations_table, param_flag_index
504
+ # ---------- --user-agent / OED_USER_AGENT override ----------
523
505
 
524
- ops = operations_table(SAMPLE_SPEC, "x")
525
- op = ops["API_getSoftwarePackage"]
526
- index = param_flag_index(op)
527
- # kebab-case stem
528
- assert "id" in index and index["id"]["name"] == "id"
529
- # raw camelCase is also indexed
530
- assert index["id"]["in"] == "path"
531
506
 
507
+ def test_resolve_user_agent_precedence(monkeypatch):
508
+ """Explicit arg > ``OED_USER_AGENT`` env > bundled default; empty string falls through."""
532
509
 
533
- def test_dispatch_per_param_flag_for_path(patched, runner, monkeypatch):
534
- """``oed <svc> <op> --id 42`` (kebab-case from camelCase/snake) should
535
- fill the path placeholder exactly like ``--params '{"id":"42"}'``."""
510
+ from oed_cli.http import _resolve_user_agent, DEFAULT_USER_AGENT
536
511
 
537
- from oed_cli import main as oed_main
512
+ monkeypatch.delenv("OED_USER_AGENT", raising=False)
513
+ assert _resolve_user_agent(None) == DEFAULT_USER_AGENT
514
+ assert _resolve_user_agent("") == DEFAULT_USER_AGENT
515
+ monkeypatch.setenv("OED_USER_AGENT", "from-env/1.0")
516
+ assert _resolve_user_agent(None) == "from-env/1.0"
517
+ assert _resolve_user_agent("") == "from-env/1.0"
518
+ assert _resolve_user_agent("from-arg/2.0") == "from-arg/2.0"
538
519
 
539
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
540
- code = oed_main.main(
541
- [
542
- "software-package-server",
543
- "API_getSoftwarePackage",
544
- "--id",
545
- "42",
546
- "--language",
547
- "zh_CN",
548
- ]
549
- )
550
- assert code == 0
551
- assert patched["captured"]["url"] == "https://apig.osinfra.cn/v1/softwarepkg/42"
552
- assert patched["captured"]["params"] == {"language": "zh_CN"}
553
520
 
521
+ def test_headers_function_uses_env(monkeypatch):
522
+ """``_headers()`` resolves User-Agent through the same precedence as ``_resolve_user_agent``."""
554
523
 
555
- def test_dispatch_per_param_flag_for_query(patched, runner, monkeypatch):
556
- """Declared ``integer`` query params get string→int coercion from flags."""
524
+ from oed_cli.http import _headers
557
525
 
558
- from oed_cli import main as oed_main
526
+ monkeypatch.setenv("OED_USER_AGENT", "browser-like/1.0")
527
+ h = _headers()
528
+ assert h["User-Agent"] == "browser-like/1.0"
529
+ h2 = _headers(user_agent="from-arg/2.0")
530
+ assert h2["User-Agent"] == "from-arg/2.0"
559
531
 
560
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
561
- code = oed_main.main(
562
- [
563
- "software-package-server",
564
- "API_listSoftwarePackages",
565
- "--page-num",
566
- "3",
567
- "--count-per-page",
568
- "5",
569
- ]
570
- )
571
- assert code == 0
572
- # ints, not strings
573
- assert patched["captured"]["params"] == {"page_num": 3, "count_per_page": 5}
574
532
 
533
+ def test_default_headers_omit_referer():
534
+ """No default ``Referer`` — openEuler APIG rejects ``easysearch`` calls with 401
535
+ when this header is present (verified 2026-07-28). Callers can still pass
536
+ one explicitly via ``extra=`` if a future endpoint needs it."""
575
537
 
576
- def test_dispatch_per_param_flag_overrides_params(patched, runner, monkeypatch):
577
- """Per-param flag values win when ``--params`` also defines the same key."""
538
+ from oed_cli.http import _headers
578
539
 
579
- from oed_cli import main as oed_main
540
+ h = _headers()
541
+ assert "Referer" not in h
542
+ assert h["User-Agent"].startswith("oed/")
543
+ assert h["Accept"].startswith("application/json")
544
+ assert "zh-CN" in h["Accept-Language"]
580
545
 
581
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
582
- code = oed_main.main(
583
- [
584
- "software-package-server",
585
- "API_listSoftwarePackages",
586
- "--params",
587
- '{"page_num":1,"count_per_page":10,"phase":"ignored"}',
588
- "--page-num",
589
- "7",
590
- ]
591
- )
592
- assert code == 0
593
- captured = patched["captured"]["params"]
594
- assert captured["page_num"] == 7 # flag wins
595
- assert captured["count_per_page"] == 10 # params still applies
546
+ h_with_extra = _headers(extra={"Referer": "https://example.com/"})
547
+ assert h_with_extra["Referer"] == "https://example.com/"
596
548
 
597
549
 
598
- def test_dispatch_unknown_flag_with_hint(patched, runner, monkeypatch, capsys):
599
- """A flag that doesn't match any declared parameter surfaces a precise
600
- hint listing what *is* valid for this operation."""
550
+ def test_call_operation_forwards_user_agent(patched):
551
+ """``call_operation(user_agent=...)`` propagates to the HTTP layer."""
601
552
 
602
- from oed_cli import main as oed_main
553
+ from oed_cli.dynamic import operations_table
554
+ from oed_cli.invoke import call_operation
603
555
 
604
- monkeypatch.setenv("OED_COMMUNITY", "openeuler")
605
- code = oed_main.main(
606
- [
607
- "software-package-server",
608
- "API_getSoftwarePackage",
609
- "--totally-unknown",
610
- "1",
611
- ]
612
- )
613
- captured = capsys.readouterr()
614
- assert code == 1
615
- text = captured.out + captured.err
616
- assert "unknown_flag" in text
617
- # The hint should list this op's declared params so the user knows what to use.
618
- assert "id" in text
619
- assert "language" in text
556
+ table = operations_table(SAMPLE_SPEC, "software-package-server")
557
+ op = table["API_getSoftwarePackage"]
558
+ call_operation(op, params={"id": "1"}, user_agent="override/1.0")
559
+ assert patched["captured"]["user_agent"] == "override/1.0"
620
560
 
621
561
 
622
- def test_operation_help_enumerates_flag_forms(patched, runner, monkeypatch, capsys):
623
- """``oed <svc> <method> --help`` shows every per-parameter flag, with the
624
- APIG-generated ``API_`` prefix stripped from ``help_for`` / ``usage``."""
562
+ def test_dispatch_user_agent_flag_flows_to_http(patched, monkeypatch, capsys):
563
+ """End-to-end: ``--user-agent foo`` reaches ``get_request(user_agent=...)``."""
625
564
 
626
565
  from oed_cli import main as oed_main
627
566
 
628
567
  monkeypatch.setenv("OED_COMMUNITY", "openeuler")
629
- code = oed_main.main(
630
- ["software-package-server", "getSoftwarePackage", "--help"]
631
- )
632
- captured = capsys.readouterr()
568
+ code = oed_main.main([
569
+ "software-package-server", "API_getSoftwarePackage",
570
+ "--id", "1", "--user-agent", "browser-mock/9.9",
571
+ ])
633
572
  assert code == 0
634
- text = captured.out + captured.err
635
- assert text.lstrip().startswith("{")
636
- payload = json.loads(_extract_json(text))
637
- # Display name strips the API_ prefix.
638
- assert payload["help_for"] == "getSoftwarePackage"
639
- # Raw spec form still surfaced for diagnostics.
640
- assert payload["operation_id_raw"] == "API_getSoftwarePackage"
641
- assert payload["method"] == "GET"
642
- flags = {p["flag"] for p in payload["parameters"]}
643
- assert "--id" in flags
644
- assert "--language" in flags
645
- # Required marker carried over.
646
- id_entry = next(p for p in payload["parameters"] if p["name"] == "id")
647
- assert id_entry["required"] is True
648
- # Usage line lists the flags in copy-paste form (also stripped).
649
- assert "--id" in payload["usage"]
650
- assert "getSoftwarePackage" in payload["usage"]
651
- assert "API_getSoftwarePackage" not in payload["usage"]
652
-
653
-
654
- def test_api_prefix_alias_resolves_same_op(patched, runner, monkeypatch):
655
- """Both ``API_xxx`` and ``xxx`` (and case variants) must resolve to the
656
- same :class:`Operation` so users never have to memorize the prefix."""
573
+ assert patched["captured"]["user_agent"] == "browser-mock/9.9"
574
+
575
+
576
+ def test_dispatch_user_agent_env_only(patched, monkeypatch):
577
+ """No flag, only env → env value is forwarded."""
657
578
 
658
579
  from oed_cli import main as oed_main
659
580
 
660
581
  monkeypatch.setenv("OED_COMMUNITY", "openeuler")
661
- # 1) Stripped form
662
- code = oed_main.main(
663
- [
664
- "software-package-server",
665
- "getSoftwarePackage",
666
- "--id",
667
- "42",
668
- ]
669
- )
582
+ monkeypatch.setenv("OED_USER_AGENT", "env-only/3.0")
583
+ code = oed_main.main(["software-package-server", "API_getSoftwarePackage", "--id", "1"])
670
584
  assert code == 0
671
- stripped_url = patched["captured"]["url"]
585
+ assert patched["captured"]["user_agent"] == "env-only/3.0"
672
586
 
673
- # 2) Raw API_ form (back-compat)
674
- patched["captured"].clear()
675
- code = oed_main.main(
676
- [
677
- "software-package-server",
678
- "API_getSoftwarePackage",
679
- "--id",
680
- "42",
681
- ]
682
- )
683
- assert code == 0
684
- raw_url = patched["captured"]["url"]
685
587
 
686
- # 3) Case-insensitive
687
- patched["captured"].clear()
688
- code = oed_main.main(
689
- [
690
- "software-package-server",
691
- "GETSOFTWAREPACKAGE",
692
- "--id",
693
- "42",
694
- ]
695
- )
696
- assert code == 0
697
- upper_url = patched["captured"]["url"]
588
+ def test_dispatch_user_agent_flag_overrides_env(patched, monkeypatch):
589
+ """Flag wins over env when both are set."""
590
+
591
+ from oed_cli import main as oed_main
698
592
 
699
- assert stripped_url == raw_url == upper_url == "https://apig.osinfra.cn/v1/softwarepkg/42"
593
+ monkeypatch.setenv("OED_COMMUNITY", "openeuler")
594
+ monkeypatch.setenv("OED_USER_AGENT", "env/1.0")
595
+ code = oed_main.main([
596
+ "software-package-server", "API_getSoftwarePackage",
597
+ "--id", "1", "--user-agent=arg/2.0",
598
+ ])
599
+ assert code == 0
600
+ assert patched["captured"]["user_agent"] == "arg/2.0"
700
601
 
701
602
 
702
- def test_service_level_help_uses_stripped_names(patched, runner, monkeypatch, capsys):
703
- """``oed <service> --help`` lists stripped names + an ``operation_aliases``
704
- map so callers can see the original spec form when it's non-trivial."""
603
+ def test_dispatch_user_agent_in_dry_run(patched, monkeypatch, capsys):
604
+ """Dry-run surfaces the resolved User-Agent under ``request.headers``."""
705
605
 
706
606
  from oed_cli import main as oed_main
707
607
 
708
608
  monkeypatch.setenv("OED_COMMUNITY", "openeuler")
709
- code = oed_main.main(["software-package-server", "--help"])
710
- captured = capsys.readouterr()
609
+ code = oed_main.main([
610
+ "software-package-server", "API_getSoftwarePackage",
611
+ "--id", "1", "--user-agent", "preview-ua/1.0", "--dry-run",
612
+ ])
711
613
  assert code == 0
712
- payload = json.loads(_extract_json(captured.out))
713
- # First op name has no API_ prefix in display
714
- assert "API_listSoftwarePackages" not in payload["operations"]
715
- assert "listSoftwarePackages" in payload["operations"]
716
- # Aliases map the display form back to the raw spec form
717
- assert payload["operation_aliases"]["listSoftwarePackages"] == "API_listSoftwarePackages"
614
+ import json as _json
615
+ payload = _json.loads(capsys.readouterr().out)
616
+ assert payload["dry_run"] is True
617
+ assert payload["request"]["headers"]["User-Agent"] == "preview-ua/1.0"
718
618
 
719
619
 
720
- def test_help_overrides_flag_validation(patched, runner, monkeypatch):
721
- """``--help`` short-circuits flag validation: unknown flags together with
722
- ``--help`` should still show the help, not error on the flag."""
620
+ def test_dispatch_user_agent_missing_value_errors(patched, monkeypatch, capsys):
621
+ """``--user-agent`` with no following value fails fast with exit 1."""
723
622
 
724
623
  from oed_cli import main as oed_main
725
624
 
726
625
  monkeypatch.setenv("OED_COMMUNITY", "openeuler")
727
- code = oed_main.main(
728
- [
729
- "software-package-server",
730
- "API_getSoftwarePackage",
731
- "--bogus-flag",
732
- "x",
733
- "--help",
734
- ]
735
- )
736
- assert code == 0
737
- assert "method" not in patched["captured"] # no network call
626
+ code = oed_main.main([
627
+ "software-package-server", "API_getSoftwarePackage",
628
+ "--id", "1", "--user-agent",
629
+ ])
630
+ out = capsys.readouterr()
631
+ assert code == 1 and "missing_flag_value" in (out.out + out.err)
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
File without changes