accli 2.0.0rc1__tar.gz → 2.0.1__tar.gz

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 (26) hide show
  1. {accli-2.0.0rc1 → accli-2.0.1}/PKG-INFO +4 -4
  2. {accli-2.0.0rc1 → accli-2.0.1}/README.md +2 -2
  3. {accli-2.0.0rc1 → accli-2.0.1}/accli/AcceleratorTaskDispatcher.py +2 -2
  4. {accli-2.0.0rc1 → accli-2.0.1}/accli/_version.py +1 -1
  5. {accli-2.0.0rc1 → accli-2.0.1}/accli/cli.py +258 -176
  6. {accli-2.0.0rc1 → accli-2.0.1}/accli/gui.py +10 -3
  7. {accli-2.0.0rc1 → accli-2.0.1}/accli/mount_downloader.py +1 -1
  8. {accli-2.0.0rc1 → accli-2.0.1}/accli/setup-nfs.ps1 +18 -0
  9. accli-2.0.1/accli/token.py +194 -0
  10. {accli-2.0.0rc1 → accli-2.0.1}/accli.egg-info/PKG-INFO +4 -4
  11. {accli-2.0.0rc1 → accli-2.0.1}/accli.egg-info/requires.txt +1 -1
  12. {accli-2.0.0rc1 → accli-2.0.1}/pyproject.toml +1 -1
  13. accli-2.0.0rc1/accli/token.py +0 -101
  14. {accli-2.0.0rc1 → accli-2.0.1}/LICENSE +0 -0
  15. {accli-2.0.0rc1 → accli-2.0.1}/accli/AcceleratorJobProjectService.py +0 -0
  16. {accli-2.0.0rc1 → accli-2.0.1}/accli/AcceleratorTerminalCliProjectService.py +0 -0
  17. {accli-2.0.0rc1 → accli-2.0.1}/accli/CsvRegionalTimeseriesValidator.py +0 -0
  18. {accli-2.0.0rc1 → accli-2.0.1}/accli/__init__.py +0 -0
  19. {accli-2.0.0rc1 → accli-2.0.1}/accli/__main__.py +0 -0
  20. {accli-2.0.0rc1 → accli-2.0.1}/accli/cleanup-nfs.ps1 +0 -0
  21. {accli-2.0.0rc1 → accli-2.0.1}/accli/common.py +0 -0
  22. {accli-2.0.0rc1 → accli-2.0.1}/accli.egg-info/SOURCES.txt +0 -0
  23. {accli-2.0.0rc1 → accli-2.0.1}/accli.egg-info/dependency_links.txt +0 -0
  24. {accli-2.0.0rc1 → accli-2.0.1}/accli.egg-info/entry_points.txt +0 -0
  25. {accli-2.0.0rc1 → accli-2.0.1}/accli.egg-info/top_level.txt +0 -0
  26. {accli-2.0.0rc1 → accli-2.0.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.0rc1
3
+ Version: 2.0.1
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -39,7 +39,7 @@ Requires-Dist: requests>=2.25.1
39
39
  Requires-Dist: jsonschema>=4.5.1
40
40
  Requires-Dist: pydantic>=2.0
41
41
  Requires-Dist: GitPython
42
- Requires-Dist: hf-xet>=1.4.3
42
+ Requires-Dist: hf-xet==1.5.2
43
43
  Dynamic: license-file
44
44
 
45
45
  # Accelerator Terminal Client and Python API
@@ -138,8 +138,8 @@ Usage: accli [OPTIONS] COMMAND [ARGS]...
138
138
 
139
139
  ### Handling self-signed certificates
140
140
 
141
- 1. Copy and paste certificates from `<control_services_backend>/certs/public.crt` as
142
- `./certs/dev-backend.crt` and `<accelerator_service>/minio_certs/public.crt` as
141
+ 1. Copy and paste certificates from `<control_services_backend>/certs/ca.crt` as
142
+ `./certs/dev-backend.crt` and `<accelerator_service>/minio_certs/ca.crt` as
143
143
  `./certs/dev-minio-ca.crt`.
144
144
  2. Run
145
145
  ```
@@ -94,8 +94,8 @@ Usage: accli [OPTIONS] COMMAND [ARGS]...
94
94
 
95
95
  ### Handling self-signed certificates
96
96
 
97
- 1. Copy and paste certificates from `<control_services_backend>/certs/public.crt` as
98
- `./certs/dev-backend.crt` and `<accelerator_service>/minio_certs/public.crt` as
97
+ 1. Copy and paste certificates from `<control_services_backend>/certs/ca.crt` as
98
+ `./certs/dev-backend.crt` and `<accelerator_service>/minio_certs/ca.crt` as
99
99
  `./certs/dev-minio-ca.crt`.
100
100
  2. Run
101
101
  ```
@@ -11,7 +11,7 @@ from pydantic import BaseModel, model_validator
11
11
  from accli.AcceleratorTerminalCliProjectService import AcceleratorTerminalCliProjectService
12
12
  from accli.token import (
13
13
  get_token, get_server_url,
14
- get_project_slug
14
+ get_project_slug, exchange_refresh_token
15
15
  )
16
16
 
17
17
  ACCLI_DEBUG = os.environ.get('ACCLI_DEBUG', False)
@@ -65,9 +65,9 @@ def copy_tree(src, dst):
65
65
 
66
66
  @lru_cache(maxsize=None)
67
67
  def push_folder_job(directory):
68
- access_token = get_token()
69
68
  server_url = get_server_url()
70
69
  project_slug = get_project_slug()
70
+ _, access_token, _ = exchange_refresh_token(project_slug)
71
71
 
72
72
  term_cli_project_service = AcceleratorTerminalCliProjectService(
73
73
  user_token=access_token,
@@ -1,3 +1,3 @@
1
1
  # Please strictly put double quote to use this info for git tag
2
2
  # Read a developer guide for git tag command with regex
3
- VERSION = "v2.0.0-c1"
3
+ VERSION = "v2.0.1"
@@ -15,7 +15,7 @@ from typing import List
15
15
 
16
16
  from accli.AcceleratorTerminalCliProjectService import AcceleratorTerminalCliProjectService
17
17
  from accli.CsvRegionalTimeseriesValidator import CsvRegionalTimeseriesValidator
18
- from accli.token import save_token_details, get_token, get_server_url, set_project_slug, get_project_slug, get_db_path
18
+ from accli.token import save_token_details, get_token, get_server_url, set_project_slug, get_project_slug, get_db_path, exchange_refresh_token
19
19
  from rich import print
20
20
  from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
21
21
  from typing_extensions import Annotated
@@ -182,84 +182,7 @@ def compute_sha256(filepath: str) -> str:
182
182
  h.update(chunk)
183
183
  return h.hexdigest()
184
184
 
185
- import threading
186
185
 
187
- _token_lock = threading.Lock()
188
- _cached_cas_token = None
189
- _cached_expires_at = 0
190
- _cached_access_token = None
191
-
192
-
193
- def exchange_refresh_token(project_slug: str) -> tuple[str, str, int]:
194
- """
195
- Exchanges the stored refresh token for a short-lived access token
196
- and a new rotated refresh token. Updates the local TinyDB token cache.
197
- Returns (cas_token, access_token, expires_at).
198
- """
199
- import time
200
- from tinydb import TinyDB
201
- from accli.token import get_db_path, save_token_details
202
-
203
- global _cached_cas_token, _cached_expires_at, _cached_access_token
204
-
205
- # 1. Fast-path check: Reuse valid in-memory cache if still fresh (> 5 mins remaining)
206
- # and matches the requested project slug prefix.
207
- now = int(time.time())
208
- if _cached_cas_token and _cached_access_token and (_cached_expires_at - now > 300):
209
- expected_prefix = f"xet_session_prj_{project_slug}_"
210
- if _cached_cas_token.startswith(expected_prefix):
211
- return _cached_cas_token, _cached_access_token, _cached_expires_at
212
-
213
- # 2. Block/lock to serialize requests and avoid rotation race conditions (RTR invalidation)
214
- with _token_lock:
215
- # Re-check cache inside the lock (double-checked locking pattern)
216
- now = int(time.time())
217
- if _cached_cas_token and _cached_access_token and (_cached_expires_at - now > 300):
218
- expected_prefix = f"xet_session_prj_{project_slug}_"
219
- if _cached_cas_token.startswith(expected_prefix):
220
- return _cached_cas_token, _cached_access_token, _cached_expires_at
221
-
222
- db_path = get_db_path()
223
- db = TinyDB(db_path)
224
- item = next(iter(db), {})
225
- refresh_token = item.get('token')
226
- server_url = item.get('server_url', "https://accelerator.iiasa.ac.at")
227
- webcli_url = item.get('webcli_url', "https://accelerator.iiasa.ac.at")
228
-
229
- if not refresh_token:
230
- print("[bold red]ERROR: No token found. Please run 'accli login' first.[/bold red]")
231
- raise typer.Exit(1)
232
-
233
- refresh_endpoint = f"{server_url.rstrip('/')}/api/v1/oauth/device/access-token/"
234
-
235
- try:
236
- response = requests.post(
237
- refresh_endpoint,
238
- json={"refresh_token": refresh_token},
239
- verify=(not ACCLI_DEBUG)
240
- )
241
- response.raise_for_status()
242
- data = response.json()
243
-
244
- access_token = data["access_token"]
245
- new_refresh_token = data["refresh_token"]
246
-
247
- # Save rotated refresh token back to local TinyDB
248
- save_token_details(new_refresh_token, server_url, webcli_url)
249
-
250
- cas_token = f"xet_session_prj_{project_slug}_{access_token}"
251
- # Expire slightly before the 1-hour limit (e.g. 50 minutes)
252
- expires_at = int(time.time()) + 3000
253
-
254
- # Populate in-memory cache
255
- _cached_cas_token = cas_token
256
- _cached_access_token = access_token
257
- _cached_expires_at = expires_at
258
-
259
- return cas_token, access_token, expires_at
260
- except Exception as e:
261
- print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
262
- raise typer.Exit(1)
263
186
 
264
187
 
265
188
  @app.command()
@@ -300,12 +223,6 @@ def copy(
300
223
  verify_cert=(not ACCLI_DEBUG),
301
224
  )
302
225
 
303
- def make_token_refresher(slug: str):
304
- def refresher() -> tuple[str, int]:
305
- token_str, _, exp_time = exchange_refresh_token(slug)
306
- return token_str, exp_time
307
- return refresher
308
-
309
226
  if is_src_remote:
310
227
  # Download flow: acc://[project_slug]/[remote_prefix] -> local destination
311
228
  source_parsed = source[len("acc://"):]
@@ -336,7 +253,7 @@ def copy(
336
253
  else:
337
254
  dest_path.parent.mkdir(parents=True, exist_ok=True)
338
255
 
339
- download_infos = []
256
+ files_to_download = []
340
257
  for filename in filenames:
341
258
  # Filename returned usually starts with project_slug/
342
259
  if filename.startswith(f"{project_slug}/"):
@@ -378,32 +295,102 @@ def copy(
378
295
 
379
296
  local_dest.parent.mkdir(parents=True, exist_ok=True)
380
297
 
381
- download_infos.append(
382
- hf_xet.PyXetDownloadInfo(
383
- destination_path=str(local_dest.resolve()),
384
- hash=merkle_hash,
385
- file_size=file_size
386
- )
298
+ files_to_download.append(
299
+ (str(local_dest.resolve()), merkle_hash, file_size)
387
300
  )
388
301
 
389
- if not download_infos:
302
+ if not files_to_download:
390
303
  print("[bold red]ERROR: No valid files identified for download.[/bold red]")
391
304
  raise typer.Exit(1)
392
305
 
393
- print(f"[bold cyan]Downloading {len(download_infos)} files using hf-xet...[/bold cyan]")
394
- try:
395
- hf_xet.download_files(
396
- files=download_infos,
397
- endpoint=cas_endpoint,
398
- token_info=(cas_token, expires_at),
399
- token_refresher=make_token_refresher(project_slug),
400
- progress_updater=None,
401
- request_headers=None
402
- )
403
- print("[bold green][OK] Download completed successfully![/bold green]")
404
- except Exception as e:
405
- print(f"[bold red]ERROR: Download failed: {e}[/bold red]")
406
- raise typer.Exit(1)
306
+ MAX_FILES_PER_BATCH = 100
307
+ MAX_BYTES_PER_BATCH = 10 * 1024 * 1024 * 1024 # 10 GB
308
+
309
+ batches = []
310
+ current_batch = []
311
+ current_batch_size = 0
312
+
313
+ for item in files_to_download:
314
+ size = item[2]
315
+ if size >= MAX_BYTES_PER_BATCH and not current_batch:
316
+ batches.append([item])
317
+ continue
318
+
319
+ if len(current_batch) >= MAX_FILES_PER_BATCH or current_batch_size + size > MAX_BYTES_PER_BATCH:
320
+ batches.append(current_batch)
321
+ current_batch = []
322
+ current_batch_size = 0
323
+
324
+ current_batch.append(item)
325
+ current_batch_size += size
326
+
327
+ if current_batch:
328
+ batches.append(current_batch)
329
+
330
+ print(f"[bold cyan]Downloading {len(files_to_download)} files in {len(batches)} batches using hf-xet...[/bold cyan]")
331
+
332
+ refresh_url = f"{server_url.rstrip('/')}/api/v1/oauth/device/cas-token/"
333
+ refresh_headers = {"Authorization": f"Bearer {cas_token}"}
334
+
335
+ import sys
336
+ columns = [
337
+ TextColumn("[progress.description]{task.description}"),
338
+ TaskProgressColumn(),
339
+ TimeElapsedColumn(),
340
+ ]
341
+ if sys.stdout.isatty():
342
+ columns.insert(1, BarColumn())
343
+
344
+ with Progress(*columns) as progress:
345
+ total_size = sum(item[2] for item in files_to_download)
346
+ overall_task = progress.add_task("[green]Total Download Progress...", total=total_size)
347
+
348
+ completed_in_previous_batches = 0
349
+
350
+ session = hf_xet.XetSession()
351
+ for batch_index, batch_items in enumerate(batches, 1):
352
+ progress.print(f"[cyan]Processing batch {batch_index}/{len(batches)}...[/cyan]")
353
+
354
+ # capture current completed_in_previous_batches in a local scope to avoid late binding issues
355
+ file_tasks = {}
356
+
357
+ def make_progress_handler(base_completed):
358
+ def on_progress(group_report, item_reports):
359
+ progress.update(overall_task, completed=base_completed + group_report.total_bytes_completed)
360
+ for item_id, item in item_reports.items():
361
+ if item_id not in file_tasks:
362
+ file_tasks[item_id] = progress.add_task(f"[blue]{item.item_name}", total=item.total_bytes)
363
+
364
+ progress.update(file_tasks[item_id], completed=item.bytes_completed)
365
+
366
+ # Hide completed tasks to keep the UI clean when downloading thousands of files
367
+ if item.bytes_completed >= item.total_bytes:
368
+ progress.update(file_tasks[item_id], visible=False)
369
+ return on_progress
370
+
371
+ try:
372
+ with session.new_file_download_group(
373
+ endpoint=cas_endpoint,
374
+ token=cas_token,
375
+ token_expiry_unix_secs=expires_at,
376
+ token_refresh_url=refresh_url,
377
+ token_refresh_headers=refresh_headers,
378
+ progress_callback=make_progress_handler(completed_in_previous_batches),
379
+ progress_interval_ms=500,
380
+ ) as group:
381
+ for dest_path, hash_val, size_val in batch_items:
382
+ info = hf_xet.XetFileInfo(hash_val, size_val)
383
+ group.start_download_file(info, dest_path)
384
+
385
+ batch_size = sum(item[2] for item in batch_items)
386
+ completed_in_previous_batches += batch_size
387
+ progress.update(overall_task, completed=completed_in_previous_batches)
388
+ progress.print(f"[bold green][OK] Batch {batch_index} completed successfully![/bold green]")
389
+ except Exception as e:
390
+ progress.print(f"[bold red]ERROR: Download failed for batch {batch_index}: {e}[/bold red]")
391
+ raise typer.Exit(1)
392
+
393
+ print("[bold green][OK] Download completed successfully![/bold green]")
407
394
 
408
395
  else:
409
396
  # Upload flow: local source -> acc://[project_slug]/[remote_path]
@@ -456,65 +443,141 @@ def copy(
456
443
  print("[bold yellow]No files to upload.[/bold yellow]")
457
444
  raise typer.Exit(0)
458
445
 
459
- print(f"[bold cyan]Uploading {len(local_paths)} files using hf-xet...[/bold cyan]")
460
- try:
461
- upload_results = hf_xet.upload_files(
462
- file_paths=[str(p) for p in local_paths],
463
- endpoint=cas_endpoint,
464
- token_info=(cas_token, expires_at),
465
- token_refresher=make_token_refresher(project_slug),
466
- progress_updater=None,
467
- _repo_type=None,
468
- request_headers=None,
469
- sha256s=None,
470
- skip_sha256=False
471
- )
472
- except Exception as e:
473
- print(f"[bold red]ERROR: Upload failed: {e}[/bold red]")
474
- raise typer.Exit(1)
446
+ MAX_FILES_PER_BATCH = 100
447
+ MAX_BYTES_PER_BATCH = 10 * 1024 * 1024 * 1024 # 10 GB
448
+
449
+ batches = []
450
+ current_batch_paths = []
451
+ current_batch_remote = []
452
+ current_batch_size = 0
453
+
454
+ for local_p, remote_n in zip(local_paths, remote_filenames):
455
+ size = local_p.stat().st_size
456
+ if size >= MAX_BYTES_PER_BATCH and not current_batch_paths:
457
+ batches.append(([local_p], [remote_n]))
458
+ continue
459
+
460
+ if len(current_batch_paths) >= MAX_FILES_PER_BATCH or current_batch_size + size > MAX_BYTES_PER_BATCH:
461
+ batches.append((current_batch_paths, current_batch_remote))
462
+ current_batch_paths = []
463
+ current_batch_remote = []
464
+ current_batch_size = 0
465
+
466
+ current_batch_paths.append(local_p)
467
+ current_batch_remote.append(remote_n)
468
+ current_batch_size += size
469
+
470
+ if current_batch_paths:
471
+ batches.append((current_batch_paths, current_batch_remote))
475
472
 
476
- # Compute SHA-256 locally and register metadata in database
477
- print("[cyan]Computing SHA-256 hashes and registering metadata...[/cyan]")
478
- registration_items = []
479
- for local_path, remote_filename, upload_info in zip(local_paths, remote_filenames, upload_results):
480
- sha256_hash = compute_sha256(str(local_path))
481
- registration_items.append({
482
- "filename": f"{project_slug}/{remote_filename}",
483
- "merkle_hash": upload_info.hash,
484
- "sha256": sha256_hash,
485
- "file_size": upload_info.file_size,
486
- "content_type": "application/octet-stream"
487
- })
488
-
489
- headers = {
490
- "Content-Type": "application/json",
491
- "X-Project-Slug": project_slug,
492
- "Authorization": f"Bearer {cas_token}"
493
- }
473
+ print(f"[bold cyan]Uploading {len(local_paths)} files in {len(batches)} batches using hf-xet...[/bold cyan]")
474
+
475
+ refresh_url = f"{server_url.rstrip('/')}/api/v1/oauth/device/cas-token/"
476
+ refresh_headers = {"Authorization": f"Bearer {cas_token}"}
477
+
478
+ import sys
479
+ columns = [
480
+ TextColumn("[progress.description]{task.description}"),
481
+ TaskProgressColumn(),
482
+ TimeElapsedColumn(),
483
+ ]
484
+ if sys.stdout.isatty():
485
+ columns.insert(1, BarColumn())
486
+
487
+ with Progress(*columns) as progress:
488
+ total_size = sum(p.stat().st_size for p in local_paths)
489
+ overall_task = progress.add_task("[green]Total Upload Progress...", total=total_size)
490
+
491
+ completed_in_previous_batches = 0
492
+
493
+ session = hf_xet.XetSession()
494
+ for batch_index, (b_paths, b_remotes) in enumerate(batches, 1):
495
+ progress.print(f"[cyan]Processing batch {batch_index}/{len(batches)}...[/cyan]")
496
+
497
+ file_tasks = {}
498
+
499
+ def make_progress_handler(base_completed):
500
+ def on_progress(group_report, item_reports):
501
+ progress.update(overall_task, completed=base_completed + group_report.total_bytes_completed)
502
+ for item_id, item in item_reports.items():
503
+ if item_id not in file_tasks:
504
+ file_tasks[item_id] = progress.add_task(f"[blue]{item.item_name}", total=item.total_bytes)
505
+
506
+ progress.update(file_tasks[item_id], completed=item.bytes_completed)
507
+
508
+ # Hide completed tasks to keep the UI clean
509
+ if item.bytes_completed >= item.total_bytes:
510
+ progress.update(file_tasks[item_id], visible=False)
511
+ return on_progress
512
+
513
+ try:
514
+ with session.new_upload_commit(
515
+ endpoint=cas_endpoint,
516
+ token=cas_token,
517
+ token_expiry_unix_secs=expires_at,
518
+ token_refresh_url=refresh_url,
519
+ token_refresh_headers=refresh_headers,
520
+ progress_callback=make_progress_handler(completed_in_previous_batches),
521
+ progress_interval_ms=500,
522
+ ) as commit:
523
+ handles = []
524
+ for p in b_paths:
525
+ sha = compute_sha256(str(p))
526
+ h = commit.start_upload_file(str(p), sha256=sha)
527
+ handles.append((p, sha, h))
528
+
529
+ upload_results = [(p, sha, h.result()) for p, sha, h in handles]
530
+ except Exception as e:
531
+ progress.print(f"[bold red]ERROR: Upload failed for batch {batch_index}: {e}[/bold red]")
532
+ raise typer.Exit(1)
494
533
 
495
- try:
496
- response = requests.post(
497
- register_url,
498
- json={"items": registration_items},
499
- headers=headers,
500
- verify=(not ACCLI_DEBUG)
501
- )
502
- response.raise_for_status()
503
- print("[bold green][OK] Upload and bulk metadata registration completed successfully![/bold green]")
504
- except requests.exceptions.HTTPError as e:
505
- detail = None
506
- try:
507
- detail = e.response.json().get("detail")
508
- except Exception:
509
- pass
510
- if detail:
511
- print(f"[bold red]ERROR: Bulk metadata registration failed: {detail}[/bold red]")
512
- else:
513
- print(f"[bold red]ERROR: Bulk metadata registration failed: {e}[/bold red]")
514
- raise typer.Exit(1)
515
- except Exception as e:
516
- print(f"[bold red]ERROR: Bulk metadata registration failed: {e}[/bold red]")
517
- raise typer.Exit(1)
534
+ progress.print("[cyan]Computing SHA-256 hashes and registering metadata for batch...[/cyan]")
535
+ registration_items = []
536
+ for local_path, remote_filename, (p, sha256_hash, upload_info) in zip(b_paths, b_remotes, upload_results):
537
+ registration_items.append({
538
+ "filename": f"{project_slug}/{remote_filename}",
539
+ "merkle_hash": upload_info.xet_info.hash,
540
+ "sha256": sha256_hash,
541
+ "file_size": upload_info.xet_info.file_size,
542
+ "content_type": "application/octet-stream"
543
+ })
544
+
545
+ headers = {
546
+ "Content-Type": "application/json",
547
+ "X-Project-Slug": project_slug,
548
+ "Authorization": f"Bearer {cas_token}"
549
+ }
550
+
551
+ try:
552
+ response = requests.post(
553
+ register_url,
554
+ json={"items": registration_items},
555
+ headers=headers,
556
+ verify=(not ACCLI_DEBUG)
557
+ )
558
+ response.raise_for_status()
559
+
560
+ batch_size = sum(p.stat().st_size for p in b_paths)
561
+ completed_in_previous_batches += batch_size
562
+ progress.update(overall_task, completed=completed_in_previous_batches)
563
+
564
+ progress.print(f"[bold green][OK] Batch {batch_index} registered successfully![/bold green]")
565
+ except requests.exceptions.HTTPError as e:
566
+ detail = None
567
+ try:
568
+ detail = e.response.json().get("detail")
569
+ except Exception:
570
+ pass
571
+ if detail:
572
+ progress.print(f"[bold red]ERROR: Bulk metadata registration failed for batch {batch_index}: {detail}[/bold red]")
573
+ else:
574
+ progress.print(f"[bold red]ERROR: Bulk metadata registration failed for batch {batch_index}: {e}[/bold red]")
575
+ raise typer.Exit(1)
576
+ except Exception as e:
577
+ progress.print(f"[bold red]ERROR: Bulk metadata registration failed for batch {batch_index}: {e}[/bold red]")
578
+ raise typer.Exit(1)
579
+
580
+ print("[bold green][OK] Upload completed successfully![/bold green]")
518
581
 
519
582
 
520
583
  # --- Mount commands group ---
@@ -1314,21 +1377,40 @@ def mount_status(
1314
1377
  args = [str(hf_mount_bin), "status"]
1315
1378
 
1316
1379
  import subprocess
1380
+ active_mounts = []
1381
+
1382
+ # 1. Query hf-mount status directly
1317
1383
  try:
1318
1384
  result = subprocess.run(args, capture_output=True, text=True)
1319
1385
  if result.returncode == 0:
1320
1386
  stdout = result.stdout.strip()
1321
- if not stdout:
1322
- print("[cyan]No running mounts found.[/cyan]")
1323
- else:
1324
- print("[bold cyan]Active Mounts:[/bold cyan]")
1325
- print(stdout)
1326
- else:
1327
- print(f"[bold red][ERROR] Failed to retrieve mount status (exit code {result.returncode}):[/bold red]")
1328
- if result.stderr:
1329
- print(f"[red]{result.stderr}[/red]")
1330
- raise typer.Exit(result.returncode)
1331
- except Exception as e:
1332
- print(f"[bold red]ERROR: Failed to execute status process: {e}[/bold red]")
1333
- raise typer.Exit(1)
1387
+ if stdout:
1388
+ for line in stdout.splitlines():
1389
+ if line.strip():
1390
+ active_mounts.append(line.strip())
1391
+ except Exception:
1392
+ pass
1393
+
1394
+ # 2. Fallback: parse system mount table on Linux to find localhost NFS or FUSE hf-mounts
1395
+ if not active_mounts and platform.system() == "Linux":
1396
+ try:
1397
+ if os.path.exists("/proc/mounts"):
1398
+ with open("/proc/mounts", "r") as f:
1399
+ for line in f:
1400
+ parts = line.strip().split()
1401
+ if len(parts) >= 3:
1402
+ dev, mnt_point, fstype = parts[0], parts[1], parts[2]
1403
+ is_nfs_localhost = (fstype == "nfs" and dev.startswith("127.0.0.1:"))
1404
+ is_fuse_hf = ("hf-mount" in dev or "hf-mount" in fstype)
1405
+ if is_nfs_localhost or is_fuse_hf:
1406
+ active_mounts.append(f"{mnt_point} ({fstype} via {dev})")
1407
+ except Exception:
1408
+ pass
1409
+
1410
+ if not active_mounts:
1411
+ print("[cyan]No running mounts found.[/cyan]")
1412
+ else:
1413
+ print("[bold cyan]Active Mounts:[/bold cyan]")
1414
+ for m in active_mounts:
1415
+ print(m)
1334
1416
 
@@ -113,7 +113,7 @@ class AccliGuiApp(tk.Tk):
113
113
  self.build_auth_tab()
114
114
  self.build_logs_tab()
115
115
 
116
- def run_cli_async(self, args, on_done=None, log_to_viewer=True):
116
+ def run_cli_async(self, args, on_done=None, log_to_viewer=True, elevate=False):
117
117
  """Runs the CLI binary in a background thread to prevent UI freezing."""
118
118
  def worker():
119
119
  # Determine how to invoke accli
@@ -124,6 +124,13 @@ class AccliGuiApp(tk.Tk):
124
124
  # Script mode
125
125
  cmd = [sys.executable, "-m", "accli.cli"] + args
126
126
 
127
+ if elevate and platform.system() != "Windows":
128
+ import shutil
129
+ if shutil.which("pkexec"):
130
+ cmd = ["pkexec"] + cmd
131
+ else:
132
+ cmd = ["sudo"] + cmd
133
+
127
134
  try:
128
135
  if log_to_viewer:
129
136
  self.append_log(f"Executing: {' '.join(cmd)}\n")
@@ -491,7 +498,7 @@ class AccliGuiApp(tk.Tk):
491
498
  self.status_bar_val.set("Mount Failed")
492
499
  messagebox.showerror("Error", f"Failed to mount project '{slug}'. See logs for details.")
493
500
 
494
- self.run_cli_async(args, on_done=on_done)
501
+ self.run_cli_async(args, on_done=on_done, elevate=True)
495
502
 
496
503
  def action_mount_stop(self):
497
504
  selected = self.mounts_list.get(tk.ACTIVE)
@@ -521,7 +528,7 @@ class AccliGuiApp(tk.Tk):
521
528
  self.status_bar_val.set("Stop Failed")
522
529
  messagebox.showerror("Error", f"Failed to stop mount at {mount_point}.")
523
530
 
524
- self.run_cli_async(args, on_done=on_done)
531
+ self.run_cli_async(args, on_done=on_done, elevate=True)
525
532
 
526
533
  # ------------------ COPY TAB ------------------
527
534
  def build_copy_tab(self):
@@ -9,7 +9,7 @@ import typer
9
9
  from rich import print
10
10
  from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
11
11
 
12
- DEFAULT_VERSION = "v0.6.5-acc-pr140-p4"
12
+ DEFAULT_VERSION = "v0.6.5-acc-pr140-p5"
13
13
  BINARY_DIR = Path.home() / ".accli" / "bin"
14
14
 
15
15
  # Regex to safely validate version strings to prevent URL manipulation or directory traversal
@@ -35,6 +35,17 @@ if (-not $NfsAlreadyEnabled) {
35
35
  Write-Host "[OK] Client for NFS features already enabled." -ForegroundColor Green
36
36
  }
37
37
 
38
+ # 1.5 Configure Client for NFS (NfsClnt) service startup type to Manual
39
+ # This prevents NFS Client from starting automatically on Windows boot, avoiding startup delays/hangs.
40
+ # The service will be started on-demand when accli mounts a drive.
41
+ Write-Host "Configuring Client for NFS (NfsClnt) service startup type to Manual..." -ForegroundColor Cyan
42
+ if (Get-Service -Name "NfsClnt" -ErrorAction SilentlyContinue) {
43
+ Set-Service -Name "NfsClnt" -StartupType Manual -ErrorAction SilentlyContinue
44
+ Write-Host "[OK] NfsClnt service startup type configured to Manual." -ForegroundColor Green
45
+ } else {
46
+ Write-Warning "Client for NFS service (NfsClnt) not found. Could not configure startup type."
47
+ }
48
+
38
49
  # 2. Add Registry value for Linked Connections
39
50
  # Allows mapped network drives to be shared between elevated and standard sessions under the same user log-in.
40
51
  if (-not $RegAlreadyEnabled) {
@@ -108,6 +119,13 @@ if ($cfg.skip_auto_mount) {
108
119
  $env:HF_MOUNT_SKIP_AUTO_MOUNT = "1"
109
120
  }
110
121
 
122
+ # Ensure NfsClnt service is started before attempting mount
123
+ try {
124
+ if ((Get-Service -Name "NfsClnt" -ErrorAction SilentlyContinue).Status -ne 'Running') {
125
+ Start-Service -Name "NfsClnt" -ErrorAction SilentlyContinue
126
+ }
127
+ } catch {}
128
+
111
129
  # Construct arguments
112
130
  $mountArgs = @(
113
131
  "--token-file", $cfg.db_path,
@@ -0,0 +1,194 @@
1
+ import os
2
+ import threading
3
+ import time
4
+ import requests
5
+ import typer
6
+ from tinydb import TinyDB
7
+
8
+ ACCLI_DEBUG = os.environ.get('ACCLI_DEBUG', False)
9
+
10
+ _token_lock = threading.Lock()
11
+ _cached_cas_token = None
12
+ _cached_expires_at = 0
13
+ _cached_access_token = None
14
+
15
+
16
+ def get_db_path():
17
+ sudo_user = os.environ.get("SUDO_USER")
18
+ pkexec_uid = os.environ.get("PKEXEC_UID")
19
+
20
+ if (sudo_user or pkexec_uid) and os.name != "nt":
21
+ try:
22
+ import pwd
23
+ if pkexec_uid:
24
+ home = pwd.getpwuid(int(pkexec_uid)).pw_dir
25
+ else:
26
+ home = pwd.getpwnam(sudo_user).pw_dir
27
+ except Exception:
28
+ home = os.path.expanduser("~")
29
+ else:
30
+ home = os.path.expanduser("~")
31
+
32
+ token_directory = f"{home}/.accli"
33
+
34
+ if not os.path.exists(token_directory):
35
+ os.makedirs(token_directory)
36
+
37
+ return f"{token_directory}/data.json"
38
+
39
+
40
+ def save_token_details(token, server_url, webcli_url):
41
+ db_path = get_db_path()
42
+
43
+ try:
44
+ os.remove(db_path)
45
+ except OSError:
46
+ pass
47
+
48
+ db = TinyDB(db_path)
49
+ db.insert({
50
+ 'token': token,
51
+ 'server_url': server_url,
52
+ 'webcli_url': webcli_url
53
+ })
54
+
55
+
56
+ def get_token():
57
+ db_path = get_db_path()
58
+
59
+ db = TinyDB(db_path)
60
+
61
+ for item in db:
62
+ token = item.get('token')
63
+ if token:
64
+ break
65
+
66
+ if not token:
67
+ print("Token does not exists. Please login.")
68
+ return token
69
+
70
+
71
+ def exchange_refresh_token(project_slug: str) -> tuple[str, str, int]:
72
+ """
73
+ Exchanges the stored refresh token for a short-lived access token
74
+ and a new rotated refresh token. Updates the local TinyDB token cache.
75
+ Returns (cas_token, access_token, expires_at).
76
+ """
77
+ global _cached_cas_token, _cached_expires_at, _cached_access_token
78
+
79
+ # 1. Fast-path check: Reuse valid in-memory cache if still fresh (> 5 mins remaining)
80
+ # and matches the requested project slug prefix.
81
+ now = int(time.time())
82
+ if _cached_cas_token and _cached_access_token and (_cached_expires_at - now > 300):
83
+ expected_prefix = f"xet_session_prj_{project_slug}_"
84
+ if _cached_cas_token.startswith(expected_prefix):
85
+ return _cached_cas_token, _cached_access_token, _cached_expires_at
86
+
87
+ # 2. Block/lock to serialize requests and avoid rotation race conditions (RTR invalidation)
88
+ with _token_lock:
89
+ # Re-check cache inside the lock (double-checked locking pattern)
90
+ now = int(time.time())
91
+ if _cached_cas_token and _cached_access_token and (_cached_expires_at - now > 300):
92
+ expected_prefix = f"xet_session_prj_{project_slug}_"
93
+ if _cached_cas_token.startswith(expected_prefix):
94
+ return _cached_cas_token, _cached_access_token, _cached_expires_at
95
+
96
+ db_path = get_db_path()
97
+ db = TinyDB(db_path)
98
+ item = next(iter(db), {})
99
+ refresh_token = item.get('token')
100
+ server_url = item.get('server_url', "https://accelerator.iiasa.ac.at")
101
+ webcli_url = item.get('webcli_url', "https://accelerator.iiasa.ac.at")
102
+
103
+ if not refresh_token:
104
+ print("[bold red]ERROR: No token found. Please run 'accli login' first.[/bold red]")
105
+ raise typer.Exit(1)
106
+
107
+ refresh_endpoint = f"{server_url.rstrip('/')}/api/v1/oauth/device/access-token/"
108
+
109
+ try:
110
+ response = requests.post(
111
+ refresh_endpoint,
112
+ json={"refresh_token": refresh_token},
113
+ verify=(not ACCLI_DEBUG)
114
+ )
115
+ response.raise_for_status()
116
+ data = response.json()
117
+
118
+ access_token = data["access_token"]
119
+ new_refresh_token = data["refresh_token"]
120
+
121
+ # Save rotated refresh token back to local TinyDB
122
+ save_token_details(new_refresh_token, server_url, webcli_url)
123
+
124
+ cas_token = f"xet_session_prj_{project_slug}_{access_token}"
125
+ # Expire slightly before the 1-hour limit (e.g. 50 minutes)
126
+ expires_at = int(time.time()) + 3000
127
+
128
+ # Populate in-memory cache
129
+ _cached_cas_token = cas_token
130
+ _cached_access_token = access_token
131
+ _cached_expires_at = expires_at
132
+
133
+ return cas_token, access_token, expires_at
134
+ except Exception as e:
135
+ print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
136
+ raise typer.Exit(1)
137
+
138
+
139
+ def get_github_app_token():
140
+ db_path = get_db_path()
141
+
142
+ db = TinyDB(db_path)
143
+
144
+ for item in db:
145
+ token = item.get('github_app_token')
146
+ if token:
147
+ break
148
+
149
+ if not token:
150
+ print("Github app token does not exists.")
151
+ return token
152
+
153
+
154
+ def set_github_app_token(github_app_token):
155
+ db_path = get_db_path()
156
+ db = TinyDB(db_path)
157
+ db.update({'github_app_token': github_app_token}, doc_ids=[1])
158
+
159
+
160
+ def set_project_slug(project_slug):
161
+ db_path = get_db_path()
162
+ db = TinyDB(db_path)
163
+ db.update({'project_slug': project_slug}, doc_ids=[1])
164
+
165
+
166
+ def get_project_slug():
167
+ db_path = get_db_path()
168
+
169
+ db = TinyDB(db_path)
170
+
171
+ for item in db:
172
+ project_slug = item.get('project_slug')
173
+ if project_slug:
174
+ break
175
+
176
+ if not project_slug:
177
+ print("project slug was not set.")
178
+ return project_slug
179
+
180
+
181
+ def get_server_url():
182
+ db_path = get_db_path()
183
+
184
+ db = TinyDB(db_path)
185
+
186
+ for item in db:
187
+ server_url = item.get('server_url')
188
+ if server_url:
189
+ break
190
+
191
+ if not server_url:
192
+ print("Server url does not exists. Please login.")
193
+ return server_url
194
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.0rc1
3
+ Version: 2.0.1
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -39,7 +39,7 @@ Requires-Dist: requests>=2.25.1
39
39
  Requires-Dist: jsonschema>=4.5.1
40
40
  Requires-Dist: pydantic>=2.0
41
41
  Requires-Dist: GitPython
42
- Requires-Dist: hf-xet>=1.4.3
42
+ Requires-Dist: hf-xet==1.5.2
43
43
  Dynamic: license-file
44
44
 
45
45
  # Accelerator Terminal Client and Python API
@@ -138,8 +138,8 @@ Usage: accli [OPTIONS] COMMAND [ARGS]...
138
138
 
139
139
  ### Handling self-signed certificates
140
140
 
141
- 1. Copy and paste certificates from `<control_services_backend>/certs/public.crt` as
142
- `./certs/dev-backend.crt` and `<accelerator_service>/minio_certs/public.crt` as
141
+ 1. Copy and paste certificates from `<control_services_backend>/certs/ca.crt` as
142
+ `./certs/dev-backend.crt` and `<accelerator_service>/minio_certs/ca.crt` as
143
143
  `./certs/dev-minio-ca.crt`.
144
144
  2. Run
145
145
  ```
@@ -5,4 +5,4 @@ requests>=2.25.1
5
5
  jsonschema>=4.5.1
6
6
  pydantic>=2.0
7
7
  GitPython
8
- hf-xet>=1.4.3
8
+ hf-xet==1.5.2
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "jsonschema>=4.5.1", # 4.5.1+ is safe across libraries
21
21
  "pydantic>=2.0", # Major version 2 required if you're using v2 features
22
22
  "GitPython",
23
- "hf-xet>=1.4.3",
23
+ "hf-xet==1.5.2",
24
24
  ]
25
25
  classifiers = [
26
26
  "Programming Language :: Python :: 3",
@@ -1,101 +0,0 @@
1
- import os
2
-
3
- from tinydb import TinyDB
4
-
5
-
6
- def get_db_path():
7
- home = os.path.expanduser("~")
8
- token_directory = f"{home}/.accli"
9
-
10
- if not os.path.exists(token_directory):
11
- os.makedirs(token_directory)
12
-
13
- return f"{token_directory}/data.json"
14
-
15
-
16
- def save_token_details(token, server_url, webcli_url):
17
- db_path = get_db_path()
18
-
19
- try:
20
- os.remove(db_path)
21
- except OSError:
22
- pass
23
-
24
- db = TinyDB(db_path)
25
- db.insert({
26
- 'token': token,
27
- 'server_url': server_url,
28
- 'webcli_url': webcli_url
29
- })
30
-
31
-
32
- def get_token():
33
- db_path = get_db_path()
34
-
35
- db = TinyDB(db_path)
36
-
37
- for item in db:
38
- token = item.get('token')
39
- if token:
40
- break
41
-
42
- if not token:
43
- print("Token does not exists. Please login.")
44
- return token
45
-
46
-
47
- def get_github_app_token():
48
- db_path = get_db_path()
49
-
50
- db = TinyDB(db_path)
51
-
52
- for item in db:
53
- token = item.get('github_app_token')
54
- if token:
55
- break
56
-
57
- if not token:
58
- print("Github app token does not exists.")
59
- return token
60
-
61
-
62
- def set_github_app_token(github_app_token):
63
- db_path = get_db_path()
64
- db = TinyDB(db_path)
65
- db.update({'github_app_token': github_app_token}, doc_ids=[1])
66
-
67
-
68
- def set_project_slug(project_slug):
69
- db_path = get_db_path()
70
- db = TinyDB(db_path)
71
- db.update({'project_slug': project_slug}, doc_ids=[1])
72
-
73
-
74
- def get_project_slug():
75
- db_path = get_db_path()
76
-
77
- db = TinyDB(db_path)
78
-
79
- for item in db:
80
- project_slug = item.get('project_slug')
81
- if project_slug:
82
- break
83
-
84
- if not project_slug:
85
- print("project slug was not set.")
86
- return project_slug
87
-
88
-
89
- def get_server_url():
90
- db_path = get_db_path()
91
-
92
- db = TinyDB(db_path)
93
-
94
- for item in db:
95
- server_url = item.get('server_url')
96
- if server_url:
97
- break
98
-
99
- if not server_url:
100
- print("Server url does not exists. Please login.")
101
- return server_url
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes