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/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