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/file_click.py ADDED
@@ -0,0 +1,932 @@
1
+ """CLI commands for managing SystemLink files."""
2
+
3
+ import json
4
+ import shutil
5
+ import sys
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import click
12
+ import questionary
13
+
14
+ from .cli_utils import validate_output_format
15
+ from .universal_handlers import UniversalResponseHandler
16
+ from .utils import (
17
+ ExitCodes,
18
+ format_success,
19
+ get_base_url,
20
+ get_workspace_map,
21
+ handle_api_error,
22
+ make_api_request,
23
+ )
24
+ from .workspace_utils import get_effective_workspace, resolve_workspace_filter
25
+
26
+
27
+ def _get_file_service_url() -> str:
28
+ """Get the file service base URL."""
29
+ return f"{get_base_url()}/nifile/v1/service-groups/Default"
30
+
31
+
32
+ def _format_file_size(size_bytes: Optional[int]) -> str:
33
+ """Format file size in human-readable format.
34
+
35
+ Args:
36
+ size_bytes: Size in bytes
37
+
38
+ Returns:
39
+ Human-readable size string
40
+ """
41
+ if size_bytes is None:
42
+ return "N/A"
43
+
44
+ size: float = float(size_bytes)
45
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
46
+ if size < 1024:
47
+ return f"{size:.1f} {unit}"
48
+ size /= 1024
49
+ return f"{size:.1f} PB"
50
+
51
+
52
+ def _format_timestamp(timestamp: Optional[str]) -> str:
53
+ """Format ISO timestamp to readable format.
54
+
55
+ Args:
56
+ timestamp: ISO format timestamp string
57
+
58
+ Returns:
59
+ Formatted date string or N/A
60
+ """
61
+ if not timestamp:
62
+ return "N/A"
63
+ try:
64
+ # Parse ISO format and return just the date/time part
65
+ return timestamp[:19].replace("T", " ")
66
+ except Exception:
67
+ return timestamp
68
+
69
+
70
+ def _get_file_name(file_item: dict) -> str:
71
+ """Extract file name from file metadata.
72
+
73
+ The API stores the filename in properties['Name'].
74
+
75
+ Args:
76
+ file_item: File metadata dictionary
77
+
78
+ Returns:
79
+ File name or 'Unknown'
80
+ """
81
+ properties = file_item.get("properties", {})
82
+ return properties.get("Name", "Unknown")
83
+
84
+
85
+ def _get_file_size(file_item: dict) -> Optional[int]:
86
+ """Get file size, preferring size64 for large files.
87
+
88
+ Args:
89
+ file_item: File metadata dictionary
90
+
91
+ Returns:
92
+ File size in bytes or None
93
+ """
94
+ size64 = file_item.get("size64")
95
+ if size64 is not None:
96
+ return size64
97
+ size = file_item.get("size")
98
+ # size == -1 means the file is larger than 32-bit int
99
+ if size is not None and size >= 0:
100
+ return size
101
+ return None
102
+
103
+
104
+ def _get_file_by_id(file_id: str) -> Optional[dict]:
105
+ """Get file metadata by ID using query-files-linq endpoint.
106
+
107
+ The API doesn't have a GET /files/{id} endpoint, so we use
108
+ query-files-linq with an ID filter instead.
109
+
110
+ Args:
111
+ file_id: The file ID to look up
112
+
113
+ Returns:
114
+ File metadata dictionary or None if not found
115
+ """
116
+ url = f"{_get_file_service_url()}/query-files-linq"
117
+ payload = {
118
+ "filter": f'id = "{file_id}"',
119
+ "take": 1,
120
+ }
121
+ resp = make_api_request("POST", url, payload=payload)
122
+ data = resp.json()
123
+ files = data.get("availableFiles", [])
124
+ if files:
125
+ return files[0]
126
+ return None
127
+
128
+
129
+ def register_file_commands(cli: Any) -> None:
130
+ """Register the 'file' command group and its subcommands."""
131
+
132
+ @cli.group()
133
+ def file() -> None:
134
+ """Manage files in SystemLink File Service."""
135
+ pass
136
+
137
+ @file.command(name="list")
138
+ @click.option(
139
+ "--format",
140
+ "-f",
141
+ type=click.Choice(["table", "json"]),
142
+ default="table",
143
+ show_default=True,
144
+ help="Output format",
145
+ )
146
+ @click.option(
147
+ "--take",
148
+ "-t",
149
+ type=int,
150
+ default=25,
151
+ show_default=True,
152
+ help="Maximum number of files to return",
153
+ )
154
+ @click.option(
155
+ "--workspace",
156
+ "-w",
157
+ help="Filter by workspace name or ID",
158
+ )
159
+ @click.option(
160
+ "--id-filter",
161
+ help="Filter by file IDs (comma-separated)",
162
+ )
163
+ @click.option(
164
+ "--filter",
165
+ "name_filter",
166
+ help="Filter by file name or extension (contains search)",
167
+ )
168
+ def list_files(
169
+ format: str = "table",
170
+ take: int = 25,
171
+ workspace: Optional[str] = None,
172
+ id_filter: Optional[str] = None,
173
+ name_filter: Optional[str] = None,
174
+ ) -> None:
175
+ """List files.
176
+
177
+ Use --filter to search for files by name or extension.
178
+ """
179
+ format_output = validate_output_format(format)
180
+
181
+ # Use search-files endpoint for better performance
182
+ url = f"{_get_file_service_url()}/search-files"
183
+
184
+ try:
185
+ # Resolve workspace name to ID if needed
186
+ workspace_id = None
187
+ workspace = get_effective_workspace(workspace)
188
+ if workspace:
189
+ workspace_map = get_workspace_map()
190
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
191
+
192
+ # Build search filter
193
+ filter_parts = []
194
+
195
+ if workspace_id:
196
+ filter_parts.append(f'workspaceId:("{workspace_id}")')
197
+
198
+ if id_filter:
199
+ # Split comma-separated IDs
200
+ ids = [f'"{id.strip()}"' for id in id_filter.split(",")]
201
+ id_list = " OR ".join(ids)
202
+ filter_parts.append(f"id:({id_list})")
203
+
204
+ if name_filter:
205
+ # Search by name or extension contains using wildcard syntax
206
+ filter_parts.append(f'(name:("*{name_filter}*") OR extension:("*{name_filter}*"))')
207
+
208
+ # For JSON format, respect the take parameter exactly
209
+ if format_output.lower() == "json":
210
+ api_take = take
211
+ else:
212
+ api_take = take if take != 25 else 1000
213
+
214
+ # Build request payload for search-files endpoint
215
+ payload: Dict[str, Any] = {
216
+ "take": api_take,
217
+ "orderBy": "updated",
218
+ "orderByDescending": True,
219
+ }
220
+
221
+ if filter_parts:
222
+ payload["filter"] = " AND ".join(filter_parts)
223
+
224
+ resp = make_api_request("POST", url, payload=payload)
225
+
226
+ def file_formatter(file_item: dict) -> list:
227
+ name = _get_file_name(file_item)
228
+ file_id = file_item.get("id", "")
229
+ size = _format_file_size(_get_file_size(file_item))
230
+ created = _format_timestamp(file_item.get("created"))
231
+ return [name, file_id, size, created]
232
+
233
+ UniversalResponseHandler.handle_list_response(
234
+ resp=resp,
235
+ data_key="availableFiles",
236
+ item_name="file",
237
+ format_output=format_output,
238
+ formatter_func=file_formatter,
239
+ headers=["Name", "ID", "Size", "Created"],
240
+ column_widths=[35, 36, 12, 20],
241
+ empty_message="No files found.",
242
+ enable_pagination=True,
243
+ page_size=take,
244
+ )
245
+
246
+ except Exception as exc:
247
+ handle_api_error(exc)
248
+
249
+ @file.command(name="get")
250
+ @click.argument("file_id")
251
+ @click.option(
252
+ "--format",
253
+ "-f",
254
+ type=click.Choice(["table", "json"]),
255
+ default="table",
256
+ show_default=True,
257
+ help="Output format",
258
+ )
259
+ def get_file(file_id: str, format: str = "table") -> None:
260
+ """Show metadata for a file.
261
+
262
+ FILE_ID is the unique identifier of the file.
263
+ """
264
+ format_output = validate_output_format(format)
265
+
266
+ try:
267
+ data = _get_file_by_id(file_id)
268
+
269
+ if data is None:
270
+ click.echo(f"✗ File not found: {file_id}", err=True)
271
+ sys.exit(ExitCodes.NOT_FOUND)
272
+
273
+ if format_output.lower() == "json":
274
+ click.echo(json.dumps(data, indent=2))
275
+ else:
276
+ # Display file metadata in a readable format
277
+ file_name = _get_file_name(data)
278
+ click.echo(f"\n{'=' * 60}")
279
+ click.echo(f" File: {file_name}")
280
+ click.echo(f"{'=' * 60}")
281
+ click.echo(f" ID: {data.get('id', 'N/A')}")
282
+ click.echo(f" Size: {_format_file_size(_get_file_size(data))}")
283
+ click.echo(f" Created: {_format_timestamp(data.get('created'))}")
284
+ click.echo(f" Workspace: {data.get('workspace', 'N/A')}")
285
+ click.echo(f" Service Grp: {data.get('serviceGroup', 'N/A')}")
286
+
287
+ # Show properties if present
288
+ properties = data.get("properties", {})
289
+ if properties:
290
+ click.echo(f"\n Properties:")
291
+ for key, value in properties.items():
292
+ click.echo(f" {key}: {value}")
293
+
294
+ click.echo(f"{'=' * 60}\n")
295
+
296
+ except Exception as exc:
297
+ handle_api_error(exc)
298
+
299
+ @file.command(name="upload")
300
+ @click.argument("file_path", type=click.Path(exists=True))
301
+ @click.option(
302
+ "--workspace",
303
+ "-w",
304
+ help="Target workspace name or ID",
305
+ )
306
+ @click.option(
307
+ "--name",
308
+ "-n",
309
+ help="Custom name for the uploaded file (defaults to filename)",
310
+ )
311
+ @click.option(
312
+ "--properties",
313
+ "-p",
314
+ help="JSON string of properties to attach to the file",
315
+ )
316
+ def upload_file(
317
+ file_path: str,
318
+ workspace: Optional[str] = None,
319
+ name: Optional[str] = None,
320
+ properties: Optional[str] = None,
321
+ ) -> None:
322
+ """Upload a file.
323
+
324
+ FILE_PATH is the local path to the file to upload.
325
+ """
326
+ from .utils import check_readonly_mode
327
+
328
+ check_readonly_mode("upload a file")
329
+
330
+ file_path_obj = Path(file_path)
331
+ file_name = name if name else file_path_obj.name
332
+ file_size = file_path_obj.stat().st_size
333
+
334
+ url = f"{_get_file_service_url()}/upload-files"
335
+
336
+ # Resolve workspace name to ID and build query string
337
+ workspace = get_effective_workspace(workspace)
338
+ if workspace:
339
+ workspace_map = get_workspace_map()
340
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
341
+ url += f"?workspace={workspace_id}"
342
+
343
+ try:
344
+ # Parse properties if provided
345
+ props_dict: Dict[str, str] = {}
346
+ if properties:
347
+ try:
348
+ props_dict = json.loads(properties)
349
+ except json.JSONDecodeError as e:
350
+ click.echo(f"✗ Invalid JSON for properties: {e}", err=True)
351
+ sys.exit(ExitCodes.INVALID_INPUT)
352
+
353
+ # Build metadata for the file - Name goes in properties
354
+ # The API stores filename in properties['Name']
355
+ metadata: Dict[str, Any] = {
356
+ "Name": file_name,
357
+ }
358
+ # Merge any additional properties
359
+ metadata.update(props_dict)
360
+
361
+ # Prepare multipart form data
362
+ # The API expects 'file' field for file content and metadata as JSON
363
+ with open(file_path, "rb") as f:
364
+ files = {
365
+ "file": (file_name, f, "application/octet-stream"),
366
+ }
367
+ # Add metadata as form field (JSON dict of key/value pairs)
368
+ data = {
369
+ "metadata": json.dumps(metadata),
370
+ }
371
+
372
+ # Make request with multipart form data
373
+ resp = make_api_request("POST", url, payload=None, files=files, data=data)
374
+
375
+ result = resp.json()
376
+
377
+ # The API returns a URI like '/nifile/v1/service-groups/Default/files/{id}'
378
+ # Extract the file ID from the URI
379
+ uri = result.get("uri", "")
380
+ file_id = uri.split("/")[-1] if uri else "N/A"
381
+
382
+ format_success(
383
+ "File uploaded successfully",
384
+ {
385
+ "ID": file_id,
386
+ "Name": file_name,
387
+ "Size": _format_file_size(file_size),
388
+ },
389
+ )
390
+
391
+ except Exception as exc:
392
+ handle_api_error(exc)
393
+
394
+ @file.command(name="download")
395
+ @click.argument("file_id")
396
+ @click.option(
397
+ "--output",
398
+ "-o",
399
+ type=click.Path(),
400
+ help="Output file path (defaults to original filename in current directory)",
401
+ )
402
+ @click.option(
403
+ "--force",
404
+ is_flag=True,
405
+ help="Overwrite existing file without prompting",
406
+ )
407
+ def download_file(
408
+ file_id: str,
409
+ output: Optional[str] = None,
410
+ force: bool = False,
411
+ ) -> None:
412
+ """Download a file.
413
+
414
+ FILE_ID is the unique identifier of the file to download.
415
+ """
416
+ try:
417
+ # First get file metadata to determine filename
418
+ metadata = _get_file_by_id(file_id)
419
+ if metadata is None:
420
+ click.echo(f"✗ File not found: {file_id}", err=True)
421
+ sys.exit(ExitCodes.NOT_FOUND)
422
+
423
+ original_name = _get_file_name(metadata)
424
+ if original_name == "Unknown":
425
+ original_name = f"file_{file_id}"
426
+
427
+ # Determine output path
428
+ if output:
429
+ output_path = Path(output)
430
+ else:
431
+ output_path = Path.cwd() / original_name
432
+
433
+ # Check if file exists
434
+ if output_path.exists() and not force:
435
+ if not questionary.confirm(
436
+ f"File '{output_path}' already exists. Overwrite?",
437
+ default=False,
438
+ ).ask():
439
+ click.echo("Download cancelled.")
440
+ sys.exit(ExitCodes.SUCCESS)
441
+
442
+ # Download file content
443
+ download_url = f"{_get_file_service_url()}/files/{file_id}/data"
444
+ resp = make_api_request("GET", download_url, payload=None, stream=True)
445
+
446
+ # Write to file
447
+ with open(output_path, "wb") as f:
448
+ for chunk in resp.iter_content(chunk_size=8192):
449
+ if chunk:
450
+ f.write(chunk)
451
+
452
+ format_success(
453
+ "File downloaded successfully",
454
+ {
455
+ "Path": str(output_path),
456
+ "Size": _format_file_size(output_path.stat().st_size),
457
+ },
458
+ )
459
+
460
+ except Exception as exc:
461
+ handle_api_error(exc)
462
+
463
+ @file.command(name="delete")
464
+ @click.option(
465
+ "--id",
466
+ "-i",
467
+ "file_id",
468
+ required=True,
469
+ help="File ID to delete",
470
+ )
471
+ @click.option(
472
+ "--force",
473
+ is_flag=True,
474
+ help="Delete without confirmation",
475
+ )
476
+ def delete_file(file_id: str, force: bool = False) -> None:
477
+ """Delete a file."""
478
+ from .utils import check_readonly_mode
479
+
480
+ check_readonly_mode("delete a file")
481
+
482
+ try:
483
+ # Get file info first using query (no GET endpoint exists)
484
+ metadata = _get_file_by_id(file_id)
485
+ if metadata is None:
486
+ click.echo(f"✗ File not found: {file_id}", err=True)
487
+ sys.exit(ExitCodes.NOT_FOUND)
488
+
489
+ file_name = _get_file_name(metadata)
490
+ if file_name == "Unknown":
491
+ file_name = file_id
492
+
493
+ if not force:
494
+ if not questionary.confirm(
495
+ f"Are you sure you want to delete '{file_name}'?",
496
+ default=False,
497
+ ).ask():
498
+ click.echo("Delete cancelled.")
499
+ sys.exit(ExitCodes.SUCCESS)
500
+
501
+ # Delete the file
502
+ delete_url = f"{_get_file_service_url()}/files/{file_id}"
503
+ make_api_request("DELETE", delete_url, payload=None)
504
+
505
+ format_success("File deleted", {"Name": file_name, "ID": file_id})
506
+
507
+ except Exception as exc:
508
+ handle_api_error(exc)
509
+
510
+ @file.command(name="query")
511
+ @click.option(
512
+ "--format",
513
+ "-f",
514
+ type=click.Choice(["table", "json"]),
515
+ default="table",
516
+ show_default=True,
517
+ help="Output format",
518
+ )
519
+ @click.option(
520
+ "--take",
521
+ "-t",
522
+ type=int,
523
+ default=25,
524
+ show_default=True,
525
+ help="Maximum number of files to return",
526
+ )
527
+ @click.option(
528
+ "--filter",
529
+ "filter_query",
530
+ help="Search filter expression (e.g., 'name:(\"*test*\")')",
531
+ )
532
+ @click.option(
533
+ "--order-by",
534
+ help="Order by field (e.g., 'updated', 'created', 'name')",
535
+ )
536
+ @click.option(
537
+ "--descending/--ascending",
538
+ default=True,
539
+ help="Sort order (default: descending)",
540
+ )
541
+ @click.option(
542
+ "--workspace",
543
+ "-w",
544
+ help="Filter by workspace name or ID",
545
+ )
546
+ def query_files(
547
+ format: str = "table",
548
+ take: int = 25,
549
+ filter_query: Optional[str] = None,
550
+ order_by: Optional[str] = None,
551
+ descending: bool = True,
552
+ workspace: Optional[str] = None,
553
+ ) -> None:
554
+ """Search files with a query.
555
+
556
+ Filter syntax uses field:(value) format with wildcards:
557
+
558
+ \b
559
+ - name:("*test*") Files with 'test' in the name
560
+ - extension:("csv") Files with .csv extension
561
+ - workspaceId:("id") Files in a specific workspace
562
+ - id:("file-id") Files with specific ID
563
+
564
+ \b
565
+ Combine filters with AND/OR:
566
+ - name:("*test*") AND extension:("csv")
567
+ """
568
+ format_output = validate_output_format(format)
569
+
570
+ # Use search-files endpoint for better performance
571
+ url = f"{_get_file_service_url()}/search-files"
572
+
573
+ try:
574
+ # Build request body
575
+ query_body: Dict[str, Any] = {
576
+ "take": take if format_output.lower() == "json" else (take if take != 25 else 1000),
577
+ "orderByDescending": descending,
578
+ }
579
+
580
+ # Build filter parts
581
+ filter_parts = []
582
+
583
+ if filter_query:
584
+ filter_parts.append(filter_query)
585
+
586
+ # Resolve workspace name to ID if needed
587
+ workspace = get_effective_workspace(workspace)
588
+ if workspace:
589
+ workspace_map = get_workspace_map()
590
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
591
+ filter_parts.append(f'workspaceId:("{workspace_id}")')
592
+
593
+ if filter_parts:
594
+ query_body["filter"] = " AND ".join(filter_parts)
595
+
596
+ if order_by:
597
+ query_body["orderBy"] = order_by
598
+ else:
599
+ query_body["orderBy"] = "updated"
600
+
601
+ resp = make_api_request("POST", url, payload=query_body)
602
+
603
+ def file_formatter(file_item: dict) -> list:
604
+ name = _get_file_name(file_item)
605
+ file_id = file_item.get("id", "")
606
+ size = _format_file_size(_get_file_size(file_item))
607
+ created = _format_timestamp(file_item.get("created"))
608
+ return [name, file_id, size, created]
609
+
610
+ UniversalResponseHandler.handle_list_response(
611
+ resp=resp,
612
+ data_key="availableFiles",
613
+ item_name="file",
614
+ format_output=format_output,
615
+ formatter_func=file_formatter,
616
+ headers=["Name", "ID", "Size", "Created"],
617
+ column_widths=[35, 36, 12, 20],
618
+ empty_message="No files match the query.",
619
+ enable_pagination=True,
620
+ page_size=take,
621
+ )
622
+
623
+ except Exception as exc:
624
+ handle_api_error(exc)
625
+
626
+ @file.command(name="update-metadata")
627
+ @click.argument("file_id")
628
+ @click.option(
629
+ "--name",
630
+ "-n",
631
+ help="New name for the file",
632
+ )
633
+ @click.option(
634
+ "--properties",
635
+ "-p",
636
+ help="JSON string of properties to set (replaces existing)",
637
+ )
638
+ @click.option(
639
+ "--add-property",
640
+ multiple=True,
641
+ help="Add/update a property (format: key=value). Can be used multiple times.",
642
+ )
643
+ def update_metadata(
644
+ file_id: str,
645
+ name: Optional[str] = None,
646
+ properties: Optional[str] = None,
647
+ add_property: tuple = (),
648
+ ) -> None:
649
+ """Update file metadata.
650
+
651
+ FILE_ID is the unique identifier of the file to update.
652
+ """
653
+ from .utils import check_readonly_mode
654
+
655
+ check_readonly_mode("update file metadata")
656
+
657
+ update_url = f"{_get_file_service_url()}/files/{file_id}/update-metadata"
658
+
659
+ try:
660
+ # Get current metadata using query endpoint
661
+ current_data = _get_file_by_id(file_id)
662
+ if current_data is None:
663
+ click.echo(f"✗ File not found: {file_id}", err=True)
664
+ sys.exit(ExitCodes.NOT_FOUND)
665
+
666
+ current_props = current_data.get("properties", {}).copy()
667
+
668
+ # Build update payload - API requires replaceExisting and properties
669
+ update_props: Dict[str, str] = {}
670
+
671
+ # Handle properties first, then name (so --name takes precedence)
672
+ if properties:
673
+ try:
674
+ props_input = json.loads(properties)
675
+ update_props.update(props_input)
676
+ except json.JSONDecodeError as e:
677
+ click.echo(f"✗ Invalid JSON for properties: {e}", err=True)
678
+ sys.exit(ExitCodes.INVALID_INPUT)
679
+ # If renaming, set the Name property after properties (takes precedence)
680
+ if name:
681
+ update_props["Name"] = name
682
+ elif add_property:
683
+ # Start with existing properties and add/update
684
+ update_props = current_props.copy()
685
+ if name:
686
+ update_props["Name"] = name
687
+ for prop in add_property:
688
+ if "=" in prop:
689
+ key, value = prop.split("=", 1)
690
+ update_props[key.strip()] = value.strip()
691
+ else:
692
+ click.echo(f"✗ Invalid property format: {prop}. Use key=value", err=True)
693
+ sys.exit(ExitCodes.INVALID_INPUT)
694
+ elif name:
695
+ # Just renaming - merge with existing properties
696
+ update_props = current_props.copy()
697
+ update_props["Name"] = name
698
+
699
+ if not update_props:
700
+ click.echo("✗ No updates specified. Use --name, --properties, or --add-property.")
701
+ sys.exit(ExitCodes.INVALID_INPUT)
702
+
703
+ # Build the request body per API spec
704
+ update_body: Dict[str, Any] = {
705
+ "replaceExisting": True,
706
+ "properties": update_props,
707
+ }
708
+
709
+ # Update the file metadata using POST to /update-metadata endpoint
710
+ make_api_request("POST", update_url, payload=update_body)
711
+
712
+ format_success(
713
+ "File metadata updated",
714
+ {"ID": file_id, "Name": update_props.get("Name", current_props.get("Name", "N/A"))},
715
+ )
716
+
717
+ except Exception as exc:
718
+ handle_api_error(exc)
719
+
720
+ @file.command(name="watch")
721
+ @click.argument("watch_dir", type=click.Path(exists=True, file_okay=False))
722
+ @click.option(
723
+ "--workspace",
724
+ "-w",
725
+ help="Target workspace name or ID for uploaded files",
726
+ )
727
+ @click.option(
728
+ "--move-to",
729
+ type=click.Path(file_okay=False),
730
+ help="Directory to move files after successful upload",
731
+ )
732
+ @click.option(
733
+ "--delete-after-upload",
734
+ is_flag=True,
735
+ help="Delete files after successful upload (mutually exclusive with --move-to)",
736
+ )
737
+ @click.option(
738
+ "--pattern",
739
+ "-p",
740
+ default="*",
741
+ show_default=True,
742
+ help="Glob pattern for files to watch (e.g., '*.csv')",
743
+ )
744
+ @click.option(
745
+ "--debounce",
746
+ type=float,
747
+ default=1.0,
748
+ show_default=True,
749
+ help="Seconds to wait before uploading (debounce file writes)",
750
+ )
751
+ @click.option(
752
+ "--recursive",
753
+ "-r",
754
+ is_flag=True,
755
+ help="Watch subdirectories recursively",
756
+ )
757
+ def watch_folder(
758
+ watch_dir: str,
759
+ workspace: Optional[str] = None,
760
+ move_to: Optional[str] = None,
761
+ delete_after_upload: bool = False,
762
+ pattern: str = "*",
763
+ debounce: float = 1.0,
764
+ recursive: bool = False,
765
+ ) -> None:
766
+ """Watch a folder and auto-upload new files.
767
+
768
+ WATCH_DIR is the directory to watch for new files.
769
+
770
+ Files upload when created or modified. Use --move-to to move files after upload,
771
+ or --delete-after-upload to remove them.
772
+ """
773
+ # Validate mutual exclusivity
774
+ if move_to and delete_after_upload:
775
+ click.echo("✗ Cannot use both --move-to and --delete-after-upload", err=True)
776
+ sys.exit(ExitCodes.INVALID_INPUT)
777
+
778
+ # Resolve workspace name to ID if needed
779
+ workspace_id: Optional[str] = None
780
+ workspace = get_effective_workspace(workspace)
781
+ if workspace:
782
+ workspace_map = get_workspace_map()
783
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
784
+
785
+ # Create move-to directory if specified
786
+ if move_to:
787
+ move_to_path = Path(move_to)
788
+ move_to_path.mkdir(parents=True, exist_ok=True)
789
+
790
+ try:
791
+ # Try to import watchdog
792
+ from watchdog.events import FileSystemEventHandler # type: ignore[import-not-found]
793
+ from watchdog.observers import Observer # type: ignore[import-not-found]
794
+ except ImportError:
795
+ click.echo(
796
+ "✗ The 'watchdog' package is required for the watch command.\n"
797
+ " Install it with: pip install watchdog",
798
+ err=True,
799
+ )
800
+ sys.exit(ExitCodes.GENERAL_ERROR)
801
+
802
+ import fnmatch
803
+
804
+ watch_path = Path(watch_dir).resolve()
805
+
806
+ # Track pending uploads with debounce
807
+ pending_uploads: Dict[str, float] = {}
808
+ pending_lock = threading.Lock()
809
+
810
+ def upload_file_async(file_path: Path) -> None:
811
+ """Upload a file and handle post-upload actions."""
812
+ try:
813
+ file_name = file_path.name
814
+ file_size = file_path.stat().st_size
815
+
816
+ url = f"{_get_file_service_url()}/upload-files"
817
+ if workspace_id:
818
+ url += f"?workspace={workspace_id}"
819
+
820
+ # Metadata uses 'Name' property for filename
821
+ metadata: Dict[str, Any] = {"Name": file_name}
822
+
823
+ with open(file_path, "rb") as f:
824
+ files = {"file": (file_name, f, "application/octet-stream")}
825
+ data = {"metadata": json.dumps(metadata)}
826
+ resp = make_api_request("POST", url, payload=None, files=files, data=data)
827
+
828
+ result = resp.json()
829
+ # Extract file ID from the URI in response
830
+ uri = result.get("uri", "")
831
+ uploaded_file_id = uri.split("/")[-1] if uri else "N/A"
832
+
833
+ click.echo(
834
+ f"✓ Uploaded: {file_name} "
835
+ f"({_format_file_size(file_size)}) -> ID: {uploaded_file_id}"
836
+ )
837
+
838
+ # Handle post-upload action
839
+ if move_to:
840
+ dest_path = Path(move_to) / file_name
841
+ # Handle duplicate filenames by adding a unique suffix
842
+ if dest_path.exists():
843
+ stem = dest_path.stem
844
+ suffix = dest_path.suffix
845
+ counter = 1
846
+ while dest_path.exists():
847
+ dest_path = Path(move_to) / f"{stem}_{counter}{suffix}"
848
+ counter += 1
849
+ shutil.move(str(file_path), str(dest_path))
850
+ click.echo(f" → Moved to: {dest_path}")
851
+ elif delete_after_upload:
852
+ file_path.unlink()
853
+ click.echo(f" → Deleted: {file_path}")
854
+
855
+ except Exception as e:
856
+ click.echo(f"✗ Failed to upload {file_path.name}: {e}", err=True)
857
+
858
+ def process_pending_uploads() -> None:
859
+ """Process pending uploads after debounce period."""
860
+ while True:
861
+ time.sleep(0.5)
862
+ current_time = time.time()
863
+ to_upload: List[str] = []
864
+
865
+ with pending_lock:
866
+ for path, timestamp in list(pending_uploads.items()):
867
+ if current_time - timestamp >= debounce:
868
+ to_upload.append(path)
869
+
870
+ for path in to_upload:
871
+ del pending_uploads[path]
872
+
873
+ for path in to_upload:
874
+ file_path = Path(path)
875
+ if file_path.exists() and file_path.is_file():
876
+ upload_file_async(file_path)
877
+
878
+ class FileUploadHandler(FileSystemEventHandler): # type: ignore[misc]
879
+ """Handler for file system events."""
880
+
881
+ def on_created(self, event: Any) -> None:
882
+ if event.is_directory:
883
+ return
884
+ self._handle_file(event.src_path)
885
+
886
+ def on_modified(self, event: Any) -> None:
887
+ if event.is_directory:
888
+ return
889
+ self._handle_file(event.src_path)
890
+
891
+ def _handle_file(self, file_path: str) -> None:
892
+ path = Path(file_path)
893
+
894
+ # Ignore dot files (e.g., .DS_Store, .gitignore)
895
+ if path.name.startswith("."):
896
+ return
897
+
898
+ # Check pattern match
899
+ if not fnmatch.fnmatch(path.name, pattern):
900
+ return
901
+
902
+ with pending_lock:
903
+ pending_uploads[file_path] = time.time()
904
+
905
+ # Start the upload processor thread
906
+ upload_thread = threading.Thread(target=process_pending_uploads, daemon=True)
907
+ upload_thread.start()
908
+
909
+ # Set up file watcher
910
+ event_handler = FileUploadHandler()
911
+ observer = Observer()
912
+ observer.schedule(event_handler, str(watch_path), recursive=recursive)
913
+ observer.start()
914
+
915
+ click.echo(f"Watching: {watch_path}")
916
+ click.echo(f"Pattern: {pattern}")
917
+ if workspace:
918
+ click.echo(f"Target workspace: {workspace}")
919
+ if move_to:
920
+ click.echo(f"Move after upload: {move_to}")
921
+ elif delete_after_upload:
922
+ click.echo("Delete after upload: enabled")
923
+ click.echo("\nPress Ctrl+C to stop watching...\n")
924
+
925
+ try:
926
+ while True:
927
+ time.sleep(1)
928
+ except KeyboardInterrupt:
929
+ click.echo("\n\nStopping file watcher...")
930
+ observer.stop()
931
+ observer.join()
932
+ click.echo("File watcher stopped.")