systemlink-cli 1.3.1__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 (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1400 @@
1
+ """CLI commands for managing SystemLink WebAssembly function definitions and executions."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ import click
10
+ import questionary
11
+ import requests
12
+
13
+ from .cli_utils import validate_output_format
14
+ from .function_templates import (
15
+ download_and_extract_template,
16
+ TEMPLATE_REPO,
17
+ TEMPLATE_BRANCH,
18
+ TEMPLATE_SUBFOLDERS,
19
+ )
20
+ from .platform import require_feature
21
+ from .universal_handlers import UniversalResponseHandler, FilteredResponse
22
+ from .utils import (
23
+ display_api_errors,
24
+ ExitCodes,
25
+ get_base_url,
26
+ get_headers,
27
+ get_ssl_verify,
28
+ get_workspace_id_with_fallback,
29
+ get_workspace_map,
30
+ handle_api_error,
31
+ load_json_file,
32
+ make_api_request,
33
+ )
34
+ from .workspace_utils import (
35
+ get_effective_workspace,
36
+ get_workspace_display_name,
37
+ resolve_workspace_filter,
38
+ )
39
+
40
+
41
+ def load_env_file() -> Dict[str, str]:
42
+ """Load environment variables from a .env file in the current directory.
43
+
44
+ Returns:
45
+ Dictionary of environment variables from .env file
46
+ """
47
+ env_vars = {}
48
+ env_file = Path.cwd() / ".env"
49
+
50
+ if env_file.exists():
51
+ try:
52
+ with open(env_file, "r", encoding="utf-8") as f:
53
+ for line in f:
54
+ line = line.strip()
55
+ if line and not line.startswith("#") and "=" in line:
56
+ key, value = line.split("=", 1)
57
+ env_vars[key.strip()] = value.strip().strip('"').strip("'")
58
+ except Exception:
59
+ # Silently ignore .env file parsing errors
60
+ pass
61
+
62
+ return env_vars
63
+
64
+
65
+ def get_function_service_base_url() -> str:
66
+ """Get the unified base URL for Function Management Service (v2).
67
+
68
+ The unified service consolidates function definition and execution.
69
+
70
+ Returns:
71
+ Base URL (prefix) for the unified Function Management Service (without version suffix)
72
+ """
73
+ env_vars = load_env_file()
74
+
75
+ # Prefer explicit FUNCTION_SERVICE_URL
76
+ function_url = env_vars.get("FUNCTION_SERVICE_URL") or os.environ.get("FUNCTION_SERVICE_URL")
77
+ if function_url:
78
+ # Normalize to include /nifunction
79
+ return (
80
+ function_url if function_url.endswith("/nifunction") else f"{function_url}/nifunction"
81
+ )
82
+
83
+ # Fallback to global SYSTEMLINK_API_URL (handled by get_base_url)
84
+ base_url = get_base_url()
85
+ return f"{base_url}/nifunction"
86
+
87
+
88
+ def get_unified_v2_base() -> str:
89
+ """Get the versioned root for unified Function Management Service (v2)."""
90
+ return f"{get_function_service_base_url()}/v2"
91
+
92
+
93
+ def _query_all_functions(
94
+ workspace_filter: Optional[str] = None,
95
+ name_filter: Optional[str] = None,
96
+ interface_filter: Optional[str] = None,
97
+ custom_filter: Optional[str] = None,
98
+ workspace_map: Optional[Dict[str, Any]] = None,
99
+ ) -> List[Dict[str, Any]]:
100
+ """Query all function definitions using continuation token pagination.
101
+
102
+ Args:
103
+ workspace_filter: Optional workspace ID to filter by
104
+ name_filter: Optional name pattern to filter by
105
+ interface_filter: Optional text to search for in the interface property
106
+ custom_filter: Optional custom Dynamic LINQ filter expression
107
+ workspace_map: Optional workspace mapping to avoid repeated lookups
108
+
109
+ Returns:
110
+ List of all function definitions matching the filters
111
+ """
112
+ url = f"{get_unified_v2_base()}/query-functions"
113
+ all_functions = []
114
+ continuation_token = None
115
+
116
+ while True:
117
+ # Build payload for the request
118
+ payload: Dict[str, Union[int, str, List[str]]] = {
119
+ "take": 100, # Use smaller page size for efficient pagination
120
+ }
121
+
122
+ # Build filter expression
123
+ filter_parts = []
124
+
125
+ if workspace_filter:
126
+ filter_parts.append(f'workspaceId == "{workspace_filter}"')
127
+
128
+ if name_filter:
129
+ filter_parts.append(f'name.StartsWith("{name_filter}")')
130
+
131
+ if interface_filter:
132
+ filter_parts.append(f'interface.Contains("{interface_filter}")')
133
+
134
+ # Always filter for WASM runtime by checking for interface.entrypoint since CLI is WASM-only
135
+ # Functions with interface.entrypoint are WASM functions
136
+ filter_parts.append('interface.entrypoint != null && interface.entrypoint != ""')
137
+
138
+ # Add custom filter if provided (this will override automatic filters if both are used)
139
+ if custom_filter:
140
+ if filter_parts:
141
+ # Combine automatic filters with custom filter using AND
142
+ combined_filter = f'({" && ".join(filter_parts)}) && ({custom_filter})'
143
+ payload["filter"] = combined_filter
144
+ else:
145
+ payload["filter"] = custom_filter
146
+ elif filter_parts:
147
+ payload["filter"] = " && ".join(filter_parts)
148
+
149
+ # Add continuation token if we have one
150
+ if continuation_token:
151
+ payload["continuationToken"] = continuation_token
152
+
153
+ resp = make_api_request("POST", url, payload)
154
+ data = resp.json()
155
+
156
+ # Extract functions from this page
157
+ functions = data.get("functions", [])
158
+ all_functions.extend(functions)
159
+
160
+ # Check if there are more pages
161
+ continuation_token = data.get("continuationToken")
162
+ if not continuation_token:
163
+ break
164
+
165
+ return all_functions
166
+
167
+
168
+ def _query_all_executions(
169
+ workspace_filter: Optional[str] = None,
170
+ status_filter: Optional[str] = None,
171
+ function_id_filter: Optional[str] = None,
172
+ workspace_map: Optional[Dict[str, Any]] = None,
173
+ ) -> List[Dict[str, Any]]:
174
+ """Query all function executions using continuation token pagination.
175
+
176
+ Args:
177
+ workspace_filter: Optional workspace ID to filter by
178
+ status_filter: Optional execution status to filter by
179
+ function_id_filter: Optional function ID to filter by
180
+ workspace_map: Optional workspace mapping to avoid repeated lookups
181
+
182
+ Returns:
183
+ List of all function executions matching the filters
184
+ """
185
+ url = f"{get_unified_v2_base()}/query-executions"
186
+ all_executions = []
187
+ continuation_token = None
188
+
189
+ while True:
190
+ # Build payload for the request
191
+ payload: Dict[str, Union[int, str, List[str]]] = {
192
+ "take": 100, # Use smaller page size for efficient pagination
193
+ }
194
+
195
+ # Build filter expression
196
+ filter_parts = []
197
+
198
+ if workspace_filter:
199
+ filter_parts.append(f'workspaceId == "{workspace_filter}"')
200
+
201
+ if status_filter:
202
+ filter_parts.append(f'status == "{status_filter}"')
203
+
204
+ if function_id_filter:
205
+ filter_parts.append(f'functionId == "{function_id_filter}"')
206
+
207
+ if filter_parts:
208
+ payload["filter"] = " && ".join(filter_parts)
209
+
210
+ # Add continuation token if we have one
211
+ if continuation_token:
212
+ payload["continuationToken"] = continuation_token
213
+
214
+ resp = make_api_request("POST", url, payload)
215
+ data = resp.json()
216
+
217
+ # Extract executions from this page
218
+ executions = data.get("executions", [])
219
+ all_executions.extend(executions)
220
+
221
+ # Check if there are more pages
222
+ continuation_token = data.get("continuationToken")
223
+ if not continuation_token:
224
+ break
225
+
226
+ return all_executions
227
+
228
+
229
+ def register_function_commands(cli: Any) -> None:
230
+ """Register the 'function' command group and its subcommands."""
231
+
232
+ @cli.group(hidden=True)
233
+ @click.pass_context
234
+ def function(ctx: click.Context) -> None:
235
+ """Manage function definitions and executions."""
236
+ # Check for platform feature availability
237
+ # Only check if a subcommand is being invoked (not just --help)
238
+ if ctx.invoked_subcommand is not None:
239
+ require_feature("function_execution")
240
+
241
+ # ------------------------------------------------------------------
242
+ # Initialization (template bootstrap) command
243
+ # ------------------------------------------------------------------
244
+
245
+ @function.command(name="init")
246
+ @click.option(
247
+ "--language",
248
+ "-l",
249
+ type=click.Choice(["typescript", "python", "ts", "py"], case_sensitive=False),
250
+ help="Template language (typescript|python). Will prompt if omitted.",
251
+ )
252
+ @click.option(
253
+ "--directory",
254
+ "-d",
255
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
256
+ help="Target directory to create or populate (defaults to current working directory)",
257
+ )
258
+ @click.option(
259
+ "--force",
260
+ is_flag=True,
261
+ help="Overwrite existing non-empty directory contents.",
262
+ )
263
+ def init_function_template(
264
+ language: Optional[str], directory: Optional[Path], force: bool
265
+ ) -> None:
266
+ """Initialize a local function template (TypeScript Hono or Python HTTP)."""
267
+ try:
268
+ # Prompt for language if not supplied
269
+ if not language:
270
+ language = questionary.select(
271
+ "Select language?",
272
+ choices=["typescript", "python"],
273
+ ).ask()
274
+ if language is None:
275
+ raise click.Abort()
276
+ if not language:
277
+ click.echo("✗ Language not specified.", err=True)
278
+ sys.exit(ExitCodes.INVALID_INPUT)
279
+ language_norm = language.lower()
280
+ if language_norm in {"ts"}:
281
+ language_norm = "typescript"
282
+ if language_norm in {"py"}:
283
+ language_norm = "python"
284
+ if language_norm not in {"typescript", "python"}:
285
+ click.echo("✗ Unsupported language.", err=True)
286
+ sys.exit(ExitCodes.INVALID_INPUT)
287
+
288
+ # Prompt for directory if not supplied
289
+ if directory is None:
290
+ dir_input = click.prompt(
291
+ "Target directory (leave blank for current directory)",
292
+ default="",
293
+ show_default=False,
294
+ )
295
+ if dir_input.strip():
296
+ directory = Path(dir_input.strip())
297
+
298
+ target_dir = directory or Path.cwd()
299
+ if not target_dir.exists():
300
+ target_dir.mkdir(parents=True, exist_ok=True)
301
+ else:
302
+ # If directory is not empty and no force, abort
303
+ if any(target_dir.iterdir()) and not force:
304
+ click.echo(
305
+ "✗ Target directory is not empty. Use --force to initialize anyway.",
306
+ err=True,
307
+ )
308
+ sys.exit(ExitCodes.INVALID_INPUT)
309
+
310
+ repo = TEMPLATE_REPO
311
+ branch = TEMPLATE_BRANCH
312
+ subfolder = TEMPLATE_SUBFOLDERS[language_norm]
313
+ click.echo(f"Downloading {language_norm} template from {repo}@{branch}:{subfolder} ...")
314
+ download_and_extract_template(language_norm, target_dir)
315
+ click.echo("✓ Template files created.")
316
+
317
+ # Print next steps (no automatic install/build)
318
+ click.echo("\nNext steps:")
319
+ rel = target_dir.resolve()
320
+ if language_norm == "typescript":
321
+ click.echo(f" 1. cd {rel}")
322
+ click.echo(" 2. npm install")
323
+ click.echo(" 3. npm run build")
324
+ click.echo(
325
+ " 4. Use 'slcli function manage create' to register your compiled dist/main.wasm"
326
+ )
327
+ else:
328
+ click.echo(f" 1. cd {rel}")
329
+ click.echo(" 2. (Optional) python -m venv .venv && source .venv/bin/activate")
330
+ click.echo(" 3. pip install -r requirements.txt (if provided)")
331
+ click.echo(
332
+ " 4. Use 'slcli function manage create' to register your function per README"
333
+ )
334
+ sys.exit(ExitCodes.SUCCESS)
335
+ except SystemExit: # re-raise explicit exits
336
+ raise
337
+ except Exception as exc: # noqa: BLE001
338
+ handle_api_error(exc)
339
+
340
+ # Function Execution Commands Group
341
+ @function.group(name="execute")
342
+ def execute_group() -> None:
343
+ """Execute and manage function executions."""
344
+ pass
345
+
346
+ # Function Management Commands Group
347
+ @function.group(name="manage")
348
+ def manage_group() -> None:
349
+ """Manage function definitions."""
350
+ pass
351
+
352
+ @manage_group.command(name="list")
353
+ @click.option(
354
+ "--workspace",
355
+ "-w",
356
+ help="Filter by workspace name or ID",
357
+ )
358
+ @click.option(
359
+ "--name",
360
+ "-n",
361
+ help="Filter by function name (starts with pattern)",
362
+ )
363
+ @click.option(
364
+ "--interface-contains",
365
+ help="Filter by interface content (searches interface property for text)",
366
+ )
367
+ @click.option(
368
+ "--filter",
369
+ help='Custom Dynamic LINQ filter expression for advanced filtering. Examples: name.StartsWith("data") && interface.Contains("entrypoint")',
370
+ )
371
+ @click.option(
372
+ "--take",
373
+ "-t",
374
+ type=int,
375
+ default=25,
376
+ show_default=True,
377
+ help="Maximum number of functions to return",
378
+ )
379
+ @click.option(
380
+ "--format",
381
+ "-f",
382
+ type=click.Choice(["table", "json"]),
383
+ default="table",
384
+ show_default=True,
385
+ help="Output format",
386
+ )
387
+ def list_functions(
388
+ workspace: Optional[str] = None,
389
+ name: Optional[str] = None,
390
+ interface_contains: Optional[str] = None,
391
+ filter: Optional[str] = None,
392
+ take: int = 25,
393
+ format: str = "table",
394
+ ) -> None:
395
+ """List function definitions."""
396
+ format_output = validate_output_format(format)
397
+
398
+ try:
399
+ workspace_map = get_workspace_map()
400
+
401
+ # Resolve workspace filter to ID if specified
402
+ workspace_id = None
403
+ workspace = get_effective_workspace(workspace)
404
+ if workspace:
405
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
406
+
407
+ # Use continuation token pagination to get all functions
408
+ all_functions = _query_all_functions(
409
+ workspace_filter=workspace_id,
410
+ name_filter=name,
411
+ interface_filter=interface_contains,
412
+ custom_filter=filter,
413
+ workspace_map=workspace_map,
414
+ )
415
+
416
+ # Create a mock response with all data
417
+ resp: Any = FilteredResponse({"functions": all_functions})
418
+
419
+ # Use universal response handler with function formatter
420
+ def function_formatter(function: Dict[str, Any]) -> List[str]:
421
+ ws_guid = function.get("workspaceId", "")
422
+ ws_name = get_workspace_display_name(ws_guid, workspace_map)
423
+
424
+ # Format timestamps
425
+ created_at = function.get("createdAt", "")
426
+ if created_at:
427
+ created_at = created_at.split("T")[0] # Just the date part
428
+
429
+ return [
430
+ function.get("id", ""),
431
+ function.get("name", ""),
432
+ function.get("version", ""),
433
+ ws_name,
434
+ created_at,
435
+ ]
436
+
437
+ UniversalResponseHandler.handle_list_response(
438
+ resp=resp,
439
+ data_key="functions",
440
+ item_name="function",
441
+ format_output=format_output,
442
+ formatter_func=function_formatter,
443
+ headers=[
444
+ "ID",
445
+ "Name",
446
+ "Version",
447
+ "Workspace",
448
+ "Created",
449
+ ],
450
+ column_widths=[36, 30, 10, 20, 12],
451
+ empty_message="No function definitions found.",
452
+ enable_pagination=True,
453
+ page_size=take,
454
+ )
455
+
456
+ except Exception as exc:
457
+ handle_api_error(exc)
458
+
459
+ @manage_group.command(name="get")
460
+ @click.option(
461
+ "--id",
462
+ "-i",
463
+ "function_id",
464
+ required=True,
465
+ help="Function ID to retrieve",
466
+ )
467
+ @click.option(
468
+ "--format",
469
+ "-f",
470
+ type=click.Choice(["table", "json"]),
471
+ default="table",
472
+ show_default=True,
473
+ help="Output format",
474
+ )
475
+ def get_function(function_id: str, format: str = "table") -> None:
476
+ """Get detailed information about a specific function definition."""
477
+ format_output = validate_output_format(format)
478
+ url = f"{get_unified_v2_base()}/functions/{function_id}"
479
+
480
+ try:
481
+ resp = make_api_request("GET", url)
482
+ data = resp.json()
483
+
484
+ if format_output == "json":
485
+ click.echo(json.dumps(data, indent=2))
486
+ return
487
+
488
+ workspace_map = get_workspace_map()
489
+ ws_name = get_workspace_display_name(data.get("workspaceId", ""), workspace_map)
490
+
491
+ click.echo("Function Definition Details:")
492
+ click.echo("=" * 50)
493
+ click.echo(f"ID: {data.get('id', 'N/A')}")
494
+ click.echo(f"Name: {data.get('name', 'N/A')}")
495
+ click.echo(f"Description: {data.get('description', 'N/A')}")
496
+ click.echo(f"Workspace: {ws_name}")
497
+ click.echo(f"Version: {data.get('version', 'N/A')}")
498
+ click.echo(f"Runtime: {data.get('runtime', 'N/A')}")
499
+ click.echo(f"Created At: {data.get('createdAt', 'N/A')}")
500
+ click.echo(f"Updated At: {data.get('updatedAt', 'N/A')}")
501
+
502
+ interface = data.get("interface")
503
+ if interface:
504
+ # New-style interface (HTTP-like) with endpoints summary
505
+ endpoints = interface.get("endpoints")
506
+ if endpoints and isinstance(endpoints, list):
507
+ click.echo("\nInterface:")
508
+ default_path = interface.get("defaultPath")
509
+ if default_path:
510
+ click.echo(f"Default Path: {default_path}")
511
+ click.echo("Endpoints:")
512
+ for ep in endpoints:
513
+ methods = (
514
+ ",".join(ep.get("methods", [])).upper() if ep.get("methods") else "*"
515
+ )
516
+ path = ep.get("path", "")
517
+ desc = ep.get("description", "")
518
+ click.echo(f" - {methods} {path} - {desc}")
519
+ # Legacy-style interface fields
520
+ if interface.get("entrypoint"):
521
+ click.echo(f"Entrypoint: {interface['entrypoint']}")
522
+ if interface.get("parameters"):
523
+ click.echo("\nParameters Schema:")
524
+ click.echo(json.dumps(interface["parameters"], indent=2))
525
+ if interface.get("returns"):
526
+ click.echo("\nReturns Schema:")
527
+ click.echo(json.dumps(interface["returns"], indent=2))
528
+ else:
529
+ if data.get("entrypoint"):
530
+ click.echo(f"Entrypoint: {data['entrypoint']}")
531
+ if data.get("parameters"):
532
+ click.echo("\nParameters Schema:")
533
+ click.echo(json.dumps(data["parameters"], indent=2))
534
+ if data.get("returns"):
535
+ click.echo("\nReturns Schema:")
536
+ click.echo(json.dumps(data["returns"], indent=2))
537
+
538
+ if data.get("properties"):
539
+ click.echo("\nCustom Properties:")
540
+ for key, value in data["properties"].items():
541
+ click.echo(f" {key}: {value}")
542
+ except Exception as exc:
543
+ handle_api_error(exc)
544
+
545
+ @manage_group.command(name="create")
546
+ @click.option(
547
+ "--name",
548
+ "-n",
549
+ required=True,
550
+ help="Function display name",
551
+ )
552
+ @click.option(
553
+ "--workspace",
554
+ "-w",
555
+ default="Default",
556
+ help="Workspace name or ID (default: 'Default')",
557
+ )
558
+ @click.option(
559
+ "--runtime",
560
+ "-r",
561
+ default="wasm",
562
+ type=click.Choice(["wasm"], case_sensitive=False),
563
+ help="Runtime environment for the function (WebAssembly)",
564
+ )
565
+ @click.option(
566
+ "--description",
567
+ "-d",
568
+ help="Function description",
569
+ )
570
+ @click.option(
571
+ "--version",
572
+ "-v",
573
+ default="1.0.0",
574
+ show_default=True,
575
+ help="Function version",
576
+ )
577
+ @click.option(
578
+ "--entrypoint",
579
+ "-e",
580
+ help="WASM file name without extension (stored in interface.entrypoint)",
581
+ )
582
+ @click.option(
583
+ "--content",
584
+ "-c",
585
+ help="Function source code content or file path",
586
+ )
587
+ @click.option(
588
+ "--parameters-schema",
589
+ "-p",
590
+ help="JSON schema for function parameters (stored in interface.parameters) (JSON string or file path)",
591
+ )
592
+ @click.option(
593
+ "--returns-schema",
594
+ help="JSON schema for function return value (stored in interface.returns) (JSON string or file path)",
595
+ )
596
+ @click.option(
597
+ "--properties",
598
+ help='Custom properties as JSON string for metadata and filtering (e.g., \'{"category": "processing", "team": "data-science"}\')',
599
+ )
600
+ def create_function(
601
+ name: str,
602
+ workspace: str = "Default",
603
+ runtime: str = "wasm",
604
+ description: Optional[str] = None,
605
+ version: str = "1.0.0",
606
+ entrypoint: Optional[str] = None,
607
+ content: Optional[str] = None,
608
+ parameters_schema: Optional[str] = None,
609
+ returns_schema: Optional[str] = None,
610
+ properties: Optional[str] = None,
611
+ ) -> None:
612
+ """Create a new function definition with metadata for efficient querying."""
613
+ from .utils import check_readonly_mode
614
+
615
+ check_readonly_mode("create a function")
616
+
617
+ url = f"{get_unified_v2_base()}/functions"
618
+ try:
619
+ workspace_id = get_workspace_id_with_fallback(
620
+ get_effective_workspace(workspace) or workspace
621
+ )
622
+
623
+ custom_properties: Dict[str, Any] = {}
624
+ if properties:
625
+ try:
626
+ custom_properties.update(json.loads(properties))
627
+ except json.JSONDecodeError:
628
+ click.echo("✗ Error: Invalid JSON in --properties option", err=True)
629
+ sys.exit(ExitCodes.INVALID_INPUT)
630
+
631
+ params_schema = None
632
+ if parameters_schema:
633
+ try:
634
+ params_schema = (
635
+ json.loads(parameters_schema)
636
+ if parameters_schema.startswith("{")
637
+ else load_json_file(parameters_schema)
638
+ )
639
+ except Exception as e: # noqa: BLE001
640
+ click.echo(f"✗ Error loading parameters schema: {e}", err=True)
641
+ sys.exit(ExitCodes.INVALID_INPUT)
642
+
643
+ ret_schema = None
644
+ if returns_schema:
645
+ try:
646
+ ret_schema = (
647
+ json.loads(returns_schema)
648
+ if returns_schema.startswith("{")
649
+ else load_json_file(returns_schema)
650
+ )
651
+ except Exception as e: # noqa: BLE001
652
+ click.echo(f"✗ Error loading returns schema: {e}", err=True)
653
+ sys.exit(ExitCodes.INVALID_INPUT)
654
+
655
+ if not entrypoint and content and Path(content).exists():
656
+ entrypoint = Path(content).stem
657
+
658
+ interface_obj: Optional[Dict[str, Any]] = None
659
+ if entrypoint or params_schema or ret_schema:
660
+ interface_obj = {}
661
+ if entrypoint:
662
+ interface_obj["entrypoint"] = entrypoint
663
+ if params_schema:
664
+ interface_obj["parameters"] = params_schema
665
+ if ret_schema:
666
+ interface_obj["returns"] = ret_schema
667
+
668
+ if content:
669
+ if Path(content).exists():
670
+ try:
671
+ with open(content, "rb") as f:
672
+ content_data = f.read()
673
+ except Exception as e: # noqa: BLE001
674
+ click.echo(f"✗ Error reading content file: {e}", err=True)
675
+ sys.exit(ExitCodes.INVALID_INPUT)
676
+ else:
677
+ content_data = content.encode("utf-8")
678
+
679
+ function_metadata: Dict[str, Any] = {
680
+ "name": name,
681
+ "workspaceId": workspace_id,
682
+ "runtime": runtime.lower(),
683
+ "version": version,
684
+ }
685
+ if description:
686
+ function_metadata["description"] = description
687
+ if interface_obj:
688
+ function_metadata["interface"] = interface_obj
689
+ if custom_properties:
690
+ function_metadata["properties"] = custom_properties
691
+
692
+ files = {
693
+ "metadata": (None, json.dumps(function_metadata), "application/json"),
694
+ "content": ("function_content", content_data, "application/octet-stream"),
695
+ }
696
+ resp = requests.post(
697
+ url,
698
+ files=files, # type: ignore
699
+ headers=get_headers(""),
700
+ verify=get_ssl_verify(),
701
+ )
702
+ resp.raise_for_status()
703
+ else:
704
+ function_request: Dict[str, Any] = {
705
+ "name": name,
706
+ "workspaceId": workspace_id,
707
+ "runtime": runtime.lower(),
708
+ "version": version,
709
+ }
710
+ if description:
711
+ function_request["description"] = description
712
+ if interface_obj:
713
+ function_request["interface"] = interface_obj
714
+ if custom_properties:
715
+ function_request["properties"] = custom_properties
716
+ resp = make_api_request("POST", url, function_request)
717
+
718
+ response_data = resp.json()
719
+ click.echo(
720
+ f"✓ Function definition created successfully with ID: {response_data.get('id', '')}"
721
+ )
722
+ except Exception as exc: # noqa: BLE001
723
+ handle_api_error(exc)
724
+
725
+ @manage_group.command(name="update")
726
+ @click.option(
727
+ "--id",
728
+ "-i",
729
+ "function_id",
730
+ required=True,
731
+ help="Function ID to update",
732
+ )
733
+ @click.option(
734
+ "--name",
735
+ "-n",
736
+ help="Updated function display name",
737
+ )
738
+ @click.option(
739
+ "--description",
740
+ "-d",
741
+ help="Updated function description",
742
+ )
743
+ @click.option(
744
+ "--version",
745
+ "-v",
746
+ help="Updated function version",
747
+ )
748
+ @click.option(
749
+ "--workspace",
750
+ "-w",
751
+ help="Updated workspace for the function (name or ID)",
752
+ )
753
+ @click.option(
754
+ "--runtime",
755
+ help="Updated runtime environment (default: wasm)",
756
+ default="wasm",
757
+ )
758
+ @click.option(
759
+ "--entrypoint",
760
+ "-e",
761
+ help="Updated WASM file name without extension (stored in interface.entrypoint)",
762
+ )
763
+ @click.option(
764
+ "--content",
765
+ "-c",
766
+ help="Updated function source code content or file path",
767
+ )
768
+ @click.option(
769
+ "--parameters-schema",
770
+ "-p",
771
+ help="Updated JSON schema for function parameters (stored in interface.parameters) (JSON string or file path)",
772
+ )
773
+ @click.option(
774
+ "--returns-schema",
775
+ help="Updated JSON schema for function return value (stored in interface.returns) (JSON string or file path)",
776
+ )
777
+ @click.option(
778
+ "--properties",
779
+ help="Updated custom properties as JSON string for metadata and filtering (replaces existing properties)",
780
+ )
781
+ def update_function(
782
+ function_id: str,
783
+ name: Optional[str] = None,
784
+ description: Optional[str] = None,
785
+ version: Optional[str] = None,
786
+ workspace: Optional[str] = None,
787
+ runtime: str = "wasm",
788
+ entrypoint: Optional[str] = None,
789
+ content: Optional[str] = None,
790
+ parameters_schema: Optional[str] = None,
791
+ returns_schema: Optional[str] = None,
792
+ properties: Optional[str] = None,
793
+ ) -> None:
794
+ """Update an existing function definition."""
795
+ from .utils import check_readonly_mode
796
+
797
+ check_readonly_mode("update a function")
798
+
799
+ url = f"{get_unified_v2_base()}/functions/{function_id}"
800
+ try:
801
+ existing_function = make_api_request("GET", url).json()
802
+ except Exception as e: # noqa: BLE001
803
+ click.echo(f"✗ Error fetching existing function: {e}", err=True)
804
+ sys.exit(ExitCodes.NOT_FOUND)
805
+
806
+ try:
807
+ workspace_id = existing_function.get("workspaceId")
808
+ if workspace:
809
+ try:
810
+ workspace_id = get_workspace_id_with_fallback(workspace)
811
+ except Exception as e: # noqa: BLE001
812
+ click.echo(f"✗ Error resolving workspace '{workspace}': {e}", err=True)
813
+ sys.exit(ExitCodes.INVALID_INPUT)
814
+
815
+ params_schema = None
816
+ if parameters_schema:
817
+ try:
818
+ params_schema = (
819
+ json.loads(parameters_schema)
820
+ if parameters_schema.startswith("{")
821
+ else load_json_file(parameters_schema)
822
+ )
823
+ except Exception as e: # noqa: BLE001
824
+ click.echo(f"✗ Error loading parameters schema: {e}", err=True)
825
+ sys.exit(ExitCodes.INVALID_INPUT)
826
+
827
+ ret_schema = None
828
+ if returns_schema:
829
+ try:
830
+ ret_schema = (
831
+ json.loads(returns_schema)
832
+ if returns_schema.startswith("{")
833
+ else load_json_file(returns_schema)
834
+ )
835
+ except Exception as e: # noqa: BLE001
836
+ click.echo(f"✗ Error loading returns schema: {e}", err=True)
837
+ sys.exit(ExitCodes.INVALID_INPUT)
838
+
839
+ interface_obj = (
840
+ existing_function.get("interface", {}).copy()
841
+ if existing_function.get("interface")
842
+ else {}
843
+ )
844
+ if entrypoint is not None:
845
+ interface_obj["entrypoint"] = entrypoint
846
+ if params_schema is not None:
847
+ interface_obj["parameters"] = params_schema
848
+ if ret_schema is not None:
849
+ interface_obj["returns"] = ret_schema
850
+
851
+ custom_properties = None
852
+ if properties:
853
+ try:
854
+ custom_properties = json.loads(properties)
855
+ except json.JSONDecodeError:
856
+ click.echo("✗ Error: Invalid JSON in --properties option", err=True)
857
+ sys.exit(ExitCodes.INVALID_INPUT)
858
+
859
+ if (
860
+ name is None
861
+ and description is None
862
+ and version is None
863
+ and workspace is None
864
+ and entrypoint is None
865
+ and content is None
866
+ and parameters_schema is None
867
+ and returns_schema is None
868
+ and properties is None
869
+ ):
870
+ click.echo(
871
+ "✗ No updates provided. Please specify at least one field to update.", err=True
872
+ )
873
+ sys.exit(ExitCodes.INVALID_INPUT)
874
+
875
+ function_metadata: Dict[str, Any] = {
876
+ "name": name if name is not None else existing_function["name"],
877
+ "workspaceId": workspace_id,
878
+ "runtime": runtime,
879
+ }
880
+ if description is not None:
881
+ function_metadata["description"] = description
882
+ elif existing_function.get("description") is not None:
883
+ function_metadata["description"] = existing_function["description"]
884
+ if version is not None:
885
+ function_metadata["version"] = version
886
+ elif existing_function.get("version"):
887
+ function_metadata["version"] = existing_function["version"]
888
+ if interface_obj:
889
+ function_metadata["interface"] = interface_obj
890
+ if custom_properties is not None:
891
+ function_metadata["properties"] = custom_properties
892
+ elif existing_function.get("properties"):
893
+ function_metadata["properties"] = existing_function["properties"]
894
+
895
+ content_data = None
896
+ if content:
897
+ if Path(content).exists():
898
+ try:
899
+ with open(content, "rb") as f:
900
+ content_data = f.read()
901
+ except Exception as e: # noqa: BLE001
902
+ click.echo(f"✗ Error reading content file: {e}", err=True)
903
+ sys.exit(ExitCodes.INVALID_INPUT)
904
+ else:
905
+ content_data = content.encode("utf-8")
906
+
907
+ files: Dict[str, Any] = {
908
+ "metadata": (None, json.dumps(function_metadata), "application/json"),
909
+ }
910
+ if content_data is not None:
911
+ files["content"] = ("function_content", content_data, "application/octet-stream")
912
+
913
+ resp = requests.put(
914
+ url,
915
+ files=files,
916
+ headers=get_headers(""),
917
+ verify=get_ssl_verify(),
918
+ )
919
+ resp.raise_for_status()
920
+ click.echo("✓ Function definition updated successfully")
921
+ except Exception as exc: # noqa: BLE001
922
+ handle_api_error(exc)
923
+
924
+ @manage_group.command(name="delete")
925
+ @click.option(
926
+ "--id",
927
+ "-i",
928
+ "function_id",
929
+ required=True,
930
+ help="Function ID to delete",
931
+ )
932
+ @click.option(
933
+ "--force",
934
+ is_flag=True,
935
+ help="Skip confirmation prompt",
936
+ )
937
+ def delete_function(function_id: str, force: bool = False) -> None:
938
+ """Delete a function definition."""
939
+ from .utils import check_readonly_mode
940
+
941
+ check_readonly_mode("delete a function")
942
+
943
+ url = f"{get_unified_v2_base()}/functions/{function_id}"
944
+ try:
945
+ if not force and not click.confirm(
946
+ f"Are you sure you want to delete function {function_id}?"
947
+ ):
948
+ click.echo("Function deletion cancelled.")
949
+ return
950
+ resp = make_api_request("DELETE", url, handle_errors=False)
951
+ if resp.status_code == 204:
952
+ click.echo(f"✓ Function {function_id} deleted successfully.")
953
+ else:
954
+ response_data = resp.json() if resp.text.strip() else {}
955
+ display_api_errors("Function deletion failed", response_data, detailed=True)
956
+ sys.exit(ExitCodes.GENERAL_ERROR)
957
+ except Exception as exc: # noqa: BLE001
958
+ handle_api_error(exc)
959
+
960
+ @manage_group.command(name="download-content")
961
+ @click.option(
962
+ "--id",
963
+ "-i",
964
+ "function_id",
965
+ required=True,
966
+ help="Function ID to download content from",
967
+ )
968
+ @click.option(
969
+ "--output",
970
+ "-o",
971
+ help="Output file path (defaults to function_<id> with appropriate extension)",
972
+ )
973
+ def download_function_content(function_id: str, output: Optional[str] = None) -> None:
974
+ """Download function source code content."""
975
+ url = f"{get_unified_v2_base()}/functions/{function_id}/content"
976
+ try:
977
+ resp = make_api_request("GET", url, handle_errors=False)
978
+ if resp.status_code != 200:
979
+ response_data = resp.json() if resp.text.strip() else {}
980
+ display_api_errors("Function content download failed", response_data, detailed=True)
981
+ sys.exit(ExitCodes.GENERAL_ERROR)
982
+
983
+ if not output:
984
+ try:
985
+ meta_data = make_api_request(
986
+ "GET", f"{get_unified_v2_base()}/functions/{function_id}"
987
+ ).json()
988
+ runtime = meta_data.get("runtime", "").lower()
989
+ ext = {"wasm": ".wasm"}.get(runtime, ".wasm")
990
+ output = f"function_{function_id}{ext}"
991
+ except Exception: # noqa: BLE001
992
+ output = f"function_{function_id}.wasm"
993
+
994
+ with open(output, "wb") as f:
995
+ f.write(resp.content)
996
+ click.echo(f"✓ Function content downloaded to '{output}' ({len(resp.content)} bytes)")
997
+ except Exception as exc: # noqa: BLE001
998
+ handle_api_error(exc)
999
+
1000
+ # Function Execution Management Commands
1001
+ @execute_group.command(name="list")
1002
+ @click.option(
1003
+ "--workspace",
1004
+ "-w",
1005
+ help="Filter by workspace name or ID",
1006
+ )
1007
+ @click.option(
1008
+ "--status",
1009
+ "-s",
1010
+ type=click.Choice(
1011
+ ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "TIMEOUT"],
1012
+ case_sensitive=False,
1013
+ ),
1014
+ help="Filter by execution status",
1015
+ )
1016
+ @click.option(
1017
+ "--function-id",
1018
+ "-f",
1019
+ help="Filter by function ID",
1020
+ )
1021
+ @click.option(
1022
+ "--take",
1023
+ "-t",
1024
+ type=int,
1025
+ default=25,
1026
+ show_default=True,
1027
+ help="Maximum number of executions to return",
1028
+ )
1029
+ @click.option(
1030
+ "--format",
1031
+ type=click.Choice(["table", "json"]),
1032
+ default="table",
1033
+ show_default=True,
1034
+ help="Output format",
1035
+ )
1036
+ def list_executions(
1037
+ workspace: Optional[str] = None,
1038
+ status: Optional[str] = None,
1039
+ function_id: Optional[str] = None,
1040
+ take: int = 25,
1041
+ format: str = "table",
1042
+ ) -> None:
1043
+ """List function executions."""
1044
+ format_output = validate_output_format(format)
1045
+
1046
+ try:
1047
+ workspace_map = get_workspace_map()
1048
+
1049
+ # Resolve workspace filter to ID if specified
1050
+ workspace_id = None
1051
+ workspace = get_effective_workspace(workspace)
1052
+ if workspace:
1053
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
1054
+
1055
+ # Normalize status to uppercase if provided
1056
+ status_filter = status.upper() if status else None
1057
+
1058
+ # Use continuation token pagination to get all executions
1059
+ all_executions = _query_all_executions(
1060
+ workspace_id, status_filter, function_id, workspace_map
1061
+ )
1062
+
1063
+ # Create a mock response with all data
1064
+ resp: Any = FilteredResponse({"executions": all_executions})
1065
+
1066
+ # Use universal response handler with execution formatter
1067
+ def execution_formatter(execution: Dict[str, Any]) -> List[str]:
1068
+ ws_guid = execution.get("workspaceId", "")
1069
+ ws_name = get_workspace_display_name(ws_guid, workspace_map)
1070
+
1071
+ # Format timestamps
1072
+ queued_at = execution.get("queuedAt", "")
1073
+ if queued_at:
1074
+ queued_at = queued_at.split("T")[0] # Just the date part
1075
+
1076
+ return [
1077
+ execution.get("id", ""), # Full ID
1078
+ execution.get("functionId", ""), # Full function ID
1079
+ ws_name,
1080
+ execution.get("status", "UNKNOWN"),
1081
+ queued_at,
1082
+ ]
1083
+
1084
+ UniversalResponseHandler.handle_list_response(
1085
+ resp=resp,
1086
+ data_key="executions",
1087
+ item_name="execution",
1088
+ format_output=format_output,
1089
+ formatter_func=execution_formatter,
1090
+ headers=["ID", "Function ID", "Workspace", "Status", "Queued"],
1091
+ column_widths=[36, 36, 20, 12, 12],
1092
+ empty_message="No function executions found.",
1093
+ enable_pagination=True,
1094
+ page_size=take,
1095
+ )
1096
+
1097
+ except Exception as exc:
1098
+ handle_api_error(exc)
1099
+
1100
+ @execute_group.command(name="get")
1101
+ @click.option(
1102
+ "--id",
1103
+ "-i",
1104
+ "execution_id",
1105
+ required=True,
1106
+ help="Execution ID to retrieve",
1107
+ )
1108
+ @click.option(
1109
+ "--format",
1110
+ "-f",
1111
+ type=click.Choice(["table", "json"]),
1112
+ default="table",
1113
+ show_default=True,
1114
+ help="Output format",
1115
+ )
1116
+ def get_execution(execution_id: str, format: str = "table") -> None:
1117
+ """Get detailed information about a specific function execution."""
1118
+ format_output = validate_output_format(format)
1119
+ url = f"{get_unified_v2_base()}/executions/{execution_id}"
1120
+ try:
1121
+ data = make_api_request("GET", url).json()
1122
+ if format_output == "json":
1123
+ click.echo(json.dumps(data, indent=2))
1124
+ return
1125
+ workspace_map = get_workspace_map()
1126
+ ws_name = get_workspace_display_name(data.get("workspaceId", ""), workspace_map)
1127
+ click.echo("Function Execution Details:")
1128
+ click.echo("=" * 50)
1129
+ click.echo(f"ID: {data.get('id', 'N/A')}")
1130
+ click.echo(f"Function ID: {data.get('functionId', 'N/A')}")
1131
+ click.echo(f"Workspace: {ws_name}")
1132
+ click.echo(f"Status: {data.get('status', 'N/A')}")
1133
+ click.echo(f"Timeout: {data.get('timeout', 'N/A')} seconds")
1134
+ click.echo(f"Retry Count: {data.get('retryCount', 0)}")
1135
+ click.echo(f"Cached Result: {data.get('cachedResult', False)}")
1136
+ click.echo(f"Queued At: {data.get('queuedAt', 'N/A')}")
1137
+ click.echo(f"Started At: {data.get('startedAt', 'N/A')}")
1138
+ click.echo(f"Completed At: {data.get('completedAt', 'N/A')}")
1139
+ if data.get("parameters"):
1140
+ click.echo("\nParameters:")
1141
+ click.echo(json.dumps(data["parameters"], indent=2))
1142
+ if data.get("result"):
1143
+ click.echo("\nResult:")
1144
+ click.echo(json.dumps(data["result"], indent=2))
1145
+ if data.get("errorMessage"):
1146
+ click.echo("\nError Message:")
1147
+ click.echo(data["errorMessage"])
1148
+ except Exception as exc: # noqa: BLE001
1149
+ handle_api_error(exc)
1150
+
1151
+ @execute_group.command(name="sync")
1152
+ @click.option(
1153
+ "--function-id",
1154
+ "-f",
1155
+ required=True,
1156
+ help="Function ID to execute synchronously",
1157
+ )
1158
+ @click.option(
1159
+ "--workspace",
1160
+ "-w",
1161
+ default="Default",
1162
+ help="Workspace name or ID (default: 'Default')",
1163
+ )
1164
+ @click.option(
1165
+ "--parameters",
1166
+ "-p",
1167
+ help="Raw JSON (string or file) for advanced parameters object (overrides --method/--path/--header/--body).",
1168
+ )
1169
+ @click.option(
1170
+ "--method",
1171
+ default="POST",
1172
+ show_default=True,
1173
+ help="Invocation HTTP method placed in parameters.method (ignored if --parameters used).",
1174
+ )
1175
+ @click.option(
1176
+ "--path",
1177
+ default="/invoke",
1178
+ show_default=True,
1179
+ help="Invocation path placed in parameters.path (ignored if --parameters used).",
1180
+ )
1181
+ @click.option(
1182
+ "--header",
1183
+ "-H",
1184
+ multiple=True,
1185
+ help="Request header key=value (can repeat). Ignored if --parameters used.",
1186
+ )
1187
+ @click.option(
1188
+ "--body",
1189
+ help="JSON string or file for request body placed in parameters.body (ignored if --parameters used).",
1190
+ )
1191
+ @click.option(
1192
+ "--timeout",
1193
+ "-t",
1194
+ type=int,
1195
+ default=300,
1196
+ show_default=True,
1197
+ help="Execution timeout in seconds (0 for infinite, maximum 3600 for synchronous execution)",
1198
+ )
1199
+ @click.option(
1200
+ "--client-request-id",
1201
+ help="Client-provided unique identifier for tracking",
1202
+ )
1203
+ @click.option(
1204
+ "--format",
1205
+ type=click.Choice(["table", "json"]),
1206
+ default="table",
1207
+ show_default=True,
1208
+ help="Output format",
1209
+ )
1210
+ def execute_function(
1211
+ function_id: str,
1212
+ workspace: str = "Default",
1213
+ parameters: Optional[str] = None,
1214
+ method: str = "POST",
1215
+ path: str = "/invoke",
1216
+ header: Optional[tuple] = None,
1217
+ body: Optional[str] = None,
1218
+ timeout: int = 300,
1219
+ client_request_id: Optional[str] = None,
1220
+ format: str = "table",
1221
+ ) -> None:
1222
+ """Execute a function synchronously and return the result.
1223
+
1224
+ This sends a single request and waits for completion (no async polling).
1225
+ """
1226
+ format_output = validate_output_format(format)
1227
+ url = f"{get_unified_v2_base()}/functions/{function_id}/execute"
1228
+ try:
1229
+ execution_parameters: Dict[str, Any] = {}
1230
+ if parameters:
1231
+ try:
1232
+ execution_parameters = (
1233
+ json.loads(parameters)
1234
+ if parameters.strip().startswith("{")
1235
+ else load_json_file(parameters)
1236
+ )
1237
+ except Exception as e: # noqa: BLE001
1238
+ click.echo(f"✗ Error parsing parameters: {e}", err=True)
1239
+ sys.exit(ExitCodes.INVALID_INPUT)
1240
+ legacy_keys = {"method", "path", "headers", "body"}
1241
+ if not any(k in execution_parameters for k in legacy_keys):
1242
+ execution_parameters = {"body": execution_parameters}
1243
+ else:
1244
+ # Determine if user explicitly set any of the four HTTP-related flags.
1245
+ # We treat them as specified only if they differ from defaults or were
1246
+ # provided via the parameters option.
1247
+ user_provided_any = (
1248
+ bool(header)
1249
+ or body is not None
1250
+ or (method.upper() != "POST" or path != "/invoke")
1251
+ )
1252
+ if not user_provided_any:
1253
+ # Pure omission: apply fallback default GET /
1254
+ execution_parameters = {"method": "GET", "path": "/"}
1255
+ else:
1256
+ headers_dict: Dict[str, str] = {}
1257
+ if header:
1258
+ for h in header:
1259
+ if "=" not in h:
1260
+ click.echo(
1261
+ f"✗ Invalid header format (expected key=value): {h}",
1262
+ err=True,
1263
+ )
1264
+ sys.exit(ExitCodes.INVALID_INPUT)
1265
+ k, v = h.split("=", 1)
1266
+ headers_dict[k.strip()] = v.strip()
1267
+ body_value: Any = None
1268
+ if body:
1269
+ try:
1270
+ body_value = (
1271
+ json.loads(body)
1272
+ if body.strip().startswith("{") or body.strip().startswith("[")
1273
+ else load_json_file(body)
1274
+ )
1275
+ except Exception:
1276
+ body_value = body
1277
+ norm_path = path if path.startswith("/") else f"/{path}"
1278
+ execution_parameters = {
1279
+ "method": method.upper(),
1280
+ "path": norm_path,
1281
+ }
1282
+ if headers_dict:
1283
+ execution_parameters["headers"] = headers_dict
1284
+ if body_value is not None:
1285
+ execution_parameters["body"] = body_value
1286
+ if timeout > 3600:
1287
+ click.echo(
1288
+ "✗ Timeout cannot exceed 3600 seconds (1 hour) for synchronous execution",
1289
+ err=True,
1290
+ )
1291
+ sys.exit(ExitCodes.INVALID_INPUT)
1292
+ execute_request: Dict[str, Any] = {
1293
+ "parameters": execution_parameters,
1294
+ "timeout": timeout,
1295
+ "async": False,
1296
+ }
1297
+ if client_request_id:
1298
+ execute_request["clientRequestId"] = client_request_id
1299
+ response_data = make_api_request("POST", url, execute_request).json()
1300
+ if format_output == "json":
1301
+ click.echo(json.dumps(response_data, indent=2))
1302
+ return
1303
+ click.echo("Function Execution Completed:")
1304
+ click.echo("=" * 50)
1305
+ click.echo(f"Execution ID: {response_data.get('executionId', 'N/A')}")
1306
+ click.echo(f"Execution Time: {response_data.get('executionTime', 0)} ms")
1307
+ click.echo(f"Cached Result: {response_data.get('cachedResult', False)}")
1308
+ result = response_data.get("result")
1309
+ if result is not None:
1310
+ click.echo("\nResult:")
1311
+ click.echo(json.dumps(result, indent=2))
1312
+ else:
1313
+ click.echo("\nResult: None (no return value)")
1314
+ except Exception as exc: # noqa: BLE001
1315
+ handle_api_error(exc)
1316
+
1317
+ @execute_group.command(name="cancel")
1318
+ @click.option(
1319
+ "--id",
1320
+ "-i",
1321
+ "execution_ids",
1322
+ multiple=True,
1323
+ required=True,
1324
+ help="Execution ID(s) to cancel (can be specified multiple times)",
1325
+ )
1326
+ def cancel_executions(execution_ids: tuple) -> None:
1327
+ """Cancel one or more function executions."""
1328
+ url = f"{get_unified_v2_base()}/executions/cancel"
1329
+ payload = {"ids": list(execution_ids)}
1330
+ try:
1331
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1332
+ if resp.status_code == 204:
1333
+ if len(execution_ids) == 1:
1334
+ click.echo(f"✓ Execution {execution_ids[0]} cancelled successfully.")
1335
+ else:
1336
+ click.echo(f"✓ All {len(execution_ids)} executions cancelled successfully.")
1337
+ return
1338
+ if resp.status_code == 200:
1339
+ data = resp.json()
1340
+ cancelled = data.get("cancelled", [])
1341
+ failed = data.get("failed", [])
1342
+ if cancelled:
1343
+ if len(cancelled) == 1:
1344
+ click.echo(f"✓ Execution {cancelled[0]} cancelled successfully.")
1345
+ else:
1346
+ click.echo(f"✓ {len(cancelled)} executions cancelled successfully:")
1347
+ for eid in cancelled:
1348
+ click.echo(f" - {eid}")
1349
+ if failed:
1350
+ click.echo(f"✗ Failed to cancel {len(failed)} execution(s):", err=True)
1351
+ for failure in failed:
1352
+ eid = failure.get("id", "unknown")
1353
+ err_msg = failure.get("error", {}).get("message", "Unknown error")
1354
+ click.echo(f" - {eid}: {err_msg}", err=True)
1355
+ sys.exit(ExitCodes.GENERAL_ERROR)
1356
+ return
1357
+ response_data = resp.json() if resp.text.strip() else {}
1358
+ display_api_errors(
1359
+ "Function execution cancellation failed", response_data, detailed=True
1360
+ )
1361
+ sys.exit(ExitCodes.GENERAL_ERROR)
1362
+ except Exception as exc: # noqa: BLE001
1363
+ handle_api_error(exc)
1364
+
1365
+ @execute_group.command(name="retry")
1366
+ @click.option(
1367
+ "--id",
1368
+ "-i",
1369
+ "execution_ids",
1370
+ multiple=True,
1371
+ required=True,
1372
+ help="Execution ID(s) to retry (can be specified multiple times)",
1373
+ )
1374
+ def retry_executions(execution_ids: tuple) -> None:
1375
+ """Retry one or more failed function executions."""
1376
+ url = f"{get_unified_v2_base()}/executions/retry"
1377
+ payload = {"ids": list(execution_ids)}
1378
+ try:
1379
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1380
+ if resp.status_code in (200, 201):
1381
+ data = resp.json()
1382
+ executions = data.get("executions", [])
1383
+ failed = data.get("failed", [])
1384
+ if executions:
1385
+ click.echo(f"✓ {len(executions)} retry executions created successfully:")
1386
+ for execution in executions:
1387
+ click.echo(f" - New execution: {execution.get('id', '')}")
1388
+ if failed:
1389
+ click.echo(f"✗ Failed to retry {len(failed)} execution(s):", err=True)
1390
+ for failure in failed:
1391
+ eid = failure.get("id", "unknown")
1392
+ err_msg = failure.get("error", {}).get("message", "Unknown error")
1393
+ click.echo(f" - {eid}: {err_msg}", err=True)
1394
+ sys.exit(ExitCodes.GENERAL_ERROR)
1395
+ return
1396
+ response_data = resp.json() if resp.text.strip() else {}
1397
+ display_api_errors("Function execution retry failed", response_data, detailed=True)
1398
+ sys.exit(ExitCodes.GENERAL_ERROR)
1399
+ except Exception as exc: # noqa: BLE001
1400
+ handle_api_error(exc)