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/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.")
|