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/__init__.py
ADDED
oed_cli/__main__.py
ADDED
oed_cli/cli.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Click command surface for ``oed``.
|
|
2
|
+
|
|
3
|
+
Reserved (built-in) commands:
|
|
4
|
+
oed --version
|
|
5
|
+
oed info
|
|
6
|
+
oed services
|
|
7
|
+
oed schema <service>[.<method>]
|
|
8
|
+
oed cache {show,clear,refresh}
|
|
9
|
+
|
|
10
|
+
Dynamic dispatch (``oed <service> <method> ...``) lives in :mod:`oed_cli.main`
|
|
11
|
+
and is documented in ``docs/cli-design.md`` (v0.2).
|
|
12
|
+
|
|
13
|
+
``oed --help`` additionally lists every auto-discovered service so newcomers
|
|
14
|
+
can see what the CLI exposes without reading docs first.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .discovery import (
|
|
28
|
+
CACHE_TTL_SECONDS,
|
|
29
|
+
DiscoveryFeed,
|
|
30
|
+
ServiceMeta,
|
|
31
|
+
current_community,
|
|
32
|
+
fetch_discovery,
|
|
33
|
+
fetch_spec,
|
|
34
|
+
)
|
|
35
|
+
from .errors import OedError
|
|
36
|
+
from .http import DEFAULT_GATEWAY
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _print_json(data: Any, *, error: bool = False) -> None:
|
|
40
|
+
click.echo(json.dumps(data, ensure_ascii=False, indent=2), err=error, color=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_feed(*, force_refresh: bool) -> DiscoveryFeed:
|
|
44
|
+
try:
|
|
45
|
+
return fetch_discovery(community=None, force_refresh=force_refresh)
|
|
46
|
+
except OedError as exc:
|
|
47
|
+
_print_json(exc.to_dict(), error=True)
|
|
48
|
+
sys.exit(exc.code)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OedCli(click.Group):
|
|
52
|
+
"""Click group that decorates ``--help`` with the live discovery feed.
|
|
53
|
+
|
|
54
|
+
Falls back gracefully if the gateway is unreachable / the cache is empty:
|
|
55
|
+
no extra section in that case, the standard help still renders.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def get_help(self, ctx: click.Context) -> str:
|
|
59
|
+
original = super().get_help(ctx)
|
|
60
|
+
extra = self._services_help_section()
|
|
61
|
+
return f"{original}\n\n{extra}" if extra else original
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _services_help_section() -> str | None:
|
|
65
|
+
try:
|
|
66
|
+
feed = fetch_discovery()
|
|
67
|
+
except Exception:
|
|
68
|
+
return None
|
|
69
|
+
community = current_community()
|
|
70
|
+
services = feed.for_community(community)
|
|
71
|
+
if not services:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
widths = {"name": max(len(s.service_name) for s in services)}
|
|
75
|
+
rows: list[str] = []
|
|
76
|
+
for s in services:
|
|
77
|
+
desc = (s.description or s.title or "").strip().split("\n", 1)[0]
|
|
78
|
+
if len(desc) > 78:
|
|
79
|
+
desc = desc[:75] + "..."
|
|
80
|
+
rows.append(f" {s.service_name.ljust(widths['name'])} {desc}")
|
|
81
|
+
|
|
82
|
+
first = services[0].service_name
|
|
83
|
+
title = (
|
|
84
|
+
f"Auto-discovered services (community='{community}', "
|
|
85
|
+
f"{len(services)} service{'' if len(services) == 1 else 's'} "
|
|
86
|
+
f"resolved live from the gateway):"
|
|
87
|
+
)
|
|
88
|
+
return (
|
|
89
|
+
f"{title}\n\n"
|
|
90
|
+
+ "\n".join(rows)
|
|
91
|
+
+ "\n\n"
|
|
92
|
+
"Every declared query / path parameter on an operation is exposed\n"
|
|
93
|
+
"as its own --<kebab-case> flag. Use `oed <service> --help` to list\n"
|
|
94
|
+
"operations, then `oed <service> <operation> --help` to see the\n"
|
|
95
|
+
"flags for that operation. For the full spec: `oed schema <service>`.\n"
|
|
96
|
+
"\n"
|
|
97
|
+
"Quickstart:\n"
|
|
98
|
+
f" oed {first} # list operations\n"
|
|
99
|
+
f" oed cve-sa-backend getSecurityNoticeByCveId --cve-id CVE-2019-10082\n"
|
|
100
|
+
f" oed {first} <operation> --<flag> <value> --dry-run # preview, no network\n"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_service(service: ServiceMeta, *, age_s: float | None) -> dict[str, Any]:
|
|
105
|
+
out: dict[str, Any] = {
|
|
106
|
+
"name": service.name,
|
|
107
|
+
"service_name": service.service_name,
|
|
108
|
+
"community": service.community,
|
|
109
|
+
"title": service.title,
|
|
110
|
+
"version": service.version,
|
|
111
|
+
}
|
|
112
|
+
if service.description:
|
|
113
|
+
out["description"] = service.description
|
|
114
|
+
if service.base_url:
|
|
115
|
+
out["base_url"] = service.base_url
|
|
116
|
+
if age_s is not None:
|
|
117
|
+
out["fetched_seconds_ago"] = round(age_s, 1)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@click.group(
|
|
122
|
+
cls=OedCli,
|
|
123
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
124
|
+
invoke_without_command=True,
|
|
125
|
+
)
|
|
126
|
+
@click.version_option(version=__version__, prog_name="oed")
|
|
127
|
+
@click.option(
|
|
128
|
+
"--no-color",
|
|
129
|
+
is_flag=True,
|
|
130
|
+
default=True,
|
|
131
|
+
help="Disable ANSI colors in output (default: true, AI-friendly).",
|
|
132
|
+
)
|
|
133
|
+
@click.pass_context
|
|
134
|
+
def cli(ctx: click.Context, no_color: bool) -> None:
|
|
135
|
+
"""oed — openEuler Infra command line. Auto-discovered, AI-friendly."""
|
|
136
|
+
ctx.ensure_object(dict)
|
|
137
|
+
ctx.obj["no_color"] = no_color
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@cli.command("info")
|
|
141
|
+
def info() -> None:
|
|
142
|
+
"""Show gateway reachability and the discovery snapshot summary."""
|
|
143
|
+
import time
|
|
144
|
+
|
|
145
|
+
feed = _resolve_feed(force_refresh=False)
|
|
146
|
+
age = time.time() - feed.fetched_at
|
|
147
|
+
|
|
148
|
+
services = feed.services
|
|
149
|
+
payload = {
|
|
150
|
+
"ok": True,
|
|
151
|
+
"oed_version": __version__,
|
|
152
|
+
"gateway": DEFAULT_GATEWAY,
|
|
153
|
+
"community": current_community(),
|
|
154
|
+
"services_total": len(services),
|
|
155
|
+
"communities_seen": sorted({s.community for s in services}),
|
|
156
|
+
"cache": {
|
|
157
|
+
"ttl_seconds": CACHE_TTL_SECONDS,
|
|
158
|
+
"stale": age >= CACHE_TTL_SECONDS,
|
|
159
|
+
"fetched_seconds_ago": round(age, 1),
|
|
160
|
+
"fetched_at_iso": dt.datetime.fromtimestamp(
|
|
161
|
+
feed.fetched_at, tz=dt.timezone.utc
|
|
162
|
+
).isoformat(),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
_print_json(payload)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@cli.command("services")
|
|
169
|
+
@click.option("--community", default=None, help="Limit to one community (default: current).")
|
|
170
|
+
@click.option("--refresh", is_flag=True, help="Force re-fetch the discovery feed first.")
|
|
171
|
+
def services_cmd(community: str | None, refresh: bool) -> None:
|
|
172
|
+
"""List services in the discovery feed."""
|
|
173
|
+
import time
|
|
174
|
+
|
|
175
|
+
feed = _resolve_feed(force_refresh=refresh)
|
|
176
|
+
target = community or current_community()
|
|
177
|
+
items = [s for s in feed.services if s.community == target]
|
|
178
|
+
age = time.time() - feed.fetched_at
|
|
179
|
+
_print_json([_format_service(s, age_s=age) for s in items])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@cli.command("schema")
|
|
183
|
+
@click.argument("target")
|
|
184
|
+
@click.option("--refresh", is_flag=True, help="Force re-fetch the discovery feed first.")
|
|
185
|
+
def schema_cmd(target: str, refresh: bool) -> None:
|
|
186
|
+
"""Print OpenAPI 3.x spec for SERVICE[.METHOD] from the current community."""
|
|
187
|
+
if "." in target:
|
|
188
|
+
service_name, method = target.split(".", 1)
|
|
189
|
+
else:
|
|
190
|
+
service_name, method = target, None
|
|
191
|
+
|
|
192
|
+
feed = _resolve_feed(force_refresh=refresh)
|
|
193
|
+
community = current_community()
|
|
194
|
+
try:
|
|
195
|
+
service = feed.find_service(community, service_name)
|
|
196
|
+
except OedError as exc:
|
|
197
|
+
_print_json(exc.to_dict(), error=True)
|
|
198
|
+
sys.exit(exc.code)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
spec = fetch_spec(service)
|
|
202
|
+
except OedError as exc:
|
|
203
|
+
_print_json(exc.to_dict(), error=True)
|
|
204
|
+
sys.exit(exc.code)
|
|
205
|
+
|
|
206
|
+
if method is None:
|
|
207
|
+
_print_json(spec)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
paths: dict[str, Any] = spec.get("paths", {})
|
|
211
|
+
matches: list[dict[str, Any]] = []
|
|
212
|
+
for path, item in paths.items():
|
|
213
|
+
for verb, op in item.items():
|
|
214
|
+
if verb.lower() not in {"get", "post", "put", "patch", "delete", "head", "options"}:
|
|
215
|
+
continue
|
|
216
|
+
if op.get("operationId") == method or method in (path, f"{verb.upper()} {path}"):
|
|
217
|
+
matches.append({"path": path, "method": verb.upper(), "operation": op})
|
|
218
|
+
if not matches:
|
|
219
|
+
_print_json(
|
|
220
|
+
{
|
|
221
|
+
"ok": False,
|
|
222
|
+
"code": 4,
|
|
223
|
+
"error": "method_not_found",
|
|
224
|
+
"message": f"no path matches method '{method}' on service '{service_name}'",
|
|
225
|
+
"available_paths": sorted(paths.keys()),
|
|
226
|
+
},
|
|
227
|
+
error=True,
|
|
228
|
+
)
|
|
229
|
+
sys.exit(4)
|
|
230
|
+
|
|
231
|
+
_print_json({"service": service.name, "matches": matches})
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@cli.group()
|
|
235
|
+
def cache() -> None:
|
|
236
|
+
"""Manage the local discovery cache."""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@cache.command("show")
|
|
240
|
+
def cache_show() -> None:
|
|
241
|
+
from .discovery import _cache_path
|
|
242
|
+
|
|
243
|
+
p = _cache_path()
|
|
244
|
+
if not p.is_file():
|
|
245
|
+
_print_json({"cached": False, "path": str(p)})
|
|
246
|
+
return
|
|
247
|
+
stat = p.stat()
|
|
248
|
+
_print_json(
|
|
249
|
+
{
|
|
250
|
+
"cached": True,
|
|
251
|
+
"path": str(p),
|
|
252
|
+
"size_bytes": stat.st_size,
|
|
253
|
+
"modified_iso": dt.datetime.fromtimestamp(
|
|
254
|
+
stat.st_mtime, tz=dt.timezone.utc
|
|
255
|
+
).isoformat(),
|
|
256
|
+
"ttl_seconds": CACHE_TTL_SECONDS,
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@cache.command("clear")
|
|
262
|
+
def cache_clear() -> None:
|
|
263
|
+
from .discovery import _cache_path
|
|
264
|
+
|
|
265
|
+
p = _cache_path()
|
|
266
|
+
if p.is_file():
|
|
267
|
+
p.unlink()
|
|
268
|
+
_print_json({"ok": True, "cleared": str(p)})
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@cache.command("refresh")
|
|
272
|
+
def cache_refresh() -> None:
|
|
273
|
+
feed = fetch_discovery(community=None, force_refresh=True)
|
|
274
|
+
_print_json({"ok": True, "services_total": len(feed.services)})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
cli()
|
oed_cli/discovery.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Discovery feed retrieval, parsing and local caching.
|
|
2
|
+
|
|
3
|
+
Reads ``https://api-gateway.osinfra.cn/discovery/apis`` (per ``context/discoverAPI.md``)
|
|
4
|
+
and caches the parsed feed under ``~/.cache/oed-cli/discovery.json``. A fresh
|
|
5
|
+
fetch happens only when the cache is older than :data:`CACHE_TTL_SECONDS`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .errors import NotFoundError
|
|
19
|
+
from .http import DEFAULT_GATEWAY, get_json
|
|
20
|
+
|
|
21
|
+
CACHE_TTL_SECONDS = 600 # 10 minutes
|
|
22
|
+
DEFAULT_COMMUNITY = "openeuler"
|
|
23
|
+
APIS_URL = f"{DEFAULT_GATEWAY}/discovery/apis"
|
|
24
|
+
SPEC_URL_TEMPLATE = f"{DEFAULT_GATEWAY}/discovery/apis/{{community}}/{{service_name}}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ServiceMeta:
|
|
29
|
+
name: str
|
|
30
|
+
service_name: str
|
|
31
|
+
community: str
|
|
32
|
+
title: str
|
|
33
|
+
version: str
|
|
34
|
+
description: str
|
|
35
|
+
base_url: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_raw(cls, raw: dict[str, Any]) -> ServiceMeta:
|
|
39
|
+
return cls(
|
|
40
|
+
name=raw["name"],
|
|
41
|
+
service_name=raw["service_name"],
|
|
42
|
+
community=raw["community"],
|
|
43
|
+
title=raw.get("title", ""),
|
|
44
|
+
version=raw.get("version", ""),
|
|
45
|
+
description=raw.get("description", ""),
|
|
46
|
+
base_url=raw.get("base_url", ""),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class DiscoveryFeed:
|
|
52
|
+
fetched_at: float
|
|
53
|
+
raw: dict[str, Any]
|
|
54
|
+
services: list[ServiceMeta] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def for_community(self, community: str) -> list[ServiceMeta]:
|
|
57
|
+
return [s for s in self.services if s.community == community]
|
|
58
|
+
|
|
59
|
+
def find_service(self, community: str, service_name: str) -> ServiceMeta:
|
|
60
|
+
for s in self.services:
|
|
61
|
+
if s.community == community and s.service_name == service_name:
|
|
62
|
+
return s
|
|
63
|
+
raise NotFoundError(
|
|
64
|
+
f"service '{service_name}' not found in community '{community}'",
|
|
65
|
+
kind="service_not_found",
|
|
66
|
+
hint="Run `oed services` to see the registered services.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cache_dir() -> Path:
|
|
71
|
+
override = os.environ.get("OED_CACHE_DIR")
|
|
72
|
+
if override:
|
|
73
|
+
return Path(override).expanduser()
|
|
74
|
+
if platform.system() == "Windows":
|
|
75
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
76
|
+
return base / "oed-cli" / "cache"
|
|
77
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
78
|
+
base = Path(xdg) if xdg else Path.home() / ".cache"
|
|
79
|
+
return base / "oed-cli"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cache_path() -> Path:
|
|
83
|
+
return _cache_dir() / "discovery.json"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_cache(path: Path) -> DiscoveryFeed | None:
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return None
|
|
93
|
+
return _materialize(raw)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _materialize(raw: dict[str, Any]) -> DiscoveryFeed:
|
|
97
|
+
fetched_at = float(raw.get("__oed_fetched_at", 0))
|
|
98
|
+
services = [ServiceMeta.from_raw(s) for s in raw.get("services", [])]
|
|
99
|
+
return DiscoveryFeed(fetched_at=fetched_at, raw=raw, services=services)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_cache(path: Path, feed: DiscoveryFeed) -> None:
|
|
103
|
+
payload = {
|
|
104
|
+
"__oed_fetched_at": feed.fetched_at,
|
|
105
|
+
"services": [s.__dict__ for s in feed.services],
|
|
106
|
+
}
|
|
107
|
+
try:
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
110
|
+
except OSError:
|
|
111
|
+
# Cache is best-effort; failures here are not fatal.
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fetch_remote(community: str | None) -> DiscoveryFeed:
|
|
116
|
+
"""Hit the gateway. If ``community`` is provided, request that variant so the
|
|
117
|
+
payload layout matches the per-community view; otherwise request the global
|
|
118
|
+
payload which is keyed by community."""
|
|
119
|
+
|
|
120
|
+
if community:
|
|
121
|
+
data = get_json(APIS_URL, params={"community": community})
|
|
122
|
+
services: list[ServiceMeta] = []
|
|
123
|
+
if isinstance(data, list):
|
|
124
|
+
services = [ServiceMeta.from_raw(s) for s in data]
|
|
125
|
+
else:
|
|
126
|
+
data = get_json(APIS_URL)
|
|
127
|
+
services = []
|
|
128
|
+
communities = data.get("communities", {}) if isinstance(data, dict) else {}
|
|
129
|
+
if isinstance(communities, dict):
|
|
130
|
+
for arr in communities.values():
|
|
131
|
+
if isinstance(arr, list):
|
|
132
|
+
services.extend(ServiceMeta.from_raw(s) for s in arr)
|
|
133
|
+
|
|
134
|
+
raw_payload = data if isinstance(data, dict) else {"services": data}
|
|
135
|
+
return DiscoveryFeed(
|
|
136
|
+
fetched_at=time.time(), raw=raw_payload, services=services
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def fetch_discovery(*, community: str | None = None, force_refresh: bool = False) -> DiscoveryFeed:
|
|
141
|
+
"""Return the discovery feed, using cache when fresh."""
|
|
142
|
+
|
|
143
|
+
cache_file = _cache_path()
|
|
144
|
+
cached = None if force_refresh else _read_cache(cache_file)
|
|
145
|
+
if cached is not None and (time.time() - cached.fetched_at) < CACHE_TTL_SECONDS:
|
|
146
|
+
return cached
|
|
147
|
+
|
|
148
|
+
feed = _fetch_remote(community)
|
|
149
|
+
_write_cache(cache_file, feed)
|
|
150
|
+
return feed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_spec(service: ServiceMeta) -> dict[str, Any]:
|
|
154
|
+
"""Return the OpenAPI 3.x spec for one service, parsed JSON."""
|
|
155
|
+
|
|
156
|
+
url = SPEC_URL_TEMPLATE.format(community=service.community, service_name=service.service_name)
|
|
157
|
+
data = get_json(url)
|
|
158
|
+
if not isinstance(data, dict) or "openapi" not in data:
|
|
159
|
+
raise NotFoundError(
|
|
160
|
+
f"spec at {url} did not return an OpenAPI document",
|
|
161
|
+
kind="spec_not_found",
|
|
162
|
+
hint="The registered service may be missing its openapi.yaml upstream.",
|
|
163
|
+
)
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def current_community() -> str:
|
|
168
|
+
return os.environ.get("OED_COMMUNITY", DEFAULT_COMMUNITY)
|