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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """oed-cli — openEuler Infra command line, auto-discovered, AI-friendly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
6
+ __all__ = ["__version__"]
oed_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m oed_cli`` as an alias for the ``oed`` command."""
2
+
3
+ from oed_cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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 getSecurityNoticeByCveId --cve-id CVE-2019-10082\n"
100
+ f" oed meeting listMeetings --date 2026-07-29\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)