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/gen/discover.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Enumerate vAPI service classes and their operations, fully offline.
|
|
2
|
+
|
|
3
|
+
Every service is a ``VapiInterface`` subclass; instantiating it with a
|
|
4
|
+
``StubConfiguration`` built on a no-op connector populates the generated
|
|
5
|
+
``_<Name>Stub`` (``service._api_interface``) whose ``_operations`` and
|
|
6
|
+
``_rest_metadata`` dicts carry everything we need — no server required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
from vmware.vapi.bindings.stub import StubConfiguration
|
|
16
|
+
from vmware.vapi.protocol.client.connector import Connector
|
|
17
|
+
|
|
18
|
+
from vsc.gen.model import Operation
|
|
19
|
+
from vsc.gen.params import param_from_type
|
|
20
|
+
|
|
21
|
+
log = structlog.get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Canonical read verbs; anything else keeps its operation id as the command name.
|
|
24
|
+
_KNOWN_VERBS = frozenset({"get", "list", "create", "delete", "update"})
|
|
25
|
+
|
|
26
|
+
# Mutating HTTP method -> base CLI verb. ``force`` variants are prefixed so they
|
|
27
|
+
# stay distinct from their non-force siblings within a service group.
|
|
28
|
+
_METHOD_VERB = {"PUT": "set", "PATCH": "patch", "DELETE": "delete"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _OfflineConnector(Connector): # type: ignore[misc]
|
|
32
|
+
"""A connector with no API provider — enough to read embedded metadata."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
super().__init__(api_provider=None, provider_filter_chain=[])
|
|
36
|
+
|
|
37
|
+
def connect(self) -> None: # pragma: no cover - never called offline
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def disconnect(self) -> None: # pragma: no cover - never called offline
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def introspect_stub(service_cls: type) -> Any:
|
|
45
|
+
"""Return the generated ``_<Name>Stub`` (ApiInterfaceStub) for a service."""
|
|
46
|
+
service = service_cls(StubConfiguration(_OfflineConnector()))
|
|
47
|
+
return service._api_interface
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _action_from_url(url_template: str) -> str | None:
|
|
51
|
+
"""Return the ``?action=<value>`` verb from a REST URL template, if present."""
|
|
52
|
+
if "?" not in url_template:
|
|
53
|
+
return None
|
|
54
|
+
query = url_template.split("?", 1)[1]
|
|
55
|
+
for part in query.split("&"):
|
|
56
|
+
key, _, value = part.partition("=")
|
|
57
|
+
if key == "action" and value:
|
|
58
|
+
return value
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cli_verb(op_id: str, http_method: str, url_template: str = "") -> str:
|
|
63
|
+
if op_id in _KNOWN_VERBS:
|
|
64
|
+
return op_id
|
|
65
|
+
low = op_id.lower()
|
|
66
|
+
# Reads: keep v0.1 behaviour exactly (these branches run before any write logic).
|
|
67
|
+
if "list" in low:
|
|
68
|
+
return "list"
|
|
69
|
+
if low.startswith("read") or "_read_" in low or "{" in low:
|
|
70
|
+
return "get"
|
|
71
|
+
if http_method == "GET":
|
|
72
|
+
return "get"
|
|
73
|
+
# Writes: prefer an explicit ``?action=`` verb, then the HTTP method, keeping
|
|
74
|
+
# ``force`` variants distinct. POST without an action falls back to its op id.
|
|
75
|
+
action = _action_from_url(url_template)
|
|
76
|
+
if action is not None:
|
|
77
|
+
return action.replace("_", "-")
|
|
78
|
+
base = _METHOD_VERB.get(http_method)
|
|
79
|
+
if base is not None:
|
|
80
|
+
return f"force-{base}" if "force" in low else base
|
|
81
|
+
return op_id.replace("$", "-").replace("_", "-")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_operations(
|
|
85
|
+
service_cls: type, backend: str, *, read_only: bool = False
|
|
86
|
+
) -> list[Operation]:
|
|
87
|
+
"""Introspect ``service_cls`` into a list of :class:`Operation`.
|
|
88
|
+
|
|
89
|
+
With ``read_only=True`` only ``GET`` operations are emitted (the v0.1 surface);
|
|
90
|
+
the v0.2 default emits writes as well.
|
|
91
|
+
"""
|
|
92
|
+
stub = introspect_stub(service_cls)
|
|
93
|
+
iface_id = stub._iface_id.get_name()
|
|
94
|
+
operations: dict[str, dict[str, Any]] = stub._operations
|
|
95
|
+
rest_metadata: dict[str, Any] = stub._rest_metadata
|
|
96
|
+
|
|
97
|
+
ops: list[Operation] = []
|
|
98
|
+
for op_id in sorted(set(operations) & set(rest_metadata)):
|
|
99
|
+
rest = rest_metadata[op_id]
|
|
100
|
+
http_method = rest.http_method
|
|
101
|
+
if read_only and http_method != "GET":
|
|
102
|
+
continue
|
|
103
|
+
input_type = operations[op_id]["input_type"]
|
|
104
|
+
path_vars = tuple(rest.get_path_variable_field_names())
|
|
105
|
+
# field name -> URL template variable (the two differ, e.g.
|
|
106
|
+
# 'resource_pool' -> 'resource-pool', 'segment_id' -> 'segmentId').
|
|
107
|
+
path_var_map = dict(getattr(rest, "_path_variables", None) or {})
|
|
108
|
+
query_vars = frozenset(rest.get_query_parameter_field_names())
|
|
109
|
+
body_param = rest.request_body_parameter
|
|
110
|
+
params = [
|
|
111
|
+
param_from_type(
|
|
112
|
+
fname,
|
|
113
|
+
input_type.get_field(fname),
|
|
114
|
+
path_vars=path_vars,
|
|
115
|
+
query_vars=query_vars,
|
|
116
|
+
body_param=body_param,
|
|
117
|
+
)
|
|
118
|
+
for fname in input_type.get_field_names()
|
|
119
|
+
]
|
|
120
|
+
ops.append(
|
|
121
|
+
Operation(
|
|
122
|
+
backend=backend,
|
|
123
|
+
service_cls=service_cls,
|
|
124
|
+
iface_id=iface_id,
|
|
125
|
+
op_id=op_id,
|
|
126
|
+
method_name=op_id,
|
|
127
|
+
cli_verb=_cli_verb(op_id, http_method, rest._url_template),
|
|
128
|
+
http_method=http_method,
|
|
129
|
+
url_template=rest._url_template,
|
|
130
|
+
path_vars=list(path_vars),
|
|
131
|
+
path_var_map=path_var_map,
|
|
132
|
+
params=params,
|
|
133
|
+
output_type=operations[op_id].get("output_type"),
|
|
134
|
+
error_types=operations[op_id].get("errors", []),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return ops
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
# Curated service catalogs (read + write surface)
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_services(backend: str, specs: tuple[tuple[str, tuple[str, ...]], ...]) -> list[type]:
|
|
146
|
+
"""Resolve ``(module, names)`` specs into classes, skipping any that moved.
|
|
147
|
+
|
|
148
|
+
A missing module or symbol is logged and skipped so one relocated class can
|
|
149
|
+
never break a whole backend's command surface.
|
|
150
|
+
"""
|
|
151
|
+
services: list[type] = []
|
|
152
|
+
for module_path, names in specs:
|
|
153
|
+
try:
|
|
154
|
+
module = importlib.import_module(module_path)
|
|
155
|
+
except ImportError as exc:
|
|
156
|
+
log.warning(
|
|
157
|
+
"service.module_import_failed", backend=backend, module=module_path, error=str(exc)
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
for name in names:
|
|
161
|
+
cls = getattr(module, name, None)
|
|
162
|
+
if cls is None:
|
|
163
|
+
log.warning("service.missing", backend=backend, module=module_path, name=name)
|
|
164
|
+
continue
|
|
165
|
+
services.append(cls)
|
|
166
|
+
return services
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# vCenter services. Core inventory lives in ``vcenter_client``; v0.2 adds VM power,
|
|
170
|
+
# VM hardware (cpu/memory/disk/ethernet) and resource pools for the write surface.
|
|
171
|
+
_VSPHERE_SERVICE_SPECS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
172
|
+
(
|
|
173
|
+
"com.vmware.vcenter_client",
|
|
174
|
+
("VM", "Host", "Cluster", "Datacenter", "Datastore", "Folder", "Network", "ResourcePool"),
|
|
175
|
+
),
|
|
176
|
+
("com.vmware.vcenter.vm_client", ("Power",)),
|
|
177
|
+
("com.vmware.vcenter.vm.hardware_client", ("Cpu", "Memory", "Disk", "Ethernet")),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def vsphere_services() -> list[type]:
|
|
182
|
+
"""vCenter service classes exposed under ``vsc vsphere``."""
|
|
183
|
+
return _load_services("vsphere", _VSPHERE_SERVICE_SPECS)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# NSX Policy services. Imported defensively so a single moved symbol cannot break
|
|
187
|
+
# the whole NSX surface. v0.2 adds IP pools, DHCP configs and Tier-1 locale services.
|
|
188
|
+
_NSX_SERVICE_SPECS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
189
|
+
(
|
|
190
|
+
"vcf.nsx.policy.api.v1.infra_client",
|
|
191
|
+
(
|
|
192
|
+
"Segments",
|
|
193
|
+
"Tier0s",
|
|
194
|
+
"Tier1s",
|
|
195
|
+
"Services",
|
|
196
|
+
"IpPools",
|
|
197
|
+
"DhcpServerConfigs",
|
|
198
|
+
"DhcpRelayConfigs",
|
|
199
|
+
),
|
|
200
|
+
),
|
|
201
|
+
(
|
|
202
|
+
"vcf.nsx.policy.api.v1.infra.domains_client",
|
|
203
|
+
("Groups", "SecurityPolicies", "GatewayPolicies"),
|
|
204
|
+
),
|
|
205
|
+
("vcf.nsx.policy.api.v1.infra.tier_1s_client", ("LocaleServices",)),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def nsx_services() -> list[type]:
|
|
210
|
+
"""NSX Policy service classes exposed under ``vsc nsx``."""
|
|
211
|
+
return _load_services("nsx", _NSX_SERVICE_SPECS)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def discover_all(*, read_only: bool = False) -> list[Operation]:
|
|
215
|
+
"""Discover every operation across both backends (writes included by default)."""
|
|
216
|
+
ops: list[Operation] = []
|
|
217
|
+
for cls in vsphere_services():
|
|
218
|
+
ops.extend(discover_operations(cls, "vsphere", read_only=read_only))
|
|
219
|
+
for cls in nsx_services():
|
|
220
|
+
ops.extend(discover_operations(cls, "nsx", read_only=read_only))
|
|
221
|
+
return ops
|
vsc/gen/model.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Introspected command model: :class:`Operation` and :class:`Param`.
|
|
2
|
+
|
|
3
|
+
These dataclasses are the generator's intermediate representation. ``discover``
|
|
4
|
+
populates them from the SDK's vAPI metadata; ``params`` and ``builder`` consume
|
|
5
|
+
them. They carry plain Python data plus opaque references to the original vAPI
|
|
6
|
+
type objects (``raw_type``) and binding classes (``struct_class``) used at
|
|
7
|
+
invocation time.
|
|
8
|
+
|
|
9
|
+
A few fields are captured now but consumed later: ``is_body`` / ``in_query`` feed
|
|
10
|
+
HTTP request routing for the v0.2 write surface, and ``output_type`` /
|
|
11
|
+
``error_types`` are reserved for richer table/error rendering. They are populated
|
|
12
|
+
eagerly so discovery stays the single source of truth.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ParamKind(str, Enum):
|
|
23
|
+
"""The unwrapped, concrete kind of a parameter's vAPI type."""
|
|
24
|
+
|
|
25
|
+
STRING = "string"
|
|
26
|
+
INTEGER = "integer"
|
|
27
|
+
DOUBLE = "double"
|
|
28
|
+
BOOLEAN = "boolean"
|
|
29
|
+
ENUM = "enum"
|
|
30
|
+
ID = "id"
|
|
31
|
+
SECRET = "secret"
|
|
32
|
+
DATETIME = "datetime"
|
|
33
|
+
URI = "uri"
|
|
34
|
+
LIST = "list"
|
|
35
|
+
SET = "set"
|
|
36
|
+
MAP = "map"
|
|
37
|
+
STRUCT = "struct"
|
|
38
|
+
DYNAMIC = "dynamic"
|
|
39
|
+
BLOB = "blob"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Scalar kinds that map to a single CLI value (no JSON / container handling).
|
|
43
|
+
SCALAR_KINDS: frozenset[ParamKind] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
ParamKind.STRING,
|
|
46
|
+
ParamKind.INTEGER,
|
|
47
|
+
ParamKind.DOUBLE,
|
|
48
|
+
ParamKind.BOOLEAN,
|
|
49
|
+
ParamKind.ENUM,
|
|
50
|
+
ParamKind.ID,
|
|
51
|
+
ParamKind.SECRET,
|
|
52
|
+
ParamKind.DATETIME,
|
|
53
|
+
ParamKind.URI,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Param:
|
|
60
|
+
"""A single operation parameter, unwrapped from its vAPI ``Type``."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
kind: ParamKind
|
|
64
|
+
required: bool
|
|
65
|
+
in_path: bool = False
|
|
66
|
+
in_query: bool = False
|
|
67
|
+
is_body: bool = False
|
|
68
|
+
resource_types: str | None = None
|
|
69
|
+
enum_values: list[str] = field(default_factory=list)
|
|
70
|
+
element: Param | None = None
|
|
71
|
+
key_kind: ParamKind | None = None
|
|
72
|
+
value_kind: ParamKind | None = None
|
|
73
|
+
struct_name: str | None = None
|
|
74
|
+
struct_class: type | None = None
|
|
75
|
+
raw_type: Any = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Operation:
|
|
80
|
+
"""A single introspected, invokable operation on a service class."""
|
|
81
|
+
|
|
82
|
+
backend: str # 'vsphere' | 'nsx'
|
|
83
|
+
service_cls: type
|
|
84
|
+
iface_id: str
|
|
85
|
+
op_id: str
|
|
86
|
+
method_name: str
|
|
87
|
+
cli_verb: str
|
|
88
|
+
http_method: str
|
|
89
|
+
url_template: str
|
|
90
|
+
path_vars: list[str] = field(default_factory=list)
|
|
91
|
+
path_var_map: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
params: list[Param] = field(default_factory=list)
|
|
93
|
+
output_type: Any = None
|
|
94
|
+
error_types: list[Any] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def service_short(self) -> str:
|
|
98
|
+
"""The last segment of the vAPI interface id, e.g. ``VM`` -> ``vm``."""
|
|
99
|
+
return self.iface_id.split(".")[-1].lower()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_write(self) -> bool:
|
|
103
|
+
"""True for mutating operations (anything other than ``GET``)."""
|
|
104
|
+
return self.http_method != "GET"
|
vsc/gen/params.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Map vAPI binding ``Type`` objects to the :class:`Param` model and coerce CLI
|
|
2
|
+
input back into the values the SDK call expects.
|
|
3
|
+
|
|
4
|
+
The vAPI runtime performs *no* string coercion: integers, booleans, datetimes,
|
|
5
|
+
and ``set`` vs ``list`` must already be the right Python type when handed to a
|
|
6
|
+
generated operation method. This module owns that conversion.
|
|
7
|
+
|
|
8
|
+
Key rules (verified against the installed SDK):
|
|
9
|
+
|
|
10
|
+
* ``OptionalType`` wraps optional fields — unwrap and treat as not-required.
|
|
11
|
+
* ``ReferenceType`` is lazy — always resolve via ``.resolved_type`` *before*
|
|
12
|
+
classifying; enums and nested structs hide behind it.
|
|
13
|
+
* ``SetType`` needs a real ``set``; ``ListType`` a ``list`` — not interchangeable.
|
|
14
|
+
* ``datetime`` must be timezone-aware.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as _dt
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import vmware.vapi.bindings.type as bt
|
|
24
|
+
|
|
25
|
+
from vsc.gen.model import SCALAR_KINDS, Param, ParamKind
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CoercionError(ValueError):
|
|
29
|
+
"""Raised when a CLI value cannot be coerced to the SDK type."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _unwrap(ftype: Any) -> tuple[bool, Any]:
|
|
33
|
+
"""Return ``(required, core_type)`` with Optional/Reference unwrapped."""
|
|
34
|
+
required = not isinstance(ftype, bt.OptionalType)
|
|
35
|
+
core = ftype.element_type if isinstance(ftype, bt.OptionalType) else ftype
|
|
36
|
+
if isinstance(core, bt.ReferenceType):
|
|
37
|
+
core = core.resolved_type
|
|
38
|
+
return required, core
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _enum_values(core: Any) -> list[str]:
|
|
42
|
+
# vAPI EnumType has no ``.values``; the members live on the binding class.
|
|
43
|
+
binding_class = getattr(core, "binding_class", None)
|
|
44
|
+
if binding_class is None or not hasattr(binding_class, "get_values"):
|
|
45
|
+
return []
|
|
46
|
+
return [str(v) for v in binding_class.get_values()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _loads_object(param_name: str, value: Any) -> Any:
|
|
50
|
+
"""Parse a JSON value, raising :class:`CoercionError` on malformed input."""
|
|
51
|
+
if isinstance(value, (dict, list)):
|
|
52
|
+
return value
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(str(value))
|
|
55
|
+
except (ValueError, TypeError) as exc:
|
|
56
|
+
raise CoercionError(f"{param_name!r}: invalid JSON ({exc})") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _kind_of(core: Any) -> ParamKind:
|
|
60
|
+
"""Classify an already-unwrapped core vAPI type into a :class:`ParamKind`."""
|
|
61
|
+
# Order matters: more specific subclasses first.
|
|
62
|
+
if isinstance(core, bt.SecretType):
|
|
63
|
+
return ParamKind.SECRET
|
|
64
|
+
if isinstance(core, bt.IdType):
|
|
65
|
+
return ParamKind.ID
|
|
66
|
+
if isinstance(core, bt.URIType):
|
|
67
|
+
return ParamKind.URI
|
|
68
|
+
if isinstance(core, bt.EnumType):
|
|
69
|
+
return ParamKind.ENUM
|
|
70
|
+
if isinstance(core, bt.StringType):
|
|
71
|
+
return ParamKind.STRING
|
|
72
|
+
if isinstance(core, bt.IntegerType):
|
|
73
|
+
return ParamKind.INTEGER
|
|
74
|
+
if isinstance(core, bt.DoubleType):
|
|
75
|
+
return ParamKind.DOUBLE
|
|
76
|
+
if isinstance(core, bt.BooleanType):
|
|
77
|
+
return ParamKind.BOOLEAN
|
|
78
|
+
if isinstance(core, bt.DateTimeType):
|
|
79
|
+
return ParamKind.DATETIME
|
|
80
|
+
if isinstance(core, bt.SetType):
|
|
81
|
+
return ParamKind.SET
|
|
82
|
+
if isinstance(core, bt.ListType):
|
|
83
|
+
return ParamKind.LIST
|
|
84
|
+
if isinstance(core, bt.MapType):
|
|
85
|
+
return ParamKind.MAP
|
|
86
|
+
if isinstance(core, bt.BlobType):
|
|
87
|
+
return ParamKind.BLOB
|
|
88
|
+
if isinstance(core, bt.StructType):
|
|
89
|
+
return ParamKind.STRUCT
|
|
90
|
+
# DynamicStructType, OpaqueType, AnyType, etc.
|
|
91
|
+
return ParamKind.DYNAMIC
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def param_from_type(
|
|
95
|
+
name: str,
|
|
96
|
+
ftype: Any,
|
|
97
|
+
*,
|
|
98
|
+
path_vars: tuple[str, ...] = (),
|
|
99
|
+
query_vars: frozenset[str] = frozenset(),
|
|
100
|
+
body_param: str | None = None,
|
|
101
|
+
) -> Param:
|
|
102
|
+
"""Build a :class:`Param` from a vAPI field ``Type`` (recursively)."""
|
|
103
|
+
required, core = _unwrap(ftype)
|
|
104
|
+
kind = _kind_of(core)
|
|
105
|
+
p = Param(
|
|
106
|
+
name=name,
|
|
107
|
+
kind=kind,
|
|
108
|
+
required=required,
|
|
109
|
+
in_path=name in path_vars,
|
|
110
|
+
in_query=name in query_vars,
|
|
111
|
+
is_body=(name == body_param),
|
|
112
|
+
raw_type=core,
|
|
113
|
+
)
|
|
114
|
+
if kind is ParamKind.ENUM:
|
|
115
|
+
p.enum_values = _enum_values(core)
|
|
116
|
+
elif kind is ParamKind.ID:
|
|
117
|
+
p.resource_types = _first_resource_type(core)
|
|
118
|
+
elif kind in (ParamKind.LIST, ParamKind.SET):
|
|
119
|
+
p.element = param_from_type("", core.element_type)
|
|
120
|
+
elif kind is ParamKind.MAP:
|
|
121
|
+
p.key_kind = _kind_of(_unwrap(core.key_type)[1])
|
|
122
|
+
p.value_kind = _kind_of(_unwrap(core.value_type)[1])
|
|
123
|
+
elif kind is ParamKind.STRUCT:
|
|
124
|
+
p.struct_name = getattr(core, "name", None)
|
|
125
|
+
p.struct_class = getattr(core, "binding_class", None)
|
|
126
|
+
return p
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _first_resource_type(core: Any) -> str | None:
|
|
130
|
+
rt = getattr(core, "resource_types", None)
|
|
131
|
+
if isinstance(rt, str):
|
|
132
|
+
return rt
|
|
133
|
+
if isinstance(rt, (list, tuple)) and rt:
|
|
134
|
+
return str(rt[0])
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------- #
|
|
139
|
+
# Coercion: CLI value -> SDK value
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def coerce_scalar(kind: ParamKind, value: Any) -> Any:
|
|
144
|
+
"""Coerce a single scalar CLI value to the SDK-expected Python type."""
|
|
145
|
+
if value is None:
|
|
146
|
+
return None
|
|
147
|
+
if kind in (
|
|
148
|
+
ParamKind.STRING,
|
|
149
|
+
ParamKind.ID,
|
|
150
|
+
ParamKind.SECRET,
|
|
151
|
+
ParamKind.URI,
|
|
152
|
+
ParamKind.ENUM,
|
|
153
|
+
):
|
|
154
|
+
return str(value)
|
|
155
|
+
if kind is ParamKind.INTEGER:
|
|
156
|
+
return value if isinstance(value, int) else int(str(value))
|
|
157
|
+
if kind is ParamKind.DOUBLE:
|
|
158
|
+
return value if isinstance(value, float) else float(str(value))
|
|
159
|
+
if kind is ParamKind.BOOLEAN:
|
|
160
|
+
if isinstance(value, bool):
|
|
161
|
+
return value
|
|
162
|
+
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
163
|
+
if kind is ParamKind.DATETIME:
|
|
164
|
+
dt = value if isinstance(value, _dt.datetime) else _dt.datetime.fromisoformat(str(value))
|
|
165
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=_dt.UTC)
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def coerce_value(param: Param, value: Any) -> Any:
|
|
170
|
+
"""Coerce a CLI value into the SDK value for ``param`` (recursive)."""
|
|
171
|
+
if value is None:
|
|
172
|
+
return None
|
|
173
|
+
kind = param.kind
|
|
174
|
+
if kind in SCALAR_KINDS:
|
|
175
|
+
return coerce_scalar(kind, value)
|
|
176
|
+
if kind in (ParamKind.LIST, ParamKind.SET):
|
|
177
|
+
items = _as_sequence(value)
|
|
178
|
+
element = param.element
|
|
179
|
+
coerced = [coerce_value(element, v) if element else v for v in items]
|
|
180
|
+
return set(coerced) if kind is ParamKind.SET else coerced
|
|
181
|
+
if kind is ParamKind.MAP:
|
|
182
|
+
obj = _loads_object(param.name, value)
|
|
183
|
+
if not isinstance(obj, dict):
|
|
184
|
+
raise CoercionError(f"{param.name!r}: expected a JSON object")
|
|
185
|
+
return {
|
|
186
|
+
coerce_scalar(param.key_kind or ParamKind.STRING, k): coerce_scalar(
|
|
187
|
+
param.value_kind or ParamKind.STRING, v
|
|
188
|
+
)
|
|
189
|
+
for k, v in obj.items()
|
|
190
|
+
}
|
|
191
|
+
if kind is ParamKind.STRUCT:
|
|
192
|
+
obj = _loads_object(param.name, value)
|
|
193
|
+
if not isinstance(obj, dict):
|
|
194
|
+
raise CoercionError(f"{param.name!r}: expected a JSON object")
|
|
195
|
+
return coerce_struct(param, obj)
|
|
196
|
+
# DYNAMIC / BLOB: parse JSON if it looks like JSON, else pass through.
|
|
197
|
+
if isinstance(value, (dict, list)):
|
|
198
|
+
return value
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(str(value))
|
|
201
|
+
except (ValueError, TypeError):
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def coerce_struct(param: Param, obj: dict[str, Any]) -> Any:
|
|
206
|
+
"""Build the struct's ``binding_class`` from a JSON object, coercing fields."""
|
|
207
|
+
struct_type = param.raw_type
|
|
208
|
+
struct_class = param.struct_class
|
|
209
|
+
if struct_class is None or struct_type is None:
|
|
210
|
+
# No binding class resolved — hand the dict to the runtime as-is.
|
|
211
|
+
return obj
|
|
212
|
+
field_names = set(struct_type.get_field_names())
|
|
213
|
+
kwargs: dict[str, Any] = {}
|
|
214
|
+
for key, raw_value in obj.items():
|
|
215
|
+
if key not in field_names:
|
|
216
|
+
raise CoercionError(f"{param.name!r}: unknown field {key!r}")
|
|
217
|
+
field_param = param_from_type(key, struct_type.get_field(key))
|
|
218
|
+
kwargs[key] = coerce_value(field_param, raw_value)
|
|
219
|
+
return struct_class(**kwargs)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _as_sequence(value: Any) -> list[Any]:
|
|
223
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
224
|
+
return list(value)
|
|
225
|
+
if isinstance(value, str):
|
|
226
|
+
stripped = value.strip()
|
|
227
|
+
if stripped.startswith("["):
|
|
228
|
+
parsed = json.loads(stripped)
|
|
229
|
+
if isinstance(parsed, list):
|
|
230
|
+
return parsed
|
|
231
|
+
return [value]
|
|
232
|
+
return [value]
|
vsc/gen/preview.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Build a request *plan* for a write operation — the dry-run preview.
|
|
2
|
+
|
|
3
|
+
This is the heart of the v0.2 safety model: before any write is executed, the CLI
|
|
4
|
+
resolves exactly what would go on the wire (method, URL, query, body) and shows it.
|
|
5
|
+
The function is **pure** — it takes an :class:`Operation` and the (coerced) keyword
|
|
6
|
+
arguments and returns a JSON-able dict. It performs no network or connection work,
|
|
7
|
+
so the dry-run path can never touch the target.
|
|
8
|
+
|
|
9
|
+
Two body conventions are handled:
|
|
10
|
+
|
|
11
|
+
* NSX names a single ``request_body_parameter`` (``is_body``) — the body is that
|
|
12
|
+
parameter's value, unwrapped.
|
|
13
|
+
* vCenter leaves ``request_body_parameter`` unset and serializes every non-path,
|
|
14
|
+
non-query parameter into the request body — so the body is the collection of
|
|
15
|
+
those parameters.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from vsc.gen.model import Operation, Param
|
|
23
|
+
from vsc.output.render import jsonable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_url(op: Operation, kwargs: dict[str, Any]) -> str:
|
|
27
|
+
"""Substitute ``{templateVar}`` placeholders using the path-var map."""
|
|
28
|
+
url = op.url_template
|
|
29
|
+
for field, template_var in op.path_var_map.items():
|
|
30
|
+
if field in kwargs and kwargs[field] is not None:
|
|
31
|
+
url = url.replace(f"{{{template_var}}}", str(kwargs[field]))
|
|
32
|
+
return url
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _body(op: Operation, present: dict[str, Any]) -> Any:
|
|
36
|
+
"""Compute the request body from the present, coerced kwargs."""
|
|
37
|
+
body_params = [p for p in op.params if p.is_body]
|
|
38
|
+
if body_params:
|
|
39
|
+
# NSX: a single named body parameter; the body is its value directly.
|
|
40
|
+
name = body_params[0].name
|
|
41
|
+
return jsonable(present[name]) if name in present else None
|
|
42
|
+
# vCenter: every non-path, non-query parameter forms the body object.
|
|
43
|
+
by_name: dict[str, Param] = {p.name: p for p in op.params}
|
|
44
|
+
body = {
|
|
45
|
+
name: jsonable(value)
|
|
46
|
+
for name, value in present.items()
|
|
47
|
+
if (p := by_name.get(name)) is not None and not p.in_path and not p.in_query
|
|
48
|
+
}
|
|
49
|
+
return body or None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_request_plan(op: Operation, sdk_kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
"""Return a JSON-able plan describing the wire request ``op`` would make.
|
|
54
|
+
|
|
55
|
+
``sdk_kwargs`` are the coerced keyword arguments (path/query/body values) that
|
|
56
|
+
would be passed to the SDK method. ``None`` values are treated as absent.
|
|
57
|
+
|
|
58
|
+
``url`` is the resolved REST template and may include a literal query string the
|
|
59
|
+
SDK bakes into the template (e.g. ``?force=true`` on force variants); ``query``
|
|
60
|
+
holds the *structured* query parameters supplied as arguments. Both together
|
|
61
|
+
describe the wire request.
|
|
62
|
+
"""
|
|
63
|
+
present = {k: v for k, v in sdk_kwargs.items() if v is not None}
|
|
64
|
+
by_name = {p.name: p for p in op.params}
|
|
65
|
+
path_params = {
|
|
66
|
+
name: jsonable(value) for name, value in present.items() if name in op.path_var_map
|
|
67
|
+
}
|
|
68
|
+
query = {
|
|
69
|
+
name: jsonable(value)
|
|
70
|
+
for name, value in present.items()
|
|
71
|
+
if (p := by_name.get(name)) is not None and p.in_query
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
"method": op.http_method,
|
|
75
|
+
"url": _resolve_url(op, present),
|
|
76
|
+
"path_params": path_params,
|
|
77
|
+
"query": query,
|
|
78
|
+
"body": _body(op, present),
|
|
79
|
+
"backend": op.backend,
|
|
80
|
+
"service": op.service_short,
|
|
81
|
+
"operation": op.cli_verb,
|
|
82
|
+
}
|
vsc/logging_config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Logging configuration.
|
|
2
|
+
|
|
3
|
+
Logs go to **stderr** so stdout stays clean for machine-readable output, and the
|
|
4
|
+
default level is ``WARNING`` (override with ``VSC_LOG_LEVEL``). This keeps the
|
|
5
|
+
agent-friendly contract: stdout is data, stderr is diagnostics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
_DEFAULT_LEVEL = "WARNING"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _level() -> int:
|
|
20
|
+
name = os.environ.get("VSC_LOG_LEVEL", _DEFAULT_LEVEL).upper()
|
|
21
|
+
return logging.getLevelNamesMapping().get(name, logging.WARNING)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def configure_logging() -> None:
|
|
25
|
+
"""Send structlog output to stderr at the configured level (idempotent)."""
|
|
26
|
+
structlog.configure(
|
|
27
|
+
wrapper_class=structlog.make_filtering_bound_logger(_level()),
|
|
28
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
29
|
+
cache_logger_on_first_use=False,
|
|
30
|
+
)
|
vsc/output/__init__.py
ADDED
|
File without changes
|