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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/cli_utils.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Common utility functions for all CLI commands."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Dict, List, Optional, Callable
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import questionary
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .utils import ExitCodes, handle_api_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_resource_by_name_or_id(
|
|
14
|
+
session: requests.Session,
|
|
15
|
+
base_url: str,
|
|
16
|
+
resource_type: str,
|
|
17
|
+
identifier: str,
|
|
18
|
+
name_field: str = "name",
|
|
19
|
+
) -> Optional[Dict[str, Any]]:
|
|
20
|
+
"""Resolve a resource by name or ID.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
session: Authenticated requests session
|
|
24
|
+
base_url: Base API URL for the resource
|
|
25
|
+
resource_type: Type of resource (for error messages)
|
|
26
|
+
identifier: Name or ID to search for
|
|
27
|
+
name_field: Field name to search by (default: "name")
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Resource data if found, None otherwise
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
# First try as direct ID lookup
|
|
34
|
+
resp = session.get(f"{base_url}/{identifier}")
|
|
35
|
+
if resp.status_code == 200:
|
|
36
|
+
return resp.json()
|
|
37
|
+
|
|
38
|
+
# If not found by ID, try name-based search
|
|
39
|
+
resp = session.get(base_url)
|
|
40
|
+
if resp.status_code == 200:
|
|
41
|
+
data = resp.json()
|
|
42
|
+
resources = data if isinstance(data, list) else data.get(resource_type, [])
|
|
43
|
+
|
|
44
|
+
# Search by name
|
|
45
|
+
for resource in resources:
|
|
46
|
+
if resource.get(name_field, "").lower() == identifier.lower():
|
|
47
|
+
return resource
|
|
48
|
+
|
|
49
|
+
# Resource not found
|
|
50
|
+
click.echo(f"✗ {resource_type.title()} '{identifier}' not found", err=True)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
handle_api_error(exc)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def confirm_bulk_operation(
|
|
59
|
+
operation: str,
|
|
60
|
+
resource_type: str,
|
|
61
|
+
count: int,
|
|
62
|
+
force: bool = False,
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""Confirm bulk operations with user prompt.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
operation: Operation name (e.g., "delete", "update")
|
|
68
|
+
resource_type: Type of resource
|
|
69
|
+
count: Number of resources affected
|
|
70
|
+
force: Skip confirmation if True
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if confirmed, False otherwise
|
|
74
|
+
"""
|
|
75
|
+
if force:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
if count == 0:
|
|
79
|
+
click.echo(f"No {resource_type}s to {operation}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
if count == 1:
|
|
83
|
+
return click.confirm(f"Are you sure you want to {operation} this {resource_type}?")
|
|
84
|
+
|
|
85
|
+
return click.confirm(f"Are you sure you want to {operation} {count} {resource_type}s?")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_output_format(format_output: str) -> str:
|
|
89
|
+
"""Validate and normalize output format.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
format_output: User-provided format string
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Normalized format string
|
|
96
|
+
"""
|
|
97
|
+
valid_formats = ["table", "json"]
|
|
98
|
+
normalized = format_output.lower().strip()
|
|
99
|
+
|
|
100
|
+
if normalized not in valid_formats:
|
|
101
|
+
click.echo(
|
|
102
|
+
f"✗ Invalid format '{format_output}'. Valid options: {', '.join(valid_formats)}",
|
|
103
|
+
err=True,
|
|
104
|
+
)
|
|
105
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
106
|
+
|
|
107
|
+
return normalized
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def handle_pagination(
|
|
111
|
+
session: requests.Session,
|
|
112
|
+
url: str,
|
|
113
|
+
page_size: int = 100,
|
|
114
|
+
max_items: Optional[int] = None,
|
|
115
|
+
) -> List[Dict[str, Any]]:
|
|
116
|
+
"""Handle paginated API responses.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
session: Authenticated requests session
|
|
120
|
+
url: API endpoint URL
|
|
121
|
+
page_size: Items per page
|
|
122
|
+
max_items: Maximum items to retrieve (None for all)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of all retrieved items
|
|
126
|
+
"""
|
|
127
|
+
all_items: List[Dict[str, Any]] = []
|
|
128
|
+
page = 0
|
|
129
|
+
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
params = {"take": page_size, "skip": page * page_size}
|
|
133
|
+
resp = session.get(url, params=params)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
|
|
136
|
+
data = resp.json()
|
|
137
|
+
items = data if isinstance(data, list) else data.get("items", [])
|
|
138
|
+
|
|
139
|
+
if not items:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
all_items.extend(items)
|
|
143
|
+
|
|
144
|
+
# Check if we've reached max_items limit
|
|
145
|
+
if max_items and len(all_items) >= max_items:
|
|
146
|
+
all_items = all_items[:max_items]
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
# Check if we've reached the end
|
|
150
|
+
if len(items) < page_size:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
page += 1
|
|
154
|
+
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
handle_api_error(exc)
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
return all_items
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def extract_id_from_response(response: requests.Response) -> Optional[str]:
|
|
163
|
+
"""Extract ID from API response.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
response: API response
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Extracted ID if found, None otherwise
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
data = response.json()
|
|
173
|
+
|
|
174
|
+
# Common ID field names
|
|
175
|
+
id_fields = ["id", "resourceId", "workspaceId", "templateId", "userId"]
|
|
176
|
+
|
|
177
|
+
for field in id_fields:
|
|
178
|
+
if field in data:
|
|
179
|
+
return str(data[field])
|
|
180
|
+
|
|
181
|
+
# If direct ID not found, try nested structures
|
|
182
|
+
if "metadata" in data and "id" in data["metadata"]:
|
|
183
|
+
return str(data["metadata"]["id"])
|
|
184
|
+
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
except (ValueError, TypeError, KeyError):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_query_params(
|
|
192
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
193
|
+
sort_by: Optional[str] = None,
|
|
194
|
+
sort_order: str = "asc",
|
|
195
|
+
page_size: Optional[int] = None,
|
|
196
|
+
page: Optional[int] = None,
|
|
197
|
+
) -> Dict[str, Any]:
|
|
198
|
+
"""Build standardized query parameters.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
filters: Filter dictionary
|
|
202
|
+
sort_by: Field to sort by
|
|
203
|
+
sort_order: Sort order ("asc" or "desc")
|
|
204
|
+
page_size: Number of items per page
|
|
205
|
+
page: Page number (0-based)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Query parameters dictionary
|
|
209
|
+
"""
|
|
210
|
+
params = {}
|
|
211
|
+
|
|
212
|
+
if filters:
|
|
213
|
+
for key, value in filters.items():
|
|
214
|
+
if value is not None:
|
|
215
|
+
params[key] = value
|
|
216
|
+
|
|
217
|
+
if sort_by:
|
|
218
|
+
params["sortBy"] = sort_by
|
|
219
|
+
params["sortOrder"] = sort_order
|
|
220
|
+
|
|
221
|
+
if page_size is not None:
|
|
222
|
+
params["take"] = page_size
|
|
223
|
+
|
|
224
|
+
if page is not None:
|
|
225
|
+
params["skip"] = page * (page_size or 100)
|
|
226
|
+
|
|
227
|
+
return params
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def format_error_message(
|
|
231
|
+
operation: str,
|
|
232
|
+
resource_type: str,
|
|
233
|
+
identifier: Optional[str] = None,
|
|
234
|
+
details: Optional[str] = None,
|
|
235
|
+
) -> str:
|
|
236
|
+
"""Format standardized error messages.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
operation: Operation that failed
|
|
240
|
+
resource_type: Type of resource
|
|
241
|
+
identifier: Resource identifier (optional)
|
|
242
|
+
details: Additional error details (optional)
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Formatted error message
|
|
246
|
+
"""
|
|
247
|
+
base_message = f"✗ Failed to {operation} {resource_type}"
|
|
248
|
+
|
|
249
|
+
if identifier:
|
|
250
|
+
base_message += f" '{identifier}'"
|
|
251
|
+
|
|
252
|
+
if details:
|
|
253
|
+
base_message += f": {details}"
|
|
254
|
+
|
|
255
|
+
return base_message
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def validate_required_fields(
|
|
259
|
+
data: Dict[str, Any],
|
|
260
|
+
required_fields: List[str],
|
|
261
|
+
resource_type: str,
|
|
262
|
+
) -> bool:
|
|
263
|
+
"""Validate that required fields are present in data.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
data: Data dictionary to validate
|
|
267
|
+
required_fields: List of required field names
|
|
268
|
+
resource_type: Type of resource (for error messages)
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
True if all required fields present, False otherwise
|
|
272
|
+
"""
|
|
273
|
+
missing_fields = []
|
|
274
|
+
|
|
275
|
+
for field in required_fields:
|
|
276
|
+
if field not in data or data[field] is None:
|
|
277
|
+
missing_fields.append(field)
|
|
278
|
+
|
|
279
|
+
if missing_fields:
|
|
280
|
+
fields_str = ", ".join(missing_fields)
|
|
281
|
+
click.echo(
|
|
282
|
+
f"✗ Missing required fields for {resource_type}: {fields_str}",
|
|
283
|
+
err=True,
|
|
284
|
+
)
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def safe_get_nested(
|
|
291
|
+
data: Dict[str, Any],
|
|
292
|
+
path: str,
|
|
293
|
+
default: Any = None,
|
|
294
|
+
separator: str = ".",
|
|
295
|
+
) -> Any:
|
|
296
|
+
"""Safely get nested dictionary values.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
data: Dictionary to search
|
|
300
|
+
path: Dot-separated path (e.g., "metadata.tags.name")
|
|
301
|
+
default: Default value if path not found
|
|
302
|
+
separator: Path separator character
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Value at path or default
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
keys = path.split(separator)
|
|
309
|
+
value = data
|
|
310
|
+
|
|
311
|
+
for key in keys:
|
|
312
|
+
value = value[key]
|
|
313
|
+
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
except (KeyError, TypeError, AttributeError):
|
|
317
|
+
return default
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def truncate_string(text: str, max_length: int = 50, suffix: str = "...") -> str:
|
|
321
|
+
"""Truncate string for table display.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
text: Text to truncate
|
|
325
|
+
max_length: Maximum length
|
|
326
|
+
suffix: Suffix for truncated text
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Truncated string
|
|
330
|
+
"""
|
|
331
|
+
if not text or len(text) <= max_length:
|
|
332
|
+
return text or ""
|
|
333
|
+
|
|
334
|
+
return text[: max_length - len(suffix)] + suffix
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def paginate_list_output(
|
|
338
|
+
items: List[Dict[str, Any]],
|
|
339
|
+
page_size: int = 25,
|
|
340
|
+
format_output: str = "table",
|
|
341
|
+
formatter_func: Optional[Callable[[Dict[str, Any]], List[str]]] = None,
|
|
342
|
+
headers: Optional[List[str]] = None,
|
|
343
|
+
column_widths: Optional[List[int]] = None,
|
|
344
|
+
empty_message: str = "No items found.",
|
|
345
|
+
total_label: str = "item(s)",
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Paginate list output with user prompts to continue.
|
|
348
|
+
|
|
349
|
+
For table format, shows pages of results with user prompts.
|
|
350
|
+
For JSON format, shows all results at once (no pagination).
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
items: List of items to paginate
|
|
354
|
+
page_size: Number of items per page (default: 25)
|
|
355
|
+
format_output: 'json' or 'table'
|
|
356
|
+
formatter_func: Function to format table rows
|
|
357
|
+
headers: Table headers
|
|
358
|
+
column_widths: Table column widths
|
|
359
|
+
empty_message: Message when no items found
|
|
360
|
+
total_label: Label for count display
|
|
361
|
+
"""
|
|
362
|
+
if not items:
|
|
363
|
+
if format_output.lower() == "json":
|
|
364
|
+
click.echo("[]")
|
|
365
|
+
else:
|
|
366
|
+
click.echo(empty_message)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# For JSON format, show all results at once (no pagination)
|
|
370
|
+
if format_output.lower() == "json":
|
|
371
|
+
import json
|
|
372
|
+
|
|
373
|
+
click.echo(json.dumps(items, indent=2))
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
# Table format with pagination
|
|
377
|
+
total_items = len(items)
|
|
378
|
+
current_page = 0
|
|
379
|
+
items_shown = 0
|
|
380
|
+
|
|
381
|
+
while items_shown < total_items:
|
|
382
|
+
# Calculate slice for current page
|
|
383
|
+
start_idx = current_page * page_size
|
|
384
|
+
end_idx = min(start_idx + page_size, total_items)
|
|
385
|
+
page_items = items[start_idx:end_idx]
|
|
386
|
+
|
|
387
|
+
# Show the page using table_utils
|
|
388
|
+
if formatter_func and headers and column_widths:
|
|
389
|
+
# For individual pages, don't show the total count footer
|
|
390
|
+
# We'll handle that in our pagination logic
|
|
391
|
+
_output_formatted_page(
|
|
392
|
+
page_items,
|
|
393
|
+
headers,
|
|
394
|
+
column_widths,
|
|
395
|
+
formatter_func,
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
# Fallback to simple display
|
|
399
|
+
for item in page_items:
|
|
400
|
+
click.echo(str(item))
|
|
401
|
+
|
|
402
|
+
items_shown = end_idx
|
|
403
|
+
|
|
404
|
+
# Show pagination info and prompt if there are more items
|
|
405
|
+
if items_shown < total_items:
|
|
406
|
+
remaining = total_items - items_shown
|
|
407
|
+
click.echo(
|
|
408
|
+
f"\nShowing {items_shown} of {total_items} {total_label}. "
|
|
409
|
+
f"{remaining} more available."
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Check if we're in an interactive environment
|
|
413
|
+
# If stdin is not a TTY or we're in a test environment, disable interactive pagination
|
|
414
|
+
import os
|
|
415
|
+
import sys
|
|
416
|
+
|
|
417
|
+
is_non_interactive = (
|
|
418
|
+
not sys.stdin.isatty() # Piped input
|
|
419
|
+
or not sys.stdout.isatty() # Piped output
|
|
420
|
+
or os.getenv("CI") == "true" # CI environment
|
|
421
|
+
or os.getenv("PYTEST_CURRENT_TEST") is not None # pytest
|
|
422
|
+
or os.getenv("SLCLI_NON_INTERACTIVE") == "true" # Explicit override
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if is_non_interactive:
|
|
426
|
+
click.echo("Non-interactive environment detected. Showing all remaining results...")
|
|
427
|
+
# Continue to show all remaining pages without prompting
|
|
428
|
+
current_page += 1
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
if not questionary.confirm("Show next 25 results?", default=True).ask():
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
click.echo() # Add blank line between pages
|
|
435
|
+
current_page += 1
|
|
436
|
+
else:
|
|
437
|
+
# Show final count
|
|
438
|
+
click.echo(f"\nTotal: {total_items} {total_label}")
|
|
439
|
+
break
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _output_formatted_page(
|
|
443
|
+
items: List[Dict[str, Any]],
|
|
444
|
+
headers: List[str],
|
|
445
|
+
column_widths: List[int],
|
|
446
|
+
row_formatter_func: Callable[[Dict[str, Any]], List[str]],
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Output a single page of formatted table data without footer.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
items: List of items to output
|
|
452
|
+
headers: List of header names for table output
|
|
453
|
+
column_widths: List of column widths for table formatting
|
|
454
|
+
row_formatter_func: Function that converts item to list of column values
|
|
455
|
+
"""
|
|
456
|
+
if not items:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
# Table format with box-drawing characters (without footer)
|
|
460
|
+
if len(headers) != len(column_widths):
|
|
461
|
+
raise ValueError("Headers and column_widths must have the same length")
|
|
462
|
+
|
|
463
|
+
# Top border
|
|
464
|
+
border_chars = ["┌"] + [("─" * (w + 2)) for w in column_widths]
|
|
465
|
+
border_line = border_chars[0] + border_chars[1]
|
|
466
|
+
for part in border_chars[2:]:
|
|
467
|
+
border_line += "┬" + part
|
|
468
|
+
border_line += "┐"
|
|
469
|
+
click.echo(border_line)
|
|
470
|
+
|
|
471
|
+
# Header row
|
|
472
|
+
header_parts = ["│"]
|
|
473
|
+
for header, width in zip(headers, column_widths):
|
|
474
|
+
header_parts.append(f" {header:<{width}} │")
|
|
475
|
+
click.echo("".join(header_parts))
|
|
476
|
+
|
|
477
|
+
# Middle border
|
|
478
|
+
border_chars = ["├"] + [("─" * (w + 2)) for w in column_widths]
|
|
479
|
+
border_line = border_chars[0] + border_chars[1]
|
|
480
|
+
for part in border_chars[2:]:
|
|
481
|
+
border_line += "┼" + part
|
|
482
|
+
border_line += "┤"
|
|
483
|
+
click.echo(border_line)
|
|
484
|
+
|
|
485
|
+
# Data rows
|
|
486
|
+
for item in items:
|
|
487
|
+
row_data = row_formatter_func(item)
|
|
488
|
+
if len(row_data) != len(column_widths):
|
|
489
|
+
raise ValueError("Row data must match column count")
|
|
490
|
+
|
|
491
|
+
row_parts = ["│"]
|
|
492
|
+
for value, width in zip(row_data, column_widths):
|
|
493
|
+
# Truncate if necessary
|
|
494
|
+
str_value = str(value or "")[:width]
|
|
495
|
+
row_parts.append(f" {str_value:<{width}} │")
|
|
496
|
+
click.echo("".join(row_parts))
|
|
497
|
+
|
|
498
|
+
# Bottom border
|
|
499
|
+
border_chars = ["└"] + [("─" * (w + 2)) for w in column_widths]
|
|
500
|
+
border_line = border_chars[0] + border_chars[1]
|
|
501
|
+
for part in border_chars[2:]:
|
|
502
|
+
border_line += "┴" + part
|
|
503
|
+
border_line += "┘"
|
|
504
|
+
click.echo(border_line)
|