hcs-cli 0.1.318__py3-none-any.whl → 0.1.319__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.
Files changed (103) hide show
  1. hcs_cli/__init__.py +1 -1
  2. hcs_cli/cmds/advisor/html_utils.py +30 -26
  3. hcs_cli/cmds/advisor/recommendation_engine.py +7 -10
  4. hcs_cli/cmds/daas/tenant/plan.py +1 -1
  5. hcs_cli/cmds/debug/start.py +0 -1
  6. hcs_cli/cmds/dev/fs/helper/credential_helper.py +2 -0
  7. hcs_cli/cmds/dev/fs/helper/k8s_util.py +0 -1
  8. hcs_cli/cmds/dev/fs/init.py +38 -5
  9. hcs_cli/cmds/dev/fs/profiler.py +0 -1
  10. hcs_cli/cmds/dev/fs/provided_files/akka.plan.yml +94 -250
  11. hcs_cli/cmds/dev/fs/provided_files/azsim.plan.yml +27 -34
  12. hcs_cli/cmds/dev/fs/provided_files/azure.plan.yml +294 -322
  13. hcs_cli/cmds/dev/fs/provided_files/mqtt-secret.yaml +188 -93
  14. hcs_cli/cmds/dev/fs/provided_files/mqtt-server-external.yaml +4 -5
  15. hcs_cli/cmds/dev/fs/provided_files/patch-mqtt-hostname.yml +3 -3
  16. hcs_cli/cmds/dev/fs/provided_files/patch-vernemq-ssl-depth.json +1 -1
  17. hcs_cli/cmds/dev/fs/tailor.py +7 -12
  18. hcs_cli/cmds/dev/mqtt.py +1 -2
  19. hcs_cli/cmds/dev/util/mqtt_helper.py +0 -1
  20. hcs_cli/cmds/hoc/search.py +39 -9
  21. hcs_cli/cmds/hst/clean.py +2 -1
  22. hcs_cli/cmds/inventory/assign.py +1 -3
  23. hcs_cli/cmds/inventory/deassign.py +1 -1
  24. hcs_cli/cmds/scm/plan.py +131 -3
  25. hcs_cli/cmds/task.py +2 -4
  26. hcs_cli/cmds/template/list_usage.py +2 -2
  27. hcs_cli/cmds/template/usage.py +20 -7
  28. hcs_cli/cmds/vm/list.py +0 -1
  29. hcs_cli/config/hcs-deployments.yaml +52 -52
  30. hcs_cli/main.py +0 -2
  31. hcs_cli/payload/akka.blueprint.yml +95 -243
  32. hcs_cli/payload/app/manual.json +19 -19
  33. hcs_cli/payload/edge/akka.json +6 -6
  34. hcs_cli/payload/edge/vsphere.json +6 -6
  35. hcs_cli/payload/hoc/lcm-capcalc.json.template +43 -0
  36. hcs_cli/payload/hoc/no-spare.json.template +1 -1
  37. hcs_cli/payload/inventory/assign.json +14 -16
  38. hcs_cli/payload/inventory/deassign.json +11 -11
  39. hcs_cli/payload/lcm/akka.json +31 -33
  40. hcs_cli/payload/lcm/azure-dummy-nt.json +64 -66
  41. hcs_cli/payload/lcm/azure-dummy.json +64 -66
  42. hcs_cli/payload/lcm/azure-real.json +13 -11
  43. hcs_cli/payload/lcm/edge-proxy.json +34 -36
  44. hcs_cli/payload/lcm/zero-dedicated.json +34 -36
  45. hcs_cli/payload/lcm/zero-delay-1m-per-vm.json +53 -69
  46. hcs_cli/payload/lcm/zero-fail-delete-template.json +43 -0
  47. hcs_cli/payload/lcm/zero-fail-destroy-onthread.json +38 -40
  48. hcs_cli/payload/lcm/zero-fail-destroy.json +38 -40
  49. hcs_cli/payload/lcm/zero-fail-prepare-onthread.json +38 -40
  50. hcs_cli/payload/lcm/zero-fail-prepare.json +38 -40
  51. hcs_cli/payload/lcm/zero-fail-vm-onthread.json +58 -74
  52. hcs_cli/payload/lcm/zero-fail-vm.json +58 -74
  53. hcs_cli/payload/lcm/zero-floating.json +34 -36
  54. hcs_cli/payload/lcm/zero-manual.json +33 -35
  55. hcs_cli/payload/lcm/zero-multisession.json +34 -36
  56. hcs_cli/payload/lcm/zero-nanw.json +31 -33
  57. hcs_cli/payload/lcm/zero-new-5k-delay.json +69 -78
  58. hcs_cli/payload/lcm/zero-new-5k.json +36 -38
  59. hcs_cli/payload/lcm/zero-new-snapshot.json +37 -39
  60. hcs_cli/payload/lcm/zero-new.json +37 -39
  61. hcs_cli/payload/lcm/zero-reuse-vm-id.json +33 -35
  62. hcs_cli/payload/lcm/zero-with-max-id-offset.json +32 -34
  63. hcs_cli/payload/lcm/zero.json +59 -73
  64. hcs_cli/payload/provider/ad-stes-vsphere.json +26 -26
  65. hcs_cli/payload/provider/akka.json +12 -12
  66. hcs_cli/payload/provider/azure.json +14 -14
  67. hcs_cli/payload/provider/edgeproxy.json +12 -12
  68. hcs_cli/payload/provider/vsphere.json +14 -14
  69. hcs_cli/payload/scm/starter.json +22 -23
  70. hcs_cli/payload/synt/core/p01-dummy-success.json +11 -15
  71. hcs_cli/payload/synt/core/p02-dummy-fail.json +12 -15
  72. hcs_cli/payload/synt/core/p03-dummy-exception.json +12 -15
  73. hcs_cli/payload/synt/core/p04-dummy-success-repeat.json +12 -15
  74. hcs_cli/payload/synt/core/p05-dummy-fail-repeat.json +13 -16
  75. hcs_cli/payload/synt/core/p06-dummy-exception-repeat.json +13 -16
  76. hcs_cli/payload/synt/core/p07-dummy-delay.json +12 -15
  77. hcs_cli/payload/synt/core/p08-dummy-property.json +12 -15
  78. hcs_cli/payload/synt/ext/p20-connect-success.json +12 -15
  79. hcs_cli/payload/synt/ext/p21-connect-fail.json +12 -15
  80. hcs_cli/payload/synt/ext/p30-ssl-success.json +12 -15
  81. hcs_cli/payload/synt/ext/p31-ssl-fail.json +13 -16
  82. hcs_cli/payload/synt/ext/p40-http-success.json +12 -15
  83. hcs_cli/payload/synt/ext/p41-http-fail.json +12 -15
  84. hcs_cli/payload/synt/ext/p42-http-status-code.json +14 -20
  85. hcs_cli/payload/synt/ext1/p10-ping-success.json +13 -16
  86. hcs_cli/payload/synt/ext1/p11-ping-fail.json +12 -15
  87. hcs_cli/payload/synt/ext1/p12-ping-success-repeat.json +14 -17
  88. hcs_cli/provider/hcs/cert.py +0 -1
  89. hcs_cli/provider/hcs/edge.py +1 -1
  90. hcs_cli/provider/hcs/uag.py +1 -1
  91. hcs_cli/service/hoc/diagnostic.py +0 -3
  92. hcs_cli/service/lcm/vm.py +0 -1
  93. hcs_cli/service/task.py +0 -1
  94. hcs_cli/support/debug_util.py +0 -1
  95. hcs_cli/support/plan_util.py +0 -1
  96. hcs_cli/support/predefined_payload.py +4 -1
  97. hcs_cli/support/template_util.py +0 -1
  98. hcs_cli/support/test_utils.py +2 -2
  99. hcs_cli/support/test_utils2.py +536 -0
  100. {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.319.dist-info}/METADATA +24 -17
  101. {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.319.dist-info}/RECORD +103 -100
  102. {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.319.dist-info}/WHEEL +0 -0
  103. {hcs_cli-0.1.318.dist-info → hcs_cli-0.1.319.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,536 @@
1
+ """
2
+ Copyright 2023-2023 VMware Inc.
3
+ SPDX-License-Identifier: Apache-2.0
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
14
+ """
15
+
16
+ import json
17
+ import re
18
+ import subprocess
19
+ from contextlib import contextmanager
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+ from unittest.mock import MagicMock, patch
22
+
23
+ import httpx
24
+
25
+
26
+ class HttpMockRequest:
27
+ """Represents an expected HTTP request with optional validation"""
28
+
29
+ def __init__(
30
+ self,
31
+ method: str,
32
+ url: str,
33
+ expected_request_body: Optional[Any] = None,
34
+ expected_request_headers: Optional[Dict[str, str]] = None,
35
+ ):
36
+ self.method = method
37
+ self.url_pattern = self._compile_pattern(url)
38
+ self.expected_request_body = expected_request_body
39
+ self.expected_request_headers = expected_request_headers
40
+
41
+ def _compile_pattern(self, url: str):
42
+ """Convert URL pattern to regex (supports {param} placeholders)"""
43
+ if "{" not in url:
44
+ return re.compile(f"^{re.escape(url)}$")
45
+
46
+ regex = url
47
+ for param in re.findall(r"\{(\w+)\}", url):
48
+ regex = regex.replace(f"{{{param}}}", f"(?P<{param}>[^/?]+)")
49
+ return re.compile(f"^{regex}$")
50
+
51
+ def matches(self, method: str, url: str) -> bool:
52
+ """Check if this request matches the given method and URL"""
53
+ return method == self.method and self.url_pattern.match(url) is not None
54
+
55
+ def extract_params(self, url: str) -> Dict[str, str]:
56
+ """Extract path parameters from URL"""
57
+ match = self.url_pattern.match(url)
58
+ if match:
59
+ return match.groupdict()
60
+ return {}
61
+
62
+
63
+ class HttpMockResponse:
64
+ """Represents a mocked HTTP response"""
65
+
66
+ def __init__(
67
+ self,
68
+ status_code: int = 200,
69
+ body: Optional[Any] = None,
70
+ headers: Optional[Dict[str, str]] = None,
71
+ ):
72
+ self.status_code = status_code
73
+ self.body = body if body is not None else {}
74
+ self.headers = headers or {"Content-Type": "application/json"}
75
+
76
+ def to_httpx_response(self, request_obj) -> httpx.Response:
77
+ """Convert to httpx.Response object"""
78
+ if isinstance(self.body, (dict, list)):
79
+ content = json.dumps(self.body).encode("utf-8")
80
+ elif isinstance(self.body, str):
81
+ content = self.body.encode("utf-8")
82
+ else:
83
+ content = str(self.body).encode("utf-8")
84
+
85
+ response = httpx.Response(
86
+ status_code=self.status_code,
87
+ content=content,
88
+ headers=self.headers,
89
+ request=request_obj,
90
+ )
91
+ return response
92
+
93
+
94
+ class HttpMockConfig:
95
+ """Configuration for a single HTTP mock mapping: request -> response"""
96
+
97
+ def __init__(
98
+ self,
99
+ request: HttpMockRequest,
100
+ response: HttpMockResponse,
101
+ ):
102
+ self.request = request
103
+ self.response = response
104
+
105
+
106
+ class HttpMockRegistry:
107
+ """Registry of configured HTTP mocks"""
108
+
109
+ def __init__(self):
110
+ self.configs: List[HttpMockConfig] = []
111
+ self.request_history: List[Tuple[str, str, Any]] = []
112
+
113
+ def register(
114
+ self,
115
+ method: str,
116
+ url: str,
117
+ response_status_code: int = 200,
118
+ response_body: Optional[Any] = None,
119
+ response_headers: Optional[Dict[str, str]] = None,
120
+ expected_request_body: Optional[Any] = None,
121
+ expected_request_headers: Optional[Dict[str, str]] = None,
122
+ ) -> "HttpMockRegistry":
123
+ """Register a mock HTTP request/response pair"""
124
+ request = HttpMockRequest(
125
+ method=method,
126
+ url=url,
127
+ expected_request_body=expected_request_body,
128
+ expected_request_headers=expected_request_headers,
129
+ )
130
+ response = HttpMockResponse(
131
+ status_code=response_status_code,
132
+ body=response_body,
133
+ headers=response_headers,
134
+ )
135
+ config = HttpMockConfig(request=request, response=response)
136
+ self.configs.append(config)
137
+ return self
138
+
139
+ def find_response(self, method: str, url: str) -> Optional[HttpMockResponse]:
140
+ """Find a registered response for the given request"""
141
+ for config in self.configs:
142
+ if config.request.matches(method, url):
143
+ self.request_history.append((method, url, None))
144
+ return config.response
145
+
146
+ # Request not found in registry
147
+ raise ValueError(
148
+ f"No mock registered for {method} {url}\n"
149
+ f"Registered: {[(c.request.method, c.request.url_pattern.pattern) for c in self.configs]}"
150
+ )
151
+
152
+ def reset(self):
153
+ """Clear all configs and history"""
154
+ self.configs.clear()
155
+ self.request_history.clear()
156
+
157
+ def get_request_history(self) -> List[Tuple[str, str]]:
158
+ """Get list of all requests made during mock session"""
159
+ return [(method, url) for method, url, _ in self.request_history]
160
+
161
+
162
+ class HttpMockContextManager:
163
+ """Context manager for mock HTTP testing with auto-cleanup"""
164
+
165
+ def __init__(self):
166
+ self.registry = HttpMockRegistry()
167
+ self._patchers = []
168
+ # Capture the real httpx.Client class before any patching
169
+ self._real_httpx_client = httpx.Client
170
+
171
+ def register(
172
+ self,
173
+ method: str,
174
+ url: str,
175
+ response_status_code: int = 200,
176
+ response_body: Optional[Any] = None,
177
+ response_headers: Optional[Dict[str, str]] = None,
178
+ expected_request_body: Optional[Any] = None,
179
+ expected_request_headers: Optional[Dict[str, str]] = None,
180
+ ) -> "HttpMockContextManager":
181
+ """Register a mock response"""
182
+ self.registry.register(
183
+ method=method,
184
+ url=url,
185
+ response_status_code=response_status_code,
186
+ response_body=response_body,
187
+ response_headers=response_headers,
188
+ expected_request_body=expected_request_body,
189
+ expected_request_headers=expected_request_headers,
190
+ )
191
+ return self
192
+
193
+ def _create_mock_client(self):
194
+ """Create a mock httpx.Client that uses the registry"""
195
+ mock_client = MagicMock(spec=self._real_httpx_client)
196
+
197
+ def mock_get(url, **kwargs):
198
+ # Handle base_url + relative URL
199
+ full_url = str(url)
200
+ if not full_url.startswith("http"):
201
+ full_url = "https://api.example.com" + full_url
202
+
203
+ response = self.registry.find_response("GET", full_url)
204
+ request_obj = httpx.Request("GET", full_url)
205
+ httpx_response = response.to_httpx_response(request_obj)
206
+
207
+ # Ensure response has read method for compatibility
208
+ httpx_response.read = lambda: None
209
+ return httpx_response
210
+
211
+ def mock_post(url, **kwargs):
212
+ full_url = str(url)
213
+ if not full_url.startswith("http"):
214
+ full_url = "https://api.example.com" + full_url
215
+
216
+ response = self.registry.find_response("POST", full_url)
217
+ request_obj = httpx.Request("POST", full_url, json=kwargs.get("json"))
218
+ httpx_response = response.to_httpx_response(request_obj)
219
+ httpx_response.read = lambda: None
220
+ return httpx_response
221
+
222
+ def mock_put(url, **kwargs):
223
+ full_url = str(url)
224
+ if not full_url.startswith("http"):
225
+ full_url = "https://api.example.com" + full_url
226
+
227
+ response = self.registry.find_response("PUT", full_url)
228
+ request_obj = httpx.Request("PUT", full_url, json=kwargs.get("json"))
229
+ httpx_response = response.to_httpx_response(request_obj)
230
+ httpx_response.read = lambda: None
231
+ return httpx_response
232
+
233
+ def mock_patch(url, **kwargs):
234
+ full_url = str(url)
235
+ if not full_url.startswith("http"):
236
+ full_url = "https://api.example.com" + full_url
237
+
238
+ response = self.registry.find_response("PATCH", full_url)
239
+ request_obj = httpx.Request("PATCH", full_url, json=kwargs.get("json"))
240
+ httpx_response = response.to_httpx_response(request_obj)
241
+ httpx_response.read = lambda: None
242
+ return httpx_response
243
+
244
+ def mock_delete(url, **kwargs):
245
+ full_url = str(url)
246
+ if not full_url.startswith("http"):
247
+ full_url = "https://api.example.com" + full_url
248
+
249
+ response = self.registry.find_response("DELETE", full_url)
250
+ request_obj = httpx.Request("DELETE", full_url)
251
+ httpx_response = response.to_httpx_response(request_obj)
252
+ httpx_response.read = lambda: None
253
+ return httpx_response
254
+
255
+ # Setup mock client attributes and methods
256
+ mock_client.get.side_effect = mock_get
257
+ mock_client.post.side_effect = mock_post
258
+ mock_client.put.side_effect = mock_put
259
+ mock_client.patch.side_effect = mock_patch
260
+ mock_client.delete.side_effect = mock_delete
261
+ mock_client.base_url = "https://api.example.com"
262
+ mock_client.timeout = 30
263
+ mock_client.event_hooks = {"request": [], "response": []}
264
+ mock_client.ensure_token = MagicMock()
265
+ mock_client.follow_redirects = True
266
+ mock_client.close = MagicMock()
267
+
268
+ return mock_client
269
+
270
+ def __enter__(self):
271
+ """Enter context manager"""
272
+ # Patch httpx.Client
273
+ patcher = patch("httpx.Client")
274
+ mock_client_class = patcher.start()
275
+ mock_client_class.return_value = self._create_mock_client()
276
+ self._patchers.append(patcher)
277
+
278
+ # Patch OAuth2Client which is used by EzClient
279
+ patcher = patch("authlib.integrations.httpx_client.OAuth2Client")
280
+ mock_oauth2_class = patcher.start()
281
+ mock_oauth2_class.return_value = self._create_mock_client()
282
+ self._patchers.append(patcher)
283
+
284
+ # Also patch at module level in case they're imported differently
285
+ patcher = patch("hcs_core.sglib.ez_client.OAuth2Client")
286
+ mock_oauth2_class = patcher.start()
287
+ mock_oauth2_class.return_value = self._create_mock_client()
288
+ self._patchers.append(patcher)
289
+
290
+ return self
291
+
292
+ def __exit__(self, exc_type, exc_val, exc_tb):
293
+ """Exit context manager and cleanup"""
294
+ for patcher in self._patchers:
295
+ patcher.stop()
296
+ self._patchers.clear()
297
+ self.registry.reset()
298
+
299
+
300
+ @contextmanager
301
+ def mock_http(**configs: Dict[str, Any]):
302
+ """
303
+ Context manager for setting up HTTP mocks.
304
+
305
+ Usage:
306
+ with mock_http_registry() as registry:
307
+ registry.register(
308
+ method='GET',
309
+ url='/v2/templates',
310
+ response_body=[...],
311
+ response_status_code=200,
312
+ )
313
+ # Run tests here
314
+
315
+ Example:
316
+ with mock_http_registry() as registry:
317
+ registry.register(
318
+ 'GET', '/api/templates',
319
+ response_body=[{'id': '1', 'name': 'template1'}]
320
+ )
321
+ result = runner.invoke(hcs_template_list)
322
+ assert result.exit_code == 0
323
+ """
324
+ ctx_manager = HttpMockContextManager()
325
+ with ctx_manager:
326
+ yield ctx_manager
327
+
328
+
329
+ def create_mock_registry() -> HttpMockRegistry:
330
+ """Create and return a new mock registry"""
331
+ return HttpMockRegistry()
332
+
333
+
334
+ class CliTestUtil:
335
+ """
336
+ Utility for testing CLI commands with mocked HTTP responses.
337
+
338
+ Uses a static configuration format:
339
+ {
340
+ "GET /v2/templates?org_id=abc": {
341
+ "request": {
342
+ "body": {...} # optional
343
+ },
344
+ "response": {
345
+ "status_code": 200,
346
+ "body": {...},
347
+ "headers": {"Content-Type": "application/json"}
348
+ }
349
+ }
350
+ }
351
+
352
+ Usage:
353
+ mock_config = {
354
+ "GET /v2/templates": {
355
+ "response": {
356
+ "status_code": 200,
357
+ "body": [{"id": "template-1", "name": "test"}]
358
+ }
359
+ }
360
+ }
361
+
362
+ with CliTestUtil(mock_config) as test:
363
+ test.run("hcs template list", expected_json=[{"id": "template-1"}])
364
+ test.run("hcs template list -o json", expected_json=[...])
365
+ """
366
+
367
+ def __init__(self, mock_config: Dict[str, Any], env: Optional[Dict[str, str]] = None):
368
+ """
369
+ Initialize the test utility.
370
+
371
+ Args:
372
+ mock_config: Dictionary mapping "METHOD /path" to request/response config
373
+ env: Optional environment variables to pass to CLI commands
374
+ """
375
+ self.mock_config = mock_config
376
+ self.env = env or {}
377
+ self._http_mock_context = None
378
+ self._registry = None
379
+ self._parse_and_register_mocks()
380
+
381
+ def _parse_and_register_mocks(self):
382
+ """Parse mock config and prepare registration"""
383
+ self._http_mock_context = HttpMockContextManager()
384
+
385
+ for endpoint_key, endpoint_config in self.mock_config.items():
386
+ # Parse "METHOD /path?params"
387
+ parts = endpoint_key.split(" ", 1)
388
+ if len(parts) != 2:
389
+ raise ValueError(f"Invalid endpoint key format: {endpoint_key}. Expected 'METHOD /path'")
390
+
391
+ method, url = parts
392
+ method = method.strip()
393
+ url = url.strip()
394
+
395
+ response_config = endpoint_config.get("response", {})
396
+ status_code = response_config.get("status_code", 200)
397
+ body = response_config.get("body", {})
398
+ headers = response_config.get("headers", {"Content-Type": "application/json"})
399
+
400
+ # Register with the HTTP mock context
401
+ # Convert exact URL to regex pattern that matches query params
402
+ url_pattern = self._url_to_pattern(url)
403
+ self._http_mock_context.register(
404
+ method=method,
405
+ url=url_pattern,
406
+ response_status_code=status_code,
407
+ response_body=body,
408
+ response_headers=headers,
409
+ )
410
+
411
+ def _url_to_pattern(self, url: str) -> str:
412
+ """
413
+ Convert exact URL to regex pattern.
414
+
415
+ /v2/templates -> .*v2/templates.*
416
+ /v2/templates/123 -> .*v2/templates/123.*
417
+ /v2/templates?org_id=abc -> .*v2/templates.*org_id=abc.*
418
+ """
419
+ # Escape special regex characters but keep ? for pattern matching
420
+ escaped = re.escape(url)
421
+ # Remove extra escapes for ? and & to allow matching
422
+ escaped = escaped.replace(r"\?", ".*").replace(r"\&", ".*")
423
+ return f".*{escaped}.*"
424
+
425
+ def __enter__(self):
426
+ """Enter the context manager"""
427
+ self._http_mock_context.__enter__()
428
+ self._registry = self._http_mock_context.registry
429
+ return self
430
+
431
+ def __exit__(self, exc_type, exc_val, exc_tb):
432
+ """Exit the context manager and cleanup"""
433
+ return self._http_mock_context.__exit__(exc_type, exc_val, exc_tb)
434
+
435
+ def run(
436
+ self,
437
+ cmd: str,
438
+ expected_stdout: Any = None,
439
+ expected_return_code: int = 0,
440
+ expect_stderr_empty: bool = True,
441
+ stdin_payload: str = None,
442
+ ):
443
+ """
444
+ Run a CLI command and verify the output.
445
+
446
+ Args:
447
+ cmd: The CLI command to run
448
+ expected_stdout: Expected output (dict/list parsed as JSON, str for exact match, callable for custom)
449
+ expected_return_code: Expected exit code
450
+ expect_stderr_empty: Whether stderr should be empty
451
+ stdin_payload: Optional stdin input
452
+
453
+ Returns:
454
+ Tuple of (stdout, stderr, returncode)
455
+ """
456
+
457
+ # TODO
458
+ if "WIP":
459
+ return None, None, 0
460
+
461
+ stdout, stderr, returncode = self._run_command(cmd, stdin_payload)
462
+
463
+ # Verify return code
464
+ if returncode != expected_return_code:
465
+ raise AssertionError(
466
+ f"Return code mismatch.\nExpected: {expected_return_code}\nGot: {returncode}\nStdout: {stdout}\nStderr: {stderr}"
467
+ )
468
+
469
+ # Verify stderr
470
+ actual_stderr_empty = stderr is None or len(stderr) == 0
471
+ if expect_stderr_empty and not actual_stderr_empty:
472
+ raise AssertionError(f"Expected empty stderr, but got:\n{stderr}")
473
+
474
+ # Verify stdout
475
+ if expected_stdout is not None:
476
+ self._verify_stdout(stdout, expected_stdout)
477
+
478
+ return stdout, stderr, returncode
479
+
480
+ def _verify_stdout(self, stdout: str, expected_stdout: Any):
481
+ """Verify stdout matches expected value"""
482
+ t = type(expected_stdout)
483
+
484
+ if t is str:
485
+ if stdout != expected_stdout:
486
+ raise AssertionError(f"Stdout mismatch.\nExpected: {expected_stdout}\nGot: {stdout}")
487
+ elif t is dict or t is list:
488
+ if stdout is None or len(stdout) == 0:
489
+ raise AssertionError("Expected stdout but got empty")
490
+ try:
491
+ data = json.loads(stdout)
492
+ if data != expected_stdout:
493
+ raise AssertionError(
494
+ f"Stdout JSON mismatch.\nExpected: {json.dumps(expected_stdout, indent=2)}\nGot: {json.dumps(data, indent=2)}"
495
+ )
496
+ except json.JSONDecodeError as e:
497
+ raise AssertionError(f"Failed to parse stdout as JSON: {e}\nStdout: {stdout}")
498
+ elif callable(expected_stdout):
499
+ expected_stdout(stdout)
500
+ else:
501
+ raise ValueError(f"Unsupported expected_stdout type: {t}")
502
+
503
+ def _run_command(self, cmd: str, stdin_payload: Optional[str] = None) -> Tuple[str, str, int]:
504
+ """
505
+ Run a command with mocked environment.
506
+
507
+ Returns:
508
+ Tuple of (stdout, stderr, returncode)
509
+ """
510
+ env = self._build_env()
511
+ p = subprocess.run(
512
+ cmd,
513
+ input=stdin_payload,
514
+ shell=True,
515
+ text=True,
516
+ check=False,
517
+ capture_output=True,
518
+ env=env,
519
+ )
520
+ return p.stdout, p.stderr, p.returncode
521
+
522
+ def _build_env(self) -> Dict[str, str]:
523
+ """Build environment variables for command execution"""
524
+ import os
525
+
526
+ env = os.environ.copy()
527
+ env["HCS_CLI_TELEMETRY"] = "false"
528
+ env["HCS_CLI_CHECK_UPGRADE"] = "false"
529
+ env.update(self.env)
530
+ return env
531
+
532
+ def get_request_history(self) -> List[Tuple[str, str]]:
533
+ """Get list of all HTTP requests made during test"""
534
+ if self._registry:
535
+ return self._registry.get_request_history()
536
+ return []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hcs-cli
3
- Version: 0.1.318
3
+ Version: 0.1.319
4
4
  Summary: Horizon Cloud Service CLI.
5
5
  Project-URL: Homepage, https://github.com/euc-eng/hcs-cli
6
6
  Project-URL: Bug Tracker, https://github.com/euc-eng/hcs-cli/issues
@@ -14,14 +14,13 @@ Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Requires-Python: >=3.9
17
- Requires-Dist: hcs-core>=0.1.318
17
+ Requires-Dist: hcs-core>=0.1.319
18
18
  Requires-Dist: inquirerpy>=0.3.4
19
19
  Requires-Dist: matplotlib>=3.8.0
20
20
  Requires-Dist: paho-mqtt>=2.1.0
21
21
  Requires-Dist: reportlab>=4.0.0
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: bandit; extra == 'dev'
24
- Requires-Dist: black; extra == 'dev'
25
24
  Requires-Dist: build; extra == 'dev'
26
25
  Requires-Dist: flake8; extra == 'dev'
27
26
  Requires-Dist: hatch; extra == 'dev'
@@ -30,7 +29,9 @@ Requires-Dist: pylint; extra == 'dev'
30
29
  Requires-Dist: pytest; extra == 'dev'
31
30
  Requires-Dist: ruff; extra == 'dev'
32
31
  Requires-Dist: twine; extra == 'dev'
32
+ Requires-Dist: vulture; extra == 'dev'
33
33
  Requires-Dist: wheel; extra == 'dev'
34
+ Requires-Dist: yamlfix; extra == 'dev'
34
35
  Description-Content-Type: text/markdown
35
36
 
36
37
  # horizon-cloud-service-cli
@@ -49,16 +50,16 @@ Description-Content-Type: text/markdown
49
50
  - [Contributing](#contributing)
50
51
  - [License](#license)
51
52
 
52
-
53
53
  ## Overview
54
+
54
55
  hcs-cli is a human-friendly command line toolbox for [Omnissa Horizon Cloud Service (HCS)](https://www.omnissa.com/products/horizon-cloud/), based on public REST APIs.
55
56
 
56
57
  ## Try it out
57
58
 
58
-
59
59
  ### Prerequisites
60
- * Python 3.9+
61
- * Pip
60
+
61
+ - Python 3.9+
62
+ - Pip
62
63
 
63
64
  Refer to [Setup Prerequisites](doc/dev-setup.md#setup-prerequisites) for more details.
64
65
 
@@ -67,42 +68,48 @@ Refer to [Setup Prerequisites](doc/dev-setup.md#setup-prerequisites) for more de
67
68
  #### Mac & Linux
68
69
 
69
70
  Install the tool
71
+
70
72
  ```
71
73
  brew install python
72
74
  pip install hcs-cli
73
75
  ```
74
76
 
75
77
  #### Windows
78
+
76
79
  Install the tool.
80
+
77
81
  ```
78
82
  pip install hcs-cli
79
83
  ```
84
+
80
85
  If you have python installed with option "Add python to path", it should be fine. Otherwise, you need to add python and it's Script directory to path.
81
86
 
82
87
  #### Use the CLI
83
- Use with default public HCS service.
88
+
89
+ Use with default public HCS service.
90
+
84
91
  ```
85
92
  hcs login
86
93
  ```
94
+
87
95
  Run a command, for example, list templates:
96
+
88
97
  ```
89
98
  hcs admin template list
90
99
  ```
91
100
 
92
101
  ## Documentation
93
- * [HCS CLI - User Guide](../doc/hcs-cli-user-guide.md)
94
- * [HCS CLI - Cheatsheet](../doc/hcs-cli-cheatsheet.md)
95
- * [HCS CLI - Dev Guide](../doc/hcs-cli-dev-guide.md)
96
- * [HCS Plan - template engine for HCS](../doc/hcs-plan.md)
97
- * [Context Programming](https://github.com/nanw1103/context-programming)
98
102
 
99
-
103
+ - [HCS CLI - User Guide](../doc/hcs-cli-user-guide.md)
104
+ - [HCS CLI - Cheatsheet](../doc/hcs-cli-cheatsheet.md)
105
+ - [HCS CLI - Dev Guide](../doc/hcs-cli-dev-guide.md)
106
+ - [HCS Plan - template engine for HCS](../doc/hcs-plan.md)
107
+ - [Context Programming](https://github.com/nanw1103/context-programming)
108
+
100
109
  ## Contributing
101
110
 
102
- The horizon-cloud-service-cli project team welcomes contributions from the community. Before you start working with horizon-cloud-service-cli, please read and sign our Contributor License Agreement [CLA](https://cla.vmware.com/cla/1/preview). If you wish to contribute code and you have not signed our CLA, our bot will prompt you to do so when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ]([https://cla.vmware.com/faq](https://cla.vmware.com/faq)).
111
+ The horizon-cloud-service-cli project team welcomes contributions from the community. Before you start working with horizon-cloud-service-cli, please read and sign our Contributor License Agreement [CLA](https://cla.vmware.com/cla/1/preview). If you wish to contribute code and you have not signed our CLA, our bot will prompt you to do so when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ](<[https://cla.vmware.com/faq](https://cla.vmware.com/faq)>).
103
112
 
104
113
  ## License
105
114
 
106
115
  Apache 2.0
107
-
108
-