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.
- vcf_super_cli-0.2.0.dist-info/METADATA +101 -0
- vcf_super_cli-0.2.0.dist-info/RECORD +31 -0
- vcf_super_cli-0.2.0.dist-info/WHEEL +4 -0
- vcf_super_cli-0.2.0.dist-info/entry_points.txt +3 -0
- vcf_super_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- vsc/__init__.py +11 -0
- vsc/_version.py +7 -0
- vsc/cli/__init__.py +0 -0
- vsc/cli/app.py +98 -0
- vsc/cli/profiles.py +208 -0
- vsc/cli/skill.py +28 -0
- vsc/config/__init__.py +7 -0
- vsc/config/schema.py +39 -0
- vsc/config/store.py +105 -0
- vsc/connect/__init__.py +6 -0
- vsc/connect/session.py +73 -0
- vsc/connect/targets.py +121 -0
- vsc/gen/__init__.py +7 -0
- vsc/gen/builder.py +243 -0
- vsc/gen/discover.py +221 -0
- vsc/gen/model.py +104 -0
- vsc/gen/params.py +232 -0
- vsc/gen/preview.py +82 -0
- vsc/logging_config.py +30 -0
- vsc/output/__init__.py +0 -0
- vsc/output/errors.py +106 -0
- vsc/output/exit_codes.py +40 -0
- vsc/output/render.py +134 -0
- vsc/skill/__init__.py +1 -0
- vsc/skill/assets/SKILL.md +96 -0
- vsc/skill/export.py +26 -0
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))
|
vsc/connect/__init__.py
ADDED
|
@@ -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
|