vcf-super-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.
vsc/cli/skill.py ADDED
@@ -0,0 +1,28 @@
1
+ """`vsc skill` commands: export the bundled agent Skill."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from vsc.output.render import to_json
10
+ from vsc.skill.export import export_path, export_skill
11
+
12
+ skill_app = typer.Typer(no_args_is_help=True, help="The bundled agent Skill.")
13
+
14
+
15
+ @skill_app.command("export")
16
+ def export(
17
+ directory: Path = typer.Argument(..., help="Directory to export the Skill into."),
18
+ apply: bool = typer.Option(
19
+ False, "--apply", help="Actually write the file (otherwise dry-run prints the path)."
20
+ ),
21
+ ) -> None:
22
+ """Export the bundled SKILL.md to ``<directory>/vcf-super-cli/SKILL.md``."""
23
+ target = export_path(directory)
24
+ if not apply:
25
+ print(to_json({"dry_run": True, "would_write": str(target)}))
26
+ return
27
+ written = export_skill(directory)
28
+ print(to_json({"written": str(written)}))
vsc/config/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Named profiles and configuration for ``vsc``.
2
+
3
+ A profile bundles per-backend connection details (server, username, optional
4
+ password, TLS verification). Profiles live in a YAML file under the platform
5
+ config dir; passwords are kept in the OS keyring by default. Environment
6
+ variables (``VSC_*``) always override the stored profile.
7
+ """
vsc/config/schema.py ADDED
@@ -0,0 +1,39 @@
1
+ """Pydantic models for the on-disk configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ BACKENDS = ("vsphere", "nsx")
8
+
9
+
10
+ class BackendCreds(BaseModel):
11
+ """Connection details for one backend within a profile."""
12
+
13
+ server: str
14
+ username: str
15
+ password: str | None = None # None => look in the OS keyring
16
+ insecure: bool = False
17
+
18
+ model_config = {"extra": "forbid"}
19
+
20
+
21
+ class Profile(BaseModel):
22
+ """A named target environment (a vCenter and/or an NSX manager)."""
23
+
24
+ vsphere: BackendCreds | None = None
25
+ nsx: BackendCreds | None = None
26
+
27
+ model_config = {"extra": "forbid"}
28
+
29
+ def backend(self, name: str) -> BackendCreds | None:
30
+ return getattr(self, name, None)
31
+
32
+
33
+ class Config(BaseModel):
34
+ """The whole configuration file."""
35
+
36
+ current_profile: str | None = None
37
+ profiles: dict[str, Profile] = Field(default_factory=dict)
38
+
39
+ model_config = {"extra": "forbid"}
vsc/config/store.py ADDED
@@ -0,0 +1,105 @@
1
+ """Load/save the config file and broker passwords through the OS keyring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import keyring
10
+ import platformdirs
11
+ import structlog
12
+ from pydantic import ValidationError
13
+ from ruamel.yaml import YAML
14
+ from ruamel.yaml.error import YAMLError
15
+
16
+ from vsc.config.schema import Config
17
+
18
+ log = structlog.get_logger(__name__)
19
+
20
+ _KEYRING_SERVICE = "vcf-super-cli"
21
+ _yaml = YAML(typ="safe")
22
+ _yaml.default_flow_style = False
23
+
24
+
25
+ class ConfigError(Exception):
26
+ """The configuration file exists but is malformed or invalid."""
27
+
28
+
29
+ def config_path() -> Path:
30
+ """Location of the config file (``VSC_CONFIG_FILE`` overrides the default)."""
31
+ override = os.environ.get("VSC_CONFIG_FILE")
32
+ if override:
33
+ return Path(override)
34
+ return Path(platformdirs.user_config_dir("vcf-super-cli", appauthor=False)) / "config.yaml"
35
+
36
+
37
+ def load_config() -> Config:
38
+ """Read the config file, returning an empty Config if it does not exist.
39
+
40
+ Raises :class:`ConfigError` (never a raw parse/validation error) if the file
41
+ exists but is malformed, so callers can map it to a clean exit code.
42
+ """
43
+ path = config_path()
44
+ if not path.exists():
45
+ return Config()
46
+ try:
47
+ with path.open("r", encoding="utf-8") as fh:
48
+ data = _yaml.load(fh) or {}
49
+ return Config.model_validate(data)
50
+ except (YAMLError, ValidationError, TypeError) as exc:
51
+ raise ConfigError(f"invalid config at {path}: {exc}") from exc
52
+
53
+
54
+ def save_config(config: Config) -> Path:
55
+ """Atomically write the config file at mode 0600 and return its path.
56
+
57
+ The file is created with 0600 from the start (never a looser intermediate
58
+ mode) and swapped in via an atomic rename, so a cleartext password is never
59
+ momentarily world/group-readable.
60
+ """
61
+ path = config_path()
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.parent.chmod(0o700)
64
+ data = config.model_dump(exclude_none=True)
65
+ buffer = io.StringIO()
66
+ _yaml.dump(data, buffer)
67
+
68
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
69
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
70
+ try:
71
+ os.write(fd, buffer.getvalue().encode("utf-8"))
72
+ finally:
73
+ os.close(fd)
74
+ os.replace(tmp, path)
75
+ path.chmod(0o600)
76
+ return path
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # Keyring (best-effort; a missing backend degrades gracefully)
81
+ # --------------------------------------------------------------------------- #
82
+
83
+
84
+ def keyring_get(profile: str, backend: str) -> str | None:
85
+ try:
86
+ return keyring.get_password(_KEYRING_SERVICE, f"{profile}:{backend}")
87
+ except Exception as exc:
88
+ log.debug("keyring.get_failed", error=str(exc))
89
+ return None
90
+
91
+
92
+ def keyring_set(profile: str, backend: str, password: str) -> bool:
93
+ try:
94
+ keyring.set_password(_KEYRING_SERVICE, f"{profile}:{backend}", password)
95
+ return True
96
+ except Exception as exc:
97
+ log.debug("keyring.set_failed", error=str(exc))
98
+ return False
99
+
100
+
101
+ def keyring_delete(profile: str, backend: str) -> None:
102
+ try:
103
+ keyring.delete_password(_KEYRING_SERVICE, f"{profile}:{backend}")
104
+ except Exception as exc:
105
+ log.debug("keyring.delete_failed", error=str(exc))
@@ -0,0 +1,6 @@
1
+ """Connections, sessions, and authentication for vCenter and NSX.
2
+
3
+ The generator is connection-agnostic: it asks a ``connect_for_backend`` callable
4
+ for an authenticated ``StubConfiguration`` only when a command actually runs, so
5
+ ``--help`` and tree assembly stay fully offline.
6
+ """
vsc/connect/session.py ADDED
@@ -0,0 +1,73 @@
1
+ """Build authenticated vAPI ``StubConfiguration`` objects.
2
+
3
+ vCenter uses session-id auth: username/password is exchanged for a session id
4
+ (one network round-trip), then every call carries that session. NSX uses HTTP
5
+ basic auth applied per request — no session to create or delete.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import requests
13
+ import urllib3
14
+ from com.vmware.cis_client import Session
15
+ from vmware.vapi.bindings.stub import StubConfiguration
16
+ from vmware.vapi.lib.connect import get_requests_connector
17
+ from vmware.vapi.security.client.security_context_filter import (
18
+ LegacySecurityContextFilter,
19
+ )
20
+ from vmware.vapi.security.session import create_session_security_context
21
+ from vmware.vapi.security.user_password import (
22
+ create_user_password_security_context,
23
+ )
24
+ from vmware.vapi.stdlib.client.factories import StubConfigurationFactory
25
+
26
+
27
+ def _session(verify: bool) -> requests.Session:
28
+ sess = requests.Session()
29
+ sess.verify = verify
30
+ if not verify:
31
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
32
+ return sess
33
+
34
+
35
+ def connect_vsphere(
36
+ server: str, username: str, password: str, *, verify: bool = True
37
+ ) -> StubConfiguration:
38
+ """Authenticate to vCenter and return a session-backed StubConfiguration."""
39
+ sess = _session(verify)
40
+ url = f"https://{server}/api"
41
+ login_cfg = StubConfigurationFactory.new_std_configuration(
42
+ get_requests_connector(
43
+ session=sess,
44
+ url=url,
45
+ provider_filter_chain=[
46
+ LegacySecurityContextFilter(
47
+ security_context=create_user_password_security_context(username, password)
48
+ )
49
+ ],
50
+ )
51
+ )
52
+ session_id: str = Session(login_cfg).create()
53
+ return StubConfigurationFactory.new_std_configuration(
54
+ get_requests_connector(
55
+ session=sess,
56
+ url=url,
57
+ provider_filter_chain=[
58
+ LegacySecurityContextFilter(
59
+ security_context=create_session_security_context(session_id)
60
+ )
61
+ ],
62
+ )
63
+ )
64
+
65
+
66
+ def connect_nsx(
67
+ server: str, username: str, password: str, *, verify: bool = True
68
+ ) -> StubConfiguration:
69
+ """Return a basic-auth StubConfiguration for the NSX Policy API."""
70
+ sess = _session(verify)
71
+ connector: Any = get_requests_connector(session=sess, url=f"https://{server}")
72
+ connector.set_security_context(create_user_password_security_context(username, password))
73
+ return StubConfiguration(connector)
vsc/connect/targets.py ADDED
@@ -0,0 +1,121 @@
1
+ """Resolve connection targets and hand out cached StubConfigurations.
2
+
3
+ Resolution order for each field: the active profile (file + keyring), then
4
+ environment-variable overrides (``VSC_<BACKEND>_*``), which always win. With no
5
+ config file at all, pure env-var operation still works.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+
13
+ from vmware.vapi.bindings.stub import StubConfiguration
14
+
15
+ from vsc.config.schema import BackendCreds
16
+ from vsc.config.store import keyring_get, load_config
17
+ from vsc.connect.session import connect_nsx, connect_vsphere
18
+
19
+ _TRUTHY = {"1", "true", "yes", "on"}
20
+
21
+ # Process-wide active profile override set by the global --profile option.
22
+ # Held in a dict to avoid a module-level `global` statement.
23
+ _state: dict[str, str | None] = {"profile": None}
24
+
25
+
26
+ class TargetNotConfigured(Exception):
27
+ """A backend was invoked without the credentials needed to reach it."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Target:
32
+ """Resolved connection details for one backend."""
33
+
34
+ server: str
35
+ username: str
36
+ password: str
37
+ verify: bool
38
+
39
+
40
+ def set_active_profile(name: str | None) -> None:
41
+ """Set the profile selected via ``--profile`` for this process."""
42
+ _state["profile"] = name
43
+
44
+
45
+ def active_profile_name() -> str | None:
46
+ """The effective profile name: --profile, then VSC_PROFILE, then config."""
47
+ if _state["profile"]:
48
+ return _state["profile"]
49
+ env = os.environ.get("VSC_PROFILE")
50
+ if env:
51
+ return env
52
+ return load_config().current_profile
53
+
54
+
55
+ def _profile_creds(backend: str) -> tuple[BackendCreds | None, str | None]:
56
+ name = active_profile_name()
57
+ if not name:
58
+ return None, None
59
+ profile = load_config().profiles.get(name)
60
+ if profile is None:
61
+ return None, name
62
+ return profile.backend(backend), name
63
+
64
+
65
+ def resolve_target(backend: str) -> Target:
66
+ """Resolve a :class:`Target` from profile + env overrides for ``backend``."""
67
+ creds, profile_name = _profile_creds(backend)
68
+ server = creds.server if creds else None
69
+ username = creds.username if creds else None
70
+ password = creds.password if creds else None
71
+ insecure = creds.insecure if creds else False
72
+ if creds and password is None and profile_name:
73
+ password = keyring_get(profile_name, backend)
74
+
75
+ prefix = f"VSC_{backend.upper()}"
76
+ server = os.environ.get(f"{prefix}_SERVER", server or "") or None
77
+ username = os.environ.get(f"{prefix}_USERNAME", username or "") or None
78
+ password = os.environ.get(f"{prefix}_PASSWORD", password or "") or None
79
+ env_insecure = os.environ.get(f"{prefix}_INSECURE")
80
+ if env_insecure: # non-empty only; an empty string is not an override
81
+ insecure = env_insecure.strip().lower() in _TRUTHY
82
+
83
+ missing = [
84
+ field
85
+ for field, val in (("server", server), ("username", username), ("password", password))
86
+ if not val
87
+ ]
88
+ if missing:
89
+ hint = (
90
+ f"profile {active_profile_name()!r}" if active_profile_name() else "no active profile"
91
+ )
92
+ raise TargetNotConfigured(
93
+ f"{backend}: missing {', '.join(missing)} ({hint}; "
94
+ f"set {prefix}_* env vars or run `vsc profiles add`)"
95
+ )
96
+ assert server and username and password
97
+ return Target(server=server, username=username, password=password, verify=not insecure)
98
+
99
+
100
+ _CACHE: dict[str, StubConfiguration] = {}
101
+
102
+
103
+ def connect_for_backend(backend: str) -> StubConfiguration:
104
+ """Return an authenticated StubConfiguration for ``backend`` (cached)."""
105
+ cached = _CACHE.get(backend)
106
+ if cached is not None:
107
+ return cached
108
+ target = resolve_target(backend)
109
+ if backend == "vsphere":
110
+ cfg = connect_vsphere(target.server, target.username, target.password, verify=target.verify)
111
+ elif backend == "nsx":
112
+ cfg = connect_nsx(target.server, target.username, target.password, verify=target.verify)
113
+ else: # pragma: no cover - guarded by the generator's backend values
114
+ raise TargetNotConfigured(f"unknown backend {backend!r}")
115
+ _CACHE[backend] = cfg
116
+ return cfg
117
+
118
+
119
+ def reset_cache() -> None:
120
+ """Drop cached connections (used by tests and re-auth)."""
121
+ _CACHE.clear()
vsc/gen/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Command-tree generation from the vcf-sdk vAPI bindings.
2
+
3
+ The generator introspects installed ``VapiInterface`` service classes (vCenter and
4
+ NSX Policy), reads their embedded REST + type metadata, and assembles a Typer
5
+ command tree. It runs fully offline — no server or credentials are needed to build
6
+ ``--help``.
7
+ """
vsc/gen/builder.py ADDED
@@ -0,0 +1,243 @@
1
+ """Turn :class:`Operation` objects into Typer commands and assemble the tree.
2
+
3
+ Each generated command builds its own ``inspect.Signature`` so Typer can derive
4
+ options/arguments dynamically. Connections are resolved lazily through an injected
5
+ ``connect_for_backend`` callable, keeping tree assembly and ``--help`` offline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import keyword
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ import requests
16
+ import typer
17
+ from vmware.vapi.exception import CoreException
18
+
19
+ from vsc.config.store import ConfigError
20
+ from vsc.connect.targets import TargetNotConfigured
21
+ from vsc.gen.model import Operation, Param, ParamKind
22
+ from vsc.gen.params import CoercionError, coerce_value
23
+ from vsc.gen.preview import build_request_plan
24
+ from vsc.output.errors import (
25
+ envelope_for_transport,
26
+ envelope_for_vapi,
27
+ is_vapi_error,
28
+ render_error,
29
+ )
30
+ from vsc.output.exit_codes import ExitCode
31
+ from vsc.output.render import OutputFormat, emit, emit_request
32
+
33
+ ConnectFn = Callable[[str], Any]
34
+
35
+ _OUTPUT_PARAM = "_vsc_output"
36
+ _APPLY_PARAM = "_vsc_apply"
37
+
38
+ # User-facing option names the generator injects itself; a real SDK parameter with
39
+ # one of these names is renamed so it can't shadow --output / --apply.
40
+ _RESERVED_OPTION_NAMES = frozenset({"output", "apply"})
41
+
42
+ _ANNOTATIONS: dict[ParamKind, type] = {
43
+ ParamKind.INTEGER: int,
44
+ ParamKind.DOUBLE: float,
45
+ ParamKind.BOOLEAN: bool,
46
+ }
47
+
48
+
49
+ def _annotation(param: Param) -> Any:
50
+ if param.kind in (ParamKind.LIST, ParamKind.SET):
51
+ base: Any = list[str]
52
+ else:
53
+ base = _ANNOTATIONS.get(param.kind, str)
54
+ return base if param.required else (base | None)
55
+
56
+
57
+ def _help_text(param: Param) -> str:
58
+ bits: list[str] = [param.kind.value]
59
+ if param.kind is ParamKind.ENUM and param.enum_values:
60
+ bits.append("choices: " + ", ".join(param.enum_values))
61
+ if param.kind is ParamKind.ID and param.resource_types:
62
+ bits.append(f"id of {param.resource_types}")
63
+ if param.kind in (ParamKind.STRUCT, ParamKind.MAP, ParamKind.DYNAMIC):
64
+ bits.append("JSON")
65
+ return "; ".join(bits)
66
+
67
+
68
+ def _sig_name(param: Param, used: set[str]) -> str:
69
+ name = param.name
70
+ reserved = name in (_OUTPUT_PARAM, _APPLY_PARAM) or name in _RESERVED_OPTION_NAMES
71
+ if keyword.iskeyword(name) or reserved or name in used:
72
+ name = f"{name}_"
73
+ used.add(name)
74
+ return name
75
+
76
+
77
+ def _build_signature(op: Operation) -> tuple[inspect.Signature, list[tuple[Param, str]]]:
78
+ parameters: list[inspect.Parameter] = []
79
+ spec: list[tuple[Param, str]] = []
80
+ used: set[str] = set()
81
+ # Stable order: required path args first, then everything else.
82
+ ordered = sorted(op.params, key=lambda p: (not (p.in_path and p.required), p.name))
83
+ for param in ordered:
84
+ sig_name = _sig_name(param, used)
85
+ help_text = _help_text(param)
86
+ default: Any
87
+ if param.in_path:
88
+ default = typer.Argument(... if param.required else None, help=help_text)
89
+ else:
90
+ # A param named output/apply would otherwise emit --output/--apply and
91
+ # clash with the injected options; use the suffixed sig name for those.
92
+ opt_source = sig_name if param.name in _RESERVED_OPTION_NAMES else param.name
93
+ kebab = opt_source.replace("_", "-")
94
+ # Boolean options need a dual flag so the user can send False, not
95
+ # just True; otherwise only the positive flag exists.
96
+ opt = f"--{kebab}/--no-{kebab}" if param.kind is ParamKind.BOOLEAN else f"--{kebab}"
97
+ default = typer.Option(
98
+ ... if param.required else None,
99
+ opt,
100
+ help=help_text,
101
+ hide_input=param.kind is ParamKind.SECRET,
102
+ )
103
+ parameters.append(
104
+ inspect.Parameter(
105
+ sig_name,
106
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
107
+ default=default,
108
+ annotation=_annotation(param),
109
+ )
110
+ )
111
+ spec.append((param, sig_name))
112
+ parameters.append(
113
+ inspect.Parameter(
114
+ _OUTPUT_PARAM,
115
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
116
+ default=typer.Option(OutputFormat.json, "--output", "-o", help="Output format."),
117
+ annotation=OutputFormat,
118
+ )
119
+ )
120
+ if op.is_write:
121
+ # Writes preview by default; --apply opts in to actually executing.
122
+ parameters.append(
123
+ inspect.Parameter(
124
+ _APPLY_PARAM,
125
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
126
+ default=typer.Option(
127
+ False, "--apply/--no-apply", help="Execute the change (default: dry-run)."
128
+ ),
129
+ annotation=bool,
130
+ )
131
+ )
132
+ return inspect.Signature(parameters), spec
133
+
134
+
135
+ def make_command(op: Operation, connect_fn: ConnectFn) -> Callable[..., None]:
136
+ """Build a Typer-compatible callback for ``op``."""
137
+ signature, spec = _build_signature(op)
138
+
139
+ def command(**kwargs: Any) -> None:
140
+ raw_fmt = kwargs.get(_OUTPUT_PARAM, OutputFormat.json)
141
+ fmt = raw_fmt.value if isinstance(raw_fmt, OutputFormat) else str(raw_fmt)
142
+ apply = bool(kwargs.get(_APPLY_PARAM, False))
143
+ # Coerce inputs and resolve the write plan up front. Building the plan
144
+ # serializes the request body, so a malformed/incomplete struct surfaces
145
+ # here as a clean usage error (exit 2) — before any connection — rather
146
+ # than leaking a vAPI traceback. CoreException is the SDK's client-side
147
+ # validation/serialization error.
148
+ try:
149
+ sdk_kwargs = _collect_kwargs(spec, kwargs)
150
+ plan = build_request_plan(op, sdk_kwargs) if op.is_write else None
151
+ except (CoercionError, CoreException) as exc:
152
+ _fail_usage(exc, fmt)
153
+ return
154
+ # Dry-run by default: preview the resolved request and touch nothing — no
155
+ # connection is opened unless the write is explicitly applied.
156
+ if op.is_write and not apply:
157
+ assert plan is not None # guaranteed: op.is_write -> plan built above
158
+ emit_request(plan, applied=False, fmt=fmt)
159
+ return
160
+ try:
161
+ cfg = connect_fn(op.backend)
162
+ service = op.service_cls(cfg)
163
+ method = getattr(service, op.method_name, None)
164
+ if callable(method):
165
+ result = method(**sdk_kwargs)
166
+ else:
167
+ result = service._invoke(op.op_id, sdk_kwargs)
168
+ if op.is_write:
169
+ assert plan is not None # guaranteed: op.is_write -> plan built above
170
+ emit_request(plan, applied=True, result=result, fmt=fmt)
171
+ else:
172
+ emit(result, fmt)
173
+ except TargetNotConfigured as exc:
174
+ _fail_config(exc, fmt)
175
+ except ConfigError as exc:
176
+ _fail(ExitCode.CONFIG, "ConfigError", exc, fmt)
177
+ except requests.exceptions.RequestException as exc:
178
+ env, code = envelope_for_transport(exc)
179
+ render_error(env, fmt)
180
+ raise typer.Exit(int(code)) from exc
181
+ except Exception as exc:
182
+ if not is_vapi_error(exc):
183
+ raise
184
+ env, code = envelope_for_vapi(exc)
185
+ render_error(env, fmt)
186
+ raise typer.Exit(int(code)) from exc
187
+
188
+ command.__signature__ = signature # type: ignore[attr-defined]
189
+ command.__name__ = op.cli_verb.replace("-", "_")
190
+ command.__doc__ = f"{op.http_method} {op.url_template}"
191
+ return command
192
+
193
+
194
+ def _collect_kwargs(spec: list[tuple[Param, str]], kwargs: dict[str, Any]) -> dict[str, Any]:
195
+ sdk_kwargs: dict[str, Any] = {}
196
+ for param, sig_name in spec:
197
+ value = kwargs.get(sig_name)
198
+ if value is None:
199
+ continue
200
+ if param.kind is ParamKind.ENUM and param.enum_values and value not in param.enum_values:
201
+ raise CoercionError(
202
+ f"{param.name!r}: {value!r} not in {{{', '.join(param.enum_values)}}}"
203
+ )
204
+ sdk_kwargs[param.name] = coerce_value(param, value)
205
+ return sdk_kwargs
206
+
207
+
208
+ def _fail(code: ExitCode, kind: str, exc: Exception, fmt: str) -> None:
209
+ env = {"error": {"code": int(code), "kind": kind, "message": str(exc), "details": None}}
210
+ render_error(env, fmt)
211
+ raise typer.Exit(int(code)) from exc
212
+
213
+
214
+ def _fail_config(exc: Exception, fmt: str) -> None:
215
+ _fail(ExitCode.CONFIG, "TargetNotConfigured", exc, fmt)
216
+
217
+
218
+ def _fail_usage(exc: Exception, fmt: str) -> None:
219
+ _fail(ExitCode.USAGE, "InvalidArgument", exc, fmt)
220
+
221
+
222
+ def build_group(operations: list[Operation], connect_fn: ConnectFn) -> typer.Typer:
223
+ """Assemble operations for one backend into a Typer app of service groups."""
224
+ services: dict[str, typer.Typer] = {}
225
+ used_names: dict[str, set[str]] = {}
226
+ for op in operations:
227
+ short = op.service_short.replace("_", "-")
228
+ group = services.get(short)
229
+ if group is None:
230
+ group = typer.Typer(no_args_is_help=True, help=f"{short} operations.")
231
+ services[short] = group
232
+ used_names[short] = set()
233
+ name = op.cli_verb
234
+ if name in used_names[short]:
235
+ name = op.op_id.replace("_", "-").replace("$", "-")
236
+ used_names[short].add(name)
237
+ group.command(name, help=f"{op.http_method} {op.url_template}")(
238
+ make_command(op, connect_fn)
239
+ )
240
+ root = typer.Typer(no_args_is_help=True)
241
+ for short, group in sorted(services.items()):
242
+ root.add_typer(group, name=short)
243
+ return root