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
slcli/dff_click.py ADDED
@@ -0,0 +1,979 @@
1
+ """CLI commands for managing SystemLink Custom Fields."""
2
+
3
+ import json
4
+ import sys
5
+ import urllib.parse
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import click
9
+ import questionary
10
+ import requests
11
+
12
+ from .platform import require_feature
13
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
14
+ from .utils import (
15
+ ExitCodes,
16
+ get_base_url,
17
+ get_workspace_map,
18
+ handle_api_error,
19
+ load_json_file,
20
+ make_api_request,
21
+ sanitize_filename,
22
+ save_json_file,
23
+ )
24
+ from .web_editor import launch_dff_editor
25
+ from .workspace_utils import (
26
+ WorkspaceFormatter,
27
+ filter_by_workspace,
28
+ get_effective_workspace,
29
+ resolve_workspace_filter,
30
+ )
31
+
32
+ # Valid resource types for Custom Fields
33
+ VALID_RESOURCE_TYPES = [
34
+ "workorder:workorder",
35
+ "workitem:workitem",
36
+ "asset:asset",
37
+ "system:system",
38
+ "testmonitor:product",
39
+ ]
40
+
41
+ # Valid field types for Custom Fields
42
+ VALID_FIELD_TYPES = [
43
+ "Text",
44
+ "Number",
45
+ "Boolean",
46
+ "Enum",
47
+ "DateTime",
48
+ "Table",
49
+ "LinkedResource",
50
+ ]
51
+
52
+ # Help text for resource type parameter
53
+ RESOURCE_TYPE_HELP = f"Resource type. Valid values: {', '.join(VALID_RESOURCE_TYPES)}"
54
+
55
+ # Help text for field type parameter
56
+ FIELD_TYPE_HELP = f"Field type. Valid values: {', '.join(VALID_FIELD_TYPES)}"
57
+
58
+
59
+ def _handle_dff_error_response(error_data: Dict[str, Any], operation: str = "operation") -> None:
60
+ """Parse and display DFF-specific error responses."""
61
+ # Check for DFF-specific error structure with failedConfigurations, failedGroups, etc.
62
+ if any(key in error_data for key in ["failedConfigurations", "failedGroups", "failedFields"]):
63
+ _handle_dff_creation_errors(error_data, operation)
64
+
65
+ elif "error" in error_data and "innerErrors" in error_data["error"]:
66
+ # Handle nested error structure
67
+ _handle_dff_nested_errors(error_data["error"])
68
+
69
+ elif "errors" in error_data:
70
+ # Handle simple validation errors structure
71
+ _handle_simple_validation_errors(error_data)
72
+
73
+ else:
74
+ # Fallback for unknown error structure
75
+ click.echo("✗ Request failed with validation errors:", err=True)
76
+ if "message" in error_data:
77
+ click.echo(f" {error_data['message']}", err=True)
78
+ else:
79
+ click.echo(f" {error_data}", err=True)
80
+
81
+
82
+ def _handle_dff_creation_errors(error_data: Dict[str, Any], operation: str = "operation") -> None:
83
+ """Handle DFF creation/update response with failed configurations/groups/fields."""
84
+ click.echo(f"✗ Configuration {operation} failed with the following issues:", err=True)
85
+
86
+ # Show successful operations if any
87
+ successful_configs = error_data.get("configurations", [])
88
+ if successful_configs:
89
+ click.echo(f"\n✓ Successfully {operation}d configurations:")
90
+ for config in successful_configs:
91
+ click.echo(f" - {config.get('name', config.get('key', 'Unknown'))}")
92
+
93
+ # Show failed configurations
94
+ failed_configs = error_data.get("failedConfigurations", [])
95
+ if failed_configs:
96
+ click.echo("\n✗ Failed configurations:")
97
+ for config in failed_configs:
98
+ click.echo(f" - {config.get('name', config.get('key', 'Unknown'))}")
99
+
100
+ # Show failed groups
101
+ failed_groups = error_data.get("failedGroups", [])
102
+ if failed_groups:
103
+ click.echo("\n✗ Failed groups:")
104
+ for group in failed_groups:
105
+ click.echo(f" - {group.get('displayText', group.get('key', 'Unknown'))}")
106
+
107
+
108
+ def _handle_dff_nested_errors(error: Dict[str, Any]) -> None:
109
+ """Handle nested error structure with innerErrors."""
110
+ click.echo("✗ Request failed with validation errors:", err=True)
111
+
112
+ if "message" in error:
113
+ click.echo(f" {error['message']}")
114
+
115
+ inner_errors = error.get("innerErrors", [])
116
+ if inner_errors:
117
+ click.echo("\nDetailed errors:")
118
+ for inner_error in inner_errors:
119
+ message = inner_error.get("message", "Unknown error")
120
+ click.echo(f" • {message}")
121
+
122
+
123
+ def _handle_simple_validation_errors(error_data: Dict[str, Any]) -> None:
124
+ """Handle simple validation errors structure."""
125
+ click.echo("✗ Validation errors occurred:", err=True)
126
+ errors = error_data.get("errors", {})
127
+
128
+ for field, field_errors in errors.items():
129
+ if isinstance(field_errors, list):
130
+ for error in field_errors:
131
+ click.echo(f" - {field}: {error}", err=True)
132
+ else:
133
+ click.echo(f" - {field}: {field_errors}", err=True)
134
+
135
+ # Show title if available
136
+ if "title" in error_data:
137
+ click.echo(f" Summary: {error_data['title']}", err=True)
138
+
139
+
140
+ def validate_resource_type(resource_type: str) -> None:
141
+ """Validate that the resource type is one of the supported values.
142
+
143
+ Args:
144
+ resource_type: The resource type to validate
145
+
146
+ Raises:
147
+ click.ClickException: If the resource type is not valid
148
+ """
149
+ if resource_type not in VALID_RESOURCE_TYPES:
150
+ valid_types_str = ", ".join(VALID_RESOURCE_TYPES)
151
+ raise click.ClickException(
152
+ f"Invalid resource type: '{resource_type}'. " f"Valid types are: {valid_types_str}"
153
+ )
154
+
155
+
156
+ def validate_field_type(field_type: str) -> None:
157
+ """Validate that the field type is one of the supported values.
158
+
159
+ Args:
160
+ field_type: The field type to validate
161
+
162
+ Raises:
163
+ click.ClickException: If the field type is not valid
164
+ """
165
+ if field_type not in VALID_FIELD_TYPES:
166
+ valid_types_str = ", ".join(VALID_FIELD_TYPES)
167
+ raise click.ClickException(
168
+ f"Invalid field type: '{field_type}'. " f"Valid types are: {valid_types_str}"
169
+ )
170
+
171
+
172
+ def _query_all_groups(
173
+ workspace_filter: Optional[str] = None, workspace_map: Optional[dict] = None
174
+ ) -> List[Dict[str, Any]]:
175
+ """Query all DFF groups using continuation token pagination.
176
+
177
+ Args:
178
+ workspace_filter: Optional workspace ID or name to filter by
179
+ workspace_map: Optional workspace mapping to avoid repeated lookups
180
+
181
+ Returns:
182
+ List of all groups, optionally filtered by workspace
183
+ """
184
+ url = f"{get_base_url()}/nidynamicformfields/v1/groups"
185
+ all_groups = []
186
+ continuation_token = None
187
+
188
+ while True:
189
+ # Build parameters for the request
190
+ params = {"Take": 100} # Use smaller page size for efficient pagination
191
+ if continuation_token:
192
+ params["ContinuationToken"] = continuation_token
193
+
194
+ # Build query string
195
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
196
+ full_url = f"{url}?{query_string}"
197
+
198
+ resp = make_api_request("GET", full_url)
199
+ data = resp.json()
200
+
201
+ # Extract groups from this page
202
+ groups = data.get("groups", [])
203
+ all_groups.extend(groups)
204
+
205
+ # Check if there are more pages
206
+ continuation_token = data.get("continuationToken")
207
+ if not continuation_token:
208
+ break
209
+
210
+ # Filter by workspace if specified
211
+ if workspace_filter and workspace_map:
212
+ all_groups = filter_by_workspace(all_groups, workspace_filter, workspace_map)
213
+
214
+ return all_groups
215
+
216
+
217
+ def _query_all_fields(
218
+ workspace_filter: Optional[str] = None, workspace_map: Optional[dict] = None
219
+ ) -> List[Dict[str, Any]]:
220
+ """Query all DFF fields using continuation token pagination.
221
+
222
+ Args:
223
+ workspace_filter: Optional workspace ID or name to filter by
224
+ workspace_map: Optional workspace mapping to avoid repeated lookups
225
+
226
+ Returns:
227
+ List of all fields, optionally filtered by workspace
228
+ """
229
+ url = f"{get_base_url()}/nidynamicformfields/v1/fields"
230
+ all_fields = []
231
+ continuation_token = None
232
+
233
+ while True:
234
+ # Build parameters for the request
235
+ params = {"Take": 500} # Use smaller page size for efficient pagination
236
+ if continuation_token:
237
+ params["ContinuationToken"] = continuation_token
238
+
239
+ # Build query string
240
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
241
+ full_url = f"{url}?{query_string}"
242
+
243
+ resp = make_api_request("GET", full_url)
244
+ data = resp.json()
245
+
246
+ # Extract fields from this page
247
+ fields = data.get("fields", [])
248
+ all_fields.extend(fields)
249
+
250
+ # Check if there are more pages
251
+ continuation_token = data.get("continuationToken")
252
+ if not continuation_token:
253
+ break
254
+
255
+ # Filter by workspace if specified
256
+ if workspace_filter and workspace_map:
257
+ all_fields = filter_by_workspace(all_fields, workspace_filter, workspace_map)
258
+
259
+ return all_fields
260
+
261
+
262
+ def _query_all_configurations(
263
+ workspace_filter: Optional[str] = None, workspace_map: Optional[dict] = None
264
+ ) -> List[Dict[str, Any]]:
265
+ """Query all configurations using continuation token pagination.
266
+
267
+ Args:
268
+ workspace_filter: Optional workspace ID or name to filter by
269
+ workspace_map: Optional workspace mapping to avoid repeated lookups
270
+
271
+ Returns:
272
+ List of all configurations, optionally filtered by workspace
273
+ """
274
+ url = f"{get_base_url()}/nidynamicformfields/v1/configurations"
275
+ all_configurations = []
276
+ continuation_token = None
277
+
278
+ while True:
279
+ # Build parameters for the request
280
+ params = {"Take": 100} # Use smaller page size for efficient pagination
281
+ if continuation_token:
282
+ params["ContinuationToken"] = continuation_token
283
+
284
+ # Build query string
285
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
286
+ full_url = f"{url}?{query_string}"
287
+
288
+ resp = make_api_request("GET", full_url)
289
+ data = resp.json()
290
+
291
+ # Extract configurations from this page
292
+ configurations = data.get("configurations", [])
293
+ all_configurations.extend(configurations)
294
+
295
+ # Check if there are more pages
296
+ continuation_token = data.get("continuationToken")
297
+ if not continuation_token:
298
+ break
299
+
300
+ # Filter by workspace if specified
301
+ if workspace_filter and workspace_map:
302
+ all_configurations = filter_by_workspace(
303
+ all_configurations, workspace_filter, workspace_map
304
+ )
305
+
306
+ return all_configurations
307
+
308
+
309
+ def register_dff_commands(cli: Any) -> None:
310
+ """Register the 'customfield' command group and its subcommands."""
311
+
312
+ @cli.group(name="customfield")
313
+ @click.pass_context
314
+ def dff(ctx: click.Context) -> None:
315
+ """Manage custom field (DFF) configurations."""
316
+ # Check for platform feature availability
317
+ # Only check if a subcommand is being invoked (not just --help)
318
+ if ctx.invoked_subcommand is not None:
319
+ require_feature("dynamic_form_fields")
320
+
321
+ # Configuration commands (now at top level under dff)
322
+ @dff.command(name="list")
323
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
324
+ @click.option(
325
+ "--take",
326
+ default=25,
327
+ show_default=True,
328
+ help="Maximum number of configurations to return",
329
+ )
330
+ @click.option(
331
+ "--format",
332
+ "-f",
333
+ type=click.Choice(["table", "json"], case_sensitive=False),
334
+ default="table",
335
+ show_default=True,
336
+ help="Output format: table or json",
337
+ )
338
+ def list_configurations(
339
+ workspace: Optional[str] = None, take: int = 25, format: str = "table"
340
+ ) -> None:
341
+ """List custom field configurations."""
342
+ try:
343
+ # Get workspace map once and reuse it
344
+ workspace_map = get_workspace_map()
345
+
346
+ # Use the workspace formatter for consistent formatting
347
+ format_config_row = WorkspaceFormatter.create_config_row_formatter(workspace_map)
348
+
349
+ # Use continuation token pagination following user_click.py pattern
350
+ all_configurations = _query_all_configurations(workspace, workspace_map)
351
+
352
+ # Use UniversalResponseHandler for consistent pagination
353
+ from typing import Any
354
+
355
+ # Create a mock response with all data
356
+ filtered_resp: Any = FilteredResponse({"configurations": all_configurations})
357
+
358
+ handler = UniversalResponseHandler()
359
+ handler.handle_list_response(
360
+ filtered_resp,
361
+ "configurations",
362
+ "configuration",
363
+ format,
364
+ format_config_row,
365
+ ["Workspace", "Name", "Configuration ID"],
366
+ [36, 40, 36],
367
+ "No custom field configurations found.",
368
+ enable_pagination=True,
369
+ )
370
+
371
+ except Exception as exc:
372
+ handle_api_error(exc)
373
+
374
+ @dff.command(name="get")
375
+ @click.option(
376
+ "--id",
377
+ "-i",
378
+ "config_id",
379
+ required=True,
380
+ help="Configuration ID to retrieve",
381
+ )
382
+ @click.option(
383
+ "--format",
384
+ "-f",
385
+ type=click.Choice(["table", "json"], case_sensitive=False),
386
+ default="json",
387
+ show_default=True,
388
+ help="Output format: table or json",
389
+ )
390
+ def get_configuration(config_id: str, format: str = "json") -> None:
391
+ """Get a specific custom field configuration by ID."""
392
+ url = f"{get_base_url()}/nidynamicformfields/v1/resolved-configuration"
393
+
394
+ try:
395
+ params = {"configurationId": config_id}
396
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
397
+ full_url = f"{url}?{query_string}"
398
+
399
+ resp = make_api_request("GET", full_url)
400
+ data = resp.json()
401
+
402
+ if format == "json":
403
+ click.echo(json.dumps(data, indent=2))
404
+ return
405
+
406
+ # Table format - show basic info
407
+ configuration = data.get("configuration", {})
408
+ workspace_map = get_workspace_map()
409
+ workspace_id = configuration.get("workspace", "")
410
+ workspace_name = workspace_map.get(workspace_id, workspace_id)
411
+
412
+ click.echo("Configuration Details")
413
+ click.echo("=" * 50)
414
+ click.echo(f"ID: {configuration.get('id', '')}")
415
+ click.echo(f"Name: {configuration.get('name', '')}")
416
+ click.echo(f"Workspace: {workspace_name}")
417
+ click.echo(f"Resource Type: {configuration.get('resourceType', '')}")
418
+
419
+ groups = data.get("groups", [])
420
+ fields = data.get("fields", [])
421
+ click.echo(f"Groups: {len(groups)}")
422
+ click.echo(f"Fields: {len(fields)}")
423
+
424
+ except Exception as exc:
425
+ handle_api_error(exc)
426
+
427
+ @dff.command(name="create")
428
+ @click.option(
429
+ "--file",
430
+ "-f",
431
+ "input_file",
432
+ required=True,
433
+ help="Input JSON file with configuration data",
434
+ )
435
+ def create_configuration(input_file: str) -> None:
436
+ """Create custom field configurations from a JSON file."""
437
+ from .utils import check_readonly_mode
438
+
439
+ check_readonly_mode("create a DataFlow Definition")
440
+
441
+ url = f"{get_base_url()}/nidynamicformfields/v1/configurations"
442
+
443
+ try:
444
+ data = load_json_file(input_file)
445
+
446
+ # Ensure data is in the expected format
447
+ if isinstance(data, dict) and "configurations" not in data:
448
+ # Wrap single configuration
449
+ data = {"configurations": [data]}
450
+ elif isinstance(data, list):
451
+ # Wrap list of configurations
452
+ data = {"configurations": data}
453
+
454
+ # Validate resource types in configurations
455
+ configurations = data.get("configurations", [])
456
+ for i, config in enumerate(configurations):
457
+ if isinstance(config, dict):
458
+ resource_type = config.get("resourceType")
459
+ if resource_type:
460
+ try:
461
+ validate_resource_type(resource_type)
462
+ except click.ClickException as e:
463
+ raise click.ClickException(
464
+ f"Invalid resource type in configuration {i + 1}: {e.message}"
465
+ )
466
+
467
+ # Validate field types in fields
468
+ fields = data.get("fields", [])
469
+ for i, field in enumerate(fields):
470
+ if isinstance(field, dict):
471
+ field_type = field.get("type")
472
+ if field_type:
473
+ try:
474
+ validate_field_type(field_type)
475
+ except click.ClickException as e:
476
+ raise click.ClickException(
477
+ f"Invalid field type in field {i + 1}: {e.message}"
478
+ )
479
+
480
+ # Make API request without automatic error handling to parse validation errors
481
+ resp = make_api_request("POST", url, data, handle_errors=False)
482
+
483
+ # Check for partial success response
484
+ response_data = resp.json() if resp.text.strip() else {}
485
+
486
+ if resp.status_code == 201:
487
+ # Full success
488
+ click.echo("✓ Custom field configurations created successfully.")
489
+ created_configs = response_data.get("configurations", [])
490
+ for config in created_configs:
491
+ click.echo(f" - {config.get('name', 'Unknown')}: {config.get('id', '')}")
492
+ elif resp.status_code == 200:
493
+ # Partial success - may contain DFF-specific error structure
494
+ if any(
495
+ key in response_data
496
+ for key in ["failedConfigurations", "failedGroups", "failedFields"]
497
+ ):
498
+ # Use DFF-specific error handling for partial failures
499
+ _handle_dff_error_response(response_data, "creation")
500
+ sys.exit(ExitCodes.INVALID_INPUT)
501
+ else:
502
+ # Handle legacy partial success format
503
+ click.echo("⚠ Some configurations were created, but some failed:", err=True)
504
+
505
+ # Show successful creations
506
+ successful = response_data.get("created", [])
507
+ if successful:
508
+ click.echo("Created:")
509
+ for config in successful:
510
+ click.echo(
511
+ f" ✓ {config.get('name', 'Unknown')}: {config.get('id', '')}"
512
+ )
513
+
514
+ # Show failures
515
+ failed = response_data.get("failed", [])
516
+ if failed:
517
+ click.echo("Failed:")
518
+ for failure in failed:
519
+ name = failure.get("name", "Unknown")
520
+ error = failure.get("error", {})
521
+ error_msg = error.get("message", "Unknown error")
522
+ click.echo(f" ✗ {name}: {error_msg}", err=True)
523
+
524
+ sys.exit(ExitCodes.GENERAL_ERROR)
525
+
526
+ except requests.RequestException as exc:
527
+ # Handle HTTP errors with detailed validation error parsing
528
+ if hasattr(exc, "response") and exc.response is not None:
529
+ try:
530
+ error_data = exc.response.json()
531
+ status_code = exc.response.status_code
532
+
533
+ if status_code == 400:
534
+ # Parse DFF-specific error structure
535
+ _handle_dff_error_response(error_data, "creation")
536
+ sys.exit(ExitCodes.INVALID_INPUT)
537
+ else:
538
+ # Fallback to general error handling for other HTTP errors
539
+ handle_api_error(exc)
540
+ except (ValueError, KeyError):
541
+ # If JSON parsing fails, fall back to general error handling
542
+ handle_api_error(exc)
543
+ else:
544
+ # For non-HTTP errors, use general error handling
545
+ handle_api_error(exc)
546
+ except Exception as exc:
547
+ handle_api_error(exc)
548
+
549
+ @dff.command(name="update")
550
+ @click.option(
551
+ "--file",
552
+ "-f",
553
+ "input_file",
554
+ required=True,
555
+ help="Input JSON file with updated configuration data",
556
+ )
557
+ def update_configuration(input_file: str) -> None:
558
+ """Update custom field configurations from a JSON file."""
559
+ from .utils import check_readonly_mode
560
+
561
+ check_readonly_mode("update a DataFlow Definition")
562
+
563
+ url = f"{get_base_url()}/nidynamicformfields/v1/update-configurations"
564
+
565
+ try:
566
+ data = load_json_file(input_file)
567
+
568
+ # Ensure data is in the expected format
569
+ if isinstance(data, dict) and "configurations" not in data:
570
+ data = {"configurations": [data]}
571
+ elif isinstance(data, list):
572
+ data = {"configurations": data}
573
+
574
+ resp = make_api_request("POST", url, data, handle_errors=False)
575
+ response_data = resp.json() if resp.text.strip() else {}
576
+
577
+ if resp.status_code == 200:
578
+ # Check if it's a DFF-specific partial success response
579
+ if any(
580
+ key in response_data
581
+ for key in ["failedConfigurations", "failedGroups", "failedFields"]
582
+ ):
583
+ # Use DFF-specific error handling for partial failures
584
+ _handle_dff_error_response(response_data, "update")
585
+ sys.exit(ExitCodes.INVALID_INPUT)
586
+ else:
587
+ # Handle legacy partial success format
588
+ updated_configs = response_data.get("configurations", [])
589
+ failed_updates = response_data.get("failed", [])
590
+
591
+ if failed_updates:
592
+ click.echo("⚠ Some configurations were updated, but some failed:", err=True)
593
+
594
+ if updated_configs:
595
+ click.echo("Updated:")
596
+ for config in updated_configs:
597
+ click.echo(
598
+ f" ✓ {config.get('name', 'Unknown')}: {config.get('id', '')}"
599
+ )
600
+
601
+ click.echo("Failed:")
602
+ for failure in failed_updates:
603
+ name = failure.get("name", "Unknown")
604
+ error = failure.get("error", {})
605
+ error_msg = error.get("message", "Unknown error")
606
+ click.echo(f" ✗ {name}: {error_msg}", err=True)
607
+
608
+ sys.exit(ExitCodes.GENERAL_ERROR)
609
+ else:
610
+ click.echo("✓ Custom field configurations updated successfully.")
611
+ for config in updated_configs:
612
+ click.echo(
613
+ f" - {config.get('name', 'Unknown')}: {config.get('id', '')}"
614
+ )
615
+
616
+ except requests.RequestException as exc:
617
+ # Handle HTTP errors with detailed validation error parsing
618
+ if hasattr(exc, "response") and exc.response is not None:
619
+ try:
620
+ error_data = exc.response.json()
621
+ status_code = exc.response.status_code
622
+
623
+ if status_code == 400:
624
+ # Parse DFF-specific error structure
625
+ _handle_dff_error_response(error_data, "update")
626
+ sys.exit(ExitCodes.INVALID_INPUT)
627
+ else:
628
+ # Fallback to general error handling for other HTTP errors
629
+ handle_api_error(exc)
630
+ except (ValueError, KeyError):
631
+ # If JSON parsing fails, fall back to general error handling
632
+ handle_api_error(exc)
633
+ else:
634
+ # For non-HTTP errors, use general error handling
635
+ handle_api_error(exc)
636
+ except Exception as exc:
637
+ handle_api_error(exc)
638
+
639
+ @dff.command(name="delete")
640
+ @click.option(
641
+ "--id",
642
+ "-i",
643
+ "config_ids",
644
+ multiple=True,
645
+ help="Configuration ID(s) to delete (can be specified multiple times)",
646
+ )
647
+ @click.option(
648
+ "--group-id",
649
+ "-g",
650
+ "group_ids",
651
+ multiple=True,
652
+ help="Group ID(s) to delete (can be specified multiple times)",
653
+ )
654
+ @click.option(
655
+ "--field-id",
656
+ "--fid",
657
+ "field_ids",
658
+ multiple=True,
659
+ help="Field ID(s) to delete (can be specified multiple times)",
660
+ )
661
+ @click.option(
662
+ "--no-recursive",
663
+ "recursive",
664
+ is_flag=True,
665
+ flag_value=False,
666
+ default=True,
667
+ help="Do not recursively delete dependent items (groups/fields when deleting configs)",
668
+ )
669
+ @click.confirmation_option(prompt="Are you sure you want to delete these items?")
670
+ def delete_configuration(
671
+ config_ids: tuple[str, ...],
672
+ group_ids: tuple[str, ...],
673
+ field_ids: tuple[str, ...],
674
+ recursive: bool = True,
675
+ ) -> None:
676
+ """Delete custom field configurations, groups, and fields."""
677
+ from .utils import check_readonly_mode
678
+
679
+ check_readonly_mode("delete a custom field configuration")
680
+
681
+ if not config_ids and not group_ids and not field_ids:
682
+ click.echo("✗ Must provide at least one of: --id, --group-id, or --field-id", err=True)
683
+ sys.exit(ExitCodes.INVALID_INPUT)
684
+
685
+ url = f"{get_base_url()}/nidynamicformfields/v1/delete"
686
+
687
+ try:
688
+ ids_to_delete = {
689
+ "configurationIds": list(config_ids),
690
+ "groupIds": list(group_ids),
691
+ "fieldIds": list(field_ids),
692
+ }
693
+
694
+ # Build payload with only non-empty ID lists
695
+ payload: dict[str, Any] = {k: v for k, v in ids_to_delete.items() if v}
696
+ payload["recursive"] = recursive
697
+
698
+ if not payload or all(not v for k, v in payload.items() if k != "recursive"):
699
+ click.echo("✗ No IDs found to delete", err=True)
700
+ sys.exit(ExitCodes.INVALID_INPUT)
701
+
702
+ resp = make_api_request("POST", url, payload, handle_errors=False)
703
+
704
+ if resp.status_code in (200, 204):
705
+ # Build summary
706
+ summary_parts = []
707
+ if ids_to_delete.get("configurationIds"):
708
+ summary_parts.append(
709
+ f"{len(ids_to_delete['configurationIds'])} configuration(s)"
710
+ )
711
+ if ids_to_delete.get("groupIds"):
712
+ summary_parts.append(f"{len(ids_to_delete['groupIds'])} group(s)")
713
+ if ids_to_delete.get("fieldIds"):
714
+ summary_parts.append(f"{len(ids_to_delete['fieldIds'])} field(s)")
715
+
716
+ summary = " and ".join(summary_parts) if summary_parts else "item(s)"
717
+ click.echo(f"✓ {summary} deleted successfully.")
718
+
719
+ # File-based delete removed; no input file updates
720
+ else:
721
+ # Handle partial success if needed
722
+ response_data = resp.json() if resp.text.strip() else {}
723
+ failed_deletes = response_data.get("failed", [])
724
+
725
+ if failed_deletes:
726
+ click.echo("⚠ Some items were deleted, but some failed:", err=True)
727
+ for failure in failed_deletes:
728
+ item_id = failure.get("id", "Unknown")
729
+ error = failure.get("error", {})
730
+ error_msg = error.get("message", "Unknown error")
731
+ click.echo(f" ✗ {item_id}: {error_msg}", err=True)
732
+
733
+ sys.exit(ExitCodes.GENERAL_ERROR)
734
+
735
+ except requests.RequestException as exc:
736
+ # Handle HTTP errors with detailed validation error parsing
737
+ if hasattr(exc, "response") and exc.response is not None:
738
+ try:
739
+ error_data = exc.response.json()
740
+ status_code = exc.response.status_code
741
+
742
+ if status_code == 400:
743
+ # Parse DFF-specific error structure
744
+ _handle_dff_error_response(error_data, "deletion")
745
+ sys.exit(ExitCodes.INVALID_INPUT)
746
+ else:
747
+ # Fallback to general error handling for other HTTP errors
748
+ handle_api_error(exc)
749
+ except (ValueError, KeyError):
750
+ # If JSON parsing fails, fall back to general error handling
751
+ handle_api_error(exc)
752
+ else:
753
+ # For non-HTTP errors, use general error handling
754
+ handle_api_error(exc)
755
+ except Exception as exc:
756
+ handle_api_error(exc)
757
+
758
+ @dff.command(name="export")
759
+ @click.option(
760
+ "--id",
761
+ "-i",
762
+ "config_id",
763
+ required=True,
764
+ help="Configuration ID to export",
765
+ )
766
+ @click.option("--output", "-o", help="Output JSON file (default: <config-name>.json)")
767
+ def export_configuration(config_id: str, output: Optional[str] = None) -> None:
768
+ """Export a custom field configuration to a JSON file."""
769
+ url = f"{get_base_url()}/nidynamicformfields/v1/resolved-configuration"
770
+
771
+ try:
772
+ params = {"configurationId": config_id}
773
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
774
+ full_url = f"{url}?{query_string}"
775
+
776
+ resp = make_api_request("GET", full_url)
777
+ data = resp.json()
778
+
779
+ # Generate output filename if not provided
780
+ if not output:
781
+ config_name = data.get("configuration", {}).get("name", f"config-{config_id}")
782
+ safe_name = sanitize_filename(config_name, f"config-{config_id}")
783
+ output = f"{safe_name}.json"
784
+
785
+ save_json_file(data, output)
786
+ click.echo(f"✓ Configuration exported to {output}")
787
+
788
+ except Exception as exc:
789
+ handle_api_error(exc)
790
+
791
+ @dff.command(name="init")
792
+ @click.option(
793
+ "--name",
794
+ "-n",
795
+ help="Configuration name (will prompt if not provided)",
796
+ )
797
+ @click.option(
798
+ "--workspace",
799
+ "-w",
800
+ help="Workspace name or ID (will prompt if not provided)",
801
+ )
802
+ @click.option(
803
+ "--resource-type",
804
+ "-r",
805
+ type=click.Choice(VALID_RESOURCE_TYPES, case_sensitive=False),
806
+ help=RESOURCE_TYPE_HELP,
807
+ )
808
+ @click.option(
809
+ "--output",
810
+ "-o",
811
+ help="Output file path (default: <name>-config.json)",
812
+ )
813
+ def init_configuration(
814
+ name: Optional[str] = None,
815
+ workspace: Optional[str] = None,
816
+ resource_type: Optional[str] = None,
817
+ output: Optional[str] = None,
818
+ ) -> None:
819
+ """Create a template configuration file for custom fields."""
820
+ try:
821
+ # Prompt for required fields if not provided
822
+ if not name:
823
+ name = click.prompt("Configuration name")
824
+
825
+ if not workspace:
826
+ workspace = click.prompt("Workspace name or ID")
827
+
828
+ if not resource_type:
829
+ resource_type = questionary.select(
830
+ "Resource type?",
831
+ choices=VALID_RESOURCE_TYPES,
832
+ ).ask()
833
+ if resource_type is None:
834
+ raise click.Abort()
835
+
836
+ # Validate resource type (resource_type is guaranteed to be str at this point)
837
+ if resource_type:
838
+ validate_resource_type(resource_type)
839
+
840
+ # Generate output filename if not provided
841
+ if not output:
842
+ safe_name = sanitize_filename(name or "config", "config")
843
+ output = f"{safe_name}-config.json"
844
+
845
+ # Try to resolve workspace name to ID
846
+ try:
847
+ workspace_map = get_workspace_map()
848
+ workspace_id = resolve_workspace_filter(
849
+ get_effective_workspace(workspace) or "", workspace_map
850
+ )
851
+ except Exception:
852
+ workspace_id = get_effective_workspace(workspace) or ""
853
+
854
+ # Create template configuration
855
+ safe_name = sanitize_filename(name or "config", "config")
856
+ import uuid
857
+
858
+ unique_suffix = str(uuid.uuid4())[:8] # Use first 8 chars of UUID for uniqueness
859
+
860
+ template_config = {
861
+ "configurations": [
862
+ {
863
+ "name": name,
864
+ "key": f"{safe_name}-config-{unique_suffix}",
865
+ "workspace": workspace_id,
866
+ "resourceType": resource_type,
867
+ "views": [
868
+ {
869
+ "key": f"default-view-{unique_suffix}",
870
+ "displayText": "Default View",
871
+ "groups": [f"group1-{unique_suffix}"],
872
+ }
873
+ ],
874
+ }
875
+ ],
876
+ "groups": [
877
+ {
878
+ "key": f"group1-{unique_suffix}",
879
+ "workspace": workspace_id,
880
+ "displayText": "Example Group",
881
+ "fields": [f"field1-{unique_suffix}", f"field2-{unique_suffix}"],
882
+ }
883
+ ],
884
+ "fields": [
885
+ {
886
+ "key": f"field1-{unique_suffix}",
887
+ "workspace": workspace_id,
888
+ "displayText": "Example Field",
889
+ "type": "Text",
890
+ "mandatory": False,
891
+ },
892
+ {
893
+ "key": f"field2-{unique_suffix}",
894
+ "workspace": workspace_id,
895
+ "displayText": "Example Field 2",
896
+ "type": "Text",
897
+ "mandatory": False,
898
+ },
899
+ ],
900
+ }
901
+
902
+ save_json_file(template_config, output)
903
+ click.echo(f"✓ Configuration template created: {output}")
904
+ click.echo("Edit the file to customize:")
905
+ click.echo(" - Add/modify groups and fields")
906
+ click.echo(
907
+ " - Set field types (Text, Number, Boolean, Enum, DateTime, Table, LinkedResource)"
908
+ )
909
+ click.echo(" - Configure mandatory/optional fields")
910
+ click.echo(" - Add validation rules and properties as needed")
911
+
912
+ except Exception as exc:
913
+ click.echo(f"✗ Error creating configuration template: {exc}", err=True)
914
+ sys.exit(ExitCodes.GENERAL_ERROR)
915
+
916
+ # Editor command
917
+ @dff.command(name="edit")
918
+ @click.option(
919
+ "--file",
920
+ "-f",
921
+ help="JSON file to edit (will create new if not exists)",
922
+ )
923
+ @click.option(
924
+ "--id",
925
+ "-i",
926
+ "config_id",
927
+ help="Configuration ID to load in the editor",
928
+ )
929
+ @click.option(
930
+ "--port",
931
+ "-p",
932
+ default=8080,
933
+ show_default=True,
934
+ help="Port for local HTTP server",
935
+ )
936
+ @click.option(
937
+ "--no-browser",
938
+ is_flag=True,
939
+ help="Don't automatically open browser",
940
+ )
941
+ def edit_configuration(
942
+ file: Optional[str] = None,
943
+ config_id: Optional[str] = None,
944
+ port: int = 8080,
945
+ no_browser: bool = False,
946
+ ) -> None:
947
+ """Launch a local web editor for custom field configurations.
948
+
949
+ This command starts a web editor for editing custom field configurations.
950
+ You can provide a JSON file to edit, or load a configuration by ID from the server.
951
+ """
952
+ from .utils import check_readonly_mode
953
+
954
+ check_readonly_mode("edit custom field configurations")
955
+
956
+ try:
957
+ # If config_id is provided, fetch and save it to a temporary file
958
+ if config_id:
959
+ url = f"{get_base_url()}/nidynamicformfields/v1/resolved-configuration"
960
+ params = {"configurationId": config_id}
961
+ query_string = urllib.parse.urlencode(params)
962
+ full_url = f"{url}?{query_string}"
963
+
964
+ resp = make_api_request("GET", full_url)
965
+ data = resp.json()
966
+
967
+ # Generate a temporary file for the configuration
968
+ if not file:
969
+ config_name = data.get("configuration", {}).get("name", f"config-{config_id}")
970
+ safe_name = sanitize_filename(config_name, f"config-{config_id}")
971
+ file = f"{safe_name}.json"
972
+
973
+ # Save the fetched configuration to file
974
+ save_json_file(data, file)
975
+ click.echo(f"✓ Configuration loaded from server: {file}")
976
+
977
+ launch_dff_editor(file=file, port=port, open_browser=not no_browser)
978
+ except Exception as exc:
979
+ handle_api_error(exc)