qbraid-cli 0.8.5a1__py3-none-any.whl → 0.12.0a0__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 (38) hide show
  1. qbraid_cli/_version.py +16 -14
  2. qbraid_cli/account/__init__.py +11 -0
  3. qbraid_cli/account/app.py +67 -0
  4. qbraid_cli/admin/app.py +21 -13
  5. qbraid_cli/admin/headers.py +132 -23
  6. qbraid_cli/admin/validation.py +1 -8
  7. qbraid_cli/chat/__init__.py +11 -0
  8. qbraid_cli/chat/app.py +76 -0
  9. qbraid_cli/configure/actions.py +21 -2
  10. qbraid_cli/configure/app.py +147 -2
  11. qbraid_cli/configure/claude_config.py +215 -0
  12. qbraid_cli/devices/app.py +27 -4
  13. qbraid_cli/envs/activate.py +38 -8
  14. qbraid_cli/envs/app.py +716 -89
  15. qbraid_cli/envs/create.py +3 -2
  16. qbraid_cli/files/__init__.py +11 -0
  17. qbraid_cli/files/app.py +139 -0
  18. qbraid_cli/handlers.py +35 -5
  19. qbraid_cli/jobs/app.py +33 -13
  20. qbraid_cli/jobs/toggle_braket.py +2 -13
  21. qbraid_cli/jobs/validation.py +1 -0
  22. qbraid_cli/kernels/app.py +4 -3
  23. qbraid_cli/main.py +57 -13
  24. qbraid_cli/mcp/__init__.py +10 -0
  25. qbraid_cli/mcp/app.py +126 -0
  26. qbraid_cli/mcp/serve.py +321 -0
  27. qbraid_cli/pip/app.py +2 -2
  28. qbraid_cli/pip/hooks.py +1 -0
  29. {qbraid_cli-0.8.5a1.dist-info → qbraid_cli-0.12.0a0.dist-info}/METADATA +37 -14
  30. qbraid_cli-0.12.0a0.dist-info/RECORD +46 -0
  31. {qbraid_cli-0.8.5a1.dist-info → qbraid_cli-0.12.0a0.dist-info}/WHEEL +1 -1
  32. {qbraid_cli-0.8.5a1.dist-info → qbraid_cli-0.12.0a0.dist-info/licenses}/LICENSE +6 -4
  33. qbraid_cli/admin/buildlogs.py +0 -114
  34. qbraid_cli/credits/__init__.py +0 -11
  35. qbraid_cli/credits/app.py +0 -35
  36. qbraid_cli-0.8.5a1.dist-info/RECORD +0 -39
  37. {qbraid_cli-0.8.5a1.dist-info → qbraid_cli-0.12.0a0.dist-info}/entry_points.txt +0 -0
  38. {qbraid_cli-0.8.5a1.dist-info → qbraid_cli-0.12.0a0.dist-info}/top_level.txt +0 -0
qbraid_cli/envs/app.py CHANGED
@@ -6,50 +6,86 @@ Module defining commands in the 'qbraid envs' namespace.
6
6
 
7
7
  """
8
8
 
9
- import shutil
10
9
  import subprocess
11
10
  import sys
12
11
  from pathlib import Path
13
12
  from typing import TYPE_CHECKING, Optional
14
13
 
15
14
  import typer
15
+ from qbraid_core.services.environments.schema import EnvironmentConfig
16
+ from qbraid_core.services.environments.exceptions import EnvironmentValidationError
16
17
  from rich.console import Console
18
+ from rich.progress import (
19
+ BarColumn,
20
+ DownloadColumn,
21
+ Progress,
22
+ SpinnerColumn,
23
+ TextColumn,
24
+ TimeElapsedColumn,
25
+ TransferSpeedColumn,
26
+ )
17
27
 
18
28
  from qbraid_cli.envs.create import create_qbraid_env_assets, create_venv
19
29
  from qbraid_cli.envs.data_handling import get_envs_data as installed_envs_data
20
30
  from qbraid_cli.envs.data_handling import validate_env_name
21
- from qbraid_cli.handlers import QbraidException, run_progress_task
31
+ from qbraid_cli.handlers import QbraidException, handle_error, run_progress_task
22
32
 
23
33
  if TYPE_CHECKING:
24
34
  from qbraid_core.services.environments.client import EnvironmentManagerClient as EMC
25
35
 
26
- envs_app = typer.Typer(help="Manage qBraid environments.")
36
+ envs_app = typer.Typer(help="Manage qBraid environments.", no_args_is_help=True)
27
37
 
28
38
 
29
39
  @envs_app.command(name="create")
30
40
  def envs_create( # pylint: disable=too-many-statements
31
- name: str = typer.Option(
32
- ..., "--name", "-n", help="Name of the environment to create", callback=validate_env_name
33
- ),
41
+ name: str = typer.Option(None, "--name", "-n", help="Name of the environment to create"),
34
42
  description: Optional[str] = typer.Option(
35
43
  None, "--description", "-d", help="Short description of the environment"
36
44
  ),
45
+ file_path: str = typer.Option(
46
+ None, "--file", "-f", help="Path to a .yml file containing the environment details"
47
+ ),
37
48
  auto_confirm: bool = typer.Option(
38
49
  False, "--yes", "-y", help="Automatically answer 'yes' to all prompts"
39
50
  ),
40
51
  ) -> None:
41
52
  """Create a new qBraid environment."""
42
53
  env_description = description or ""
54
+ if name:
55
+ if not validate_env_name(name):
56
+ handle_error(
57
+ error_type="ValueError",
58
+ include_traceback=False,
59
+ message=f"Invalid environment name '{name}'. ",
60
+ )
61
+
62
+ env_details_in_cli = name is not None and env_description != ""
63
+ env_config = None
64
+ if env_details_in_cli and file_path:
65
+ handle_error(
66
+ error_type="ArgumentConflictError",
67
+ include_traceback=False,
68
+ message="Cannot use --file with --name or --description while creating an environment",
69
+ )
70
+ elif not env_details_in_cli and not file_path:
71
+ handle_error(
72
+ error_type="MalformedCommandError",
73
+ include_traceback=False,
74
+ message="Must provide either --name and --description or --file "
75
+ "while creating an environment",
76
+ )
77
+ else:
78
+ try:
79
+ if file_path:
80
+ env_config: EnvironmentConfig = EnvironmentConfig.from_yaml(file_path)
81
+ except ValueError as err:
82
+ handle_error(error_type="YamlValidationError", message=str(err))
43
83
 
44
- def create_environment(*args, **kwargs) -> "tuple[dict, EMC]":
45
- """Create a qBraid environment."""
46
- from qbraid_core.services.environments.client import EnvironmentManagerClient
47
-
48
- client = EnvironmentManagerClient()
49
- return client.create_environment(*args, **kwargs), client
84
+ if not name:
85
+ name = env_config.name
50
86
 
51
87
  def gather_local_data() -> tuple[Path, str]:
52
- """Gather environment data and return the slug."""
88
+ """Gather local environment data for creation."""
53
89
  from qbraid_core.services.environments import get_default_envs_paths
54
90
 
55
91
  env_path = get_default_envs_paths()[0]
@@ -65,69 +101,71 @@ def envs_create( # pylint: disable=too-many-statements
65
101
 
66
102
  return env_path, python_version
67
103
 
68
- environment, emc = run_progress_task(
69
- create_environment,
70
- name,
71
- env_description,
72
- description="Validating request...",
73
- error_message="Failed to create qBraid environment",
74
- )
104
+ if not env_config:
105
+ env_config = EnvironmentConfig(
106
+ name=name,
107
+ description=env_description,
108
+ )
75
109
 
76
- env_path, python_version = run_progress_task(
110
+ # NOTE: create_environment API call will be removed from CLI
111
+ # For now, we generate env_id locally and create environment without API
112
+ from qbraid_core.services.environments.registry import generate_env_id
113
+
114
+ local_data_out: tuple[Path, str] = run_progress_task(
77
115
  gather_local_data,
78
116
  description="Solving environment...",
79
117
  error_message="Failed to create qBraid environment",
80
118
  )
81
- slug = environment.get("slug")
82
- display_name = environment.get("displayName")
83
- prompt = environment.get("prompt")
84
- description = environment.get("description")
85
- tags = environment.get("tags")
86
- kernel_name = environment.get("kernelName")
87
-
88
- slug_path = env_path / slug
119
+
120
+ env_path, python_version = local_data_out
121
+
122
+ env_config.python_version = python_version
123
+
124
+ # Generate env_id for local environment (slug will be set when published)
125
+ env_id = generate_env_id()
126
+ env_id_path = env_path / env_id
89
127
  description = "None" if description == "" else description
90
128
 
91
- typer.echo("\n\n## qBraid Metadata ##\n")
92
- typer.echo(f" name: {display_name}")
93
- typer.echo(f" description: {description}")
94
- typer.echo(f" tags: {tags}")
95
- typer.echo(f" slug: {slug}")
96
- typer.echo(f" shellPrompt: {prompt}")
97
- typer.echo(f" kernelName: {kernel_name}")
129
+ typer.echo("## qBraid Metadata ##\n")
130
+ typer.echo(f" name: {env_config.name}")
131
+ typer.echo(f" description: {env_config.description}")
132
+ typer.echo(f" tags: {env_config.tags}")
133
+ typer.echo(f" env_id: {env_id}")
134
+ typer.echo(f" shellPrompt: {env_config.shell_prompt}")
135
+ typer.echo(f" kernelName: {env_config.kernel_name}")
98
136
 
99
137
  typer.echo("\n\n## Environment Plan ##\n")
100
- typer.echo(f" location: {slug_path}")
138
+ typer.echo(f" location: {env_id_path}")
101
139
  typer.echo(f" version: {python_version}\n")
102
140
 
103
141
  user_confirmation = auto_confirm or typer.confirm("Proceed", default=True)
104
142
  typer.echo("")
105
143
  if not user_confirmation:
106
- emc.delete_environment(slug)
107
144
  typer.echo("qBraidSystemExit: Exiting.")
108
145
  raise typer.Exit()
109
146
 
110
147
  run_progress_task(
111
148
  create_qbraid_env_assets,
112
- slug,
113
- prompt,
114
- kernel_name,
115
- slug_path,
149
+ env_id,
150
+ env_id_path,
151
+ env_config,
116
152
  description="Generating qBraid assets...",
117
153
  error_message="Failed to create qBraid environment",
118
154
  )
119
155
 
120
156
  run_progress_task(
121
157
  create_venv,
122
- slug_path,
123
- prompt,
158
+ env_id_path,
159
+ env_config.shell_prompt,
160
+ None, # python_exe (use default)
161
+ env_id, # env_id for registry package tracking
124
162
  description="Creating virtual environment...",
125
163
  error_message="Failed to create qBraid environment",
126
164
  )
127
165
 
128
166
  console = Console()
129
167
  console.print(
130
- f"\n[bold green]Successfully created qBraid environment: "
168
+ f"[bold green]Successfully created qBraid environment: "
131
169
  f"[/bold green][bold magenta]{name}[/bold magenta]\n"
132
170
  )
133
171
  typer.echo("# To activate this environment, use")
@@ -147,30 +185,43 @@ def envs_remove(
147
185
  ),
148
186
  ) -> None:
149
187
  """Delete a qBraid environment."""
188
+ import asyncio
150
189
 
151
- def delete_environment(slug: str) -> None:
152
- """Delete a qBraid environment."""
190
+ def uninstall_environment(slug: str) -> dict:
191
+ """Uninstall a qBraid environment using async method."""
153
192
  from qbraid_core.services.environments.client import EnvironmentManagerClient
154
193
 
155
194
  emc = EnvironmentManagerClient()
156
- emc.delete_environment(slug)
195
+
196
+ # Run async uninstall method
197
+ result = asyncio.run(
198
+ emc.uninstall_environment_local(slug, delete_metadata=True, force=auto_confirm)
199
+ )
200
+ return result
157
201
 
158
202
  def gather_local_data(env_name: str) -> tuple[Path, str]:
159
- """Get environment path and slug from name (alias)."""
203
+ """Get environment path and env_id from name or env_id."""
160
204
  installed, aliases = installed_envs_data()
161
- for alias, slug in aliases.items():
162
- if alias == env_name:
163
- path = installed[slug]
164
-
165
- return path, slug
166
-
167
- raise QbraidException(f"Environment '{name}' not found.")
205
+ # Try to find by name first, then by env_id
206
+ if env_name in aliases:
207
+ env_id = aliases[env_name]
208
+ path = installed[env_id]
209
+ return path, env_id
210
+ elif env_name in installed:
211
+ # Direct env_id lookup
212
+ path = installed[env_name]
213
+ return path, env_name
214
+
215
+ raise QbraidException(
216
+ f"Environment '{name}' not found. "
217
+ "Use name (if unique) or env_id to reference the environment."
218
+ )
168
219
 
169
- slug_path, slug = gather_local_data(name)
220
+ env_path, env_id = gather_local_data(name)
170
221
 
171
222
  confirmation_message = (
172
223
  f"⚠️ Warning: You are about to delete the environment '{name}' "
173
- f"located at '{slug_path}'.\n"
224
+ f"located at '{env_path}'.\n"
174
225
  "This will remove all local packages and permanently delete all "
175
226
  "of its associated qBraid environment metadata.\n"
176
227
  "This operation CANNOT be undone.\n\n"
@@ -179,27 +230,25 @@ def envs_remove(
179
230
 
180
231
  if auto_confirm or typer.confirm(confirmation_message, abort=True):
181
232
  typer.echo("")
182
- run_progress_task(
183
- delete_environment,
184
- slug,
185
- description="Deleting remote environment data...",
233
+ result = run_progress_task(
234
+ uninstall_environment,
235
+ env_id, # Can be name or env_id - method handles both
236
+ description="Deleting environment...",
186
237
  error_message="Failed to delete qBraid environment",
187
238
  )
188
239
 
189
- run_progress_task(
190
- shutil.rmtree,
191
- slug_path,
192
- description="Deleting local environment...",
193
- error_message="Failed to delete qBraid environment",
194
- )
195
- typer.echo(f"\nEnvironment '{name}' successfully removed.")
240
+ if result.get('deleted_metadata'):
241
+ typer.echo(f"✅ Environment '{name}' successfully removed (local files and metadata).")
242
+ else:
243
+ typer.echo(f" Environment '{name}' successfully removed (local files only).")
196
244
 
197
245
 
198
246
  @envs_app.command(name="list")
199
247
  def envs_list():
200
248
  """List installed qBraid environments."""
201
- installed, aliases = installed_envs_data()
202
-
249
+ from qbraid_core.services.environments.registry import EnvironmentRegistryManager
250
+
251
+ installed, aliases = installed_envs_data(use_registry=True)
203
252
  console = Console()
204
253
 
205
254
  if len(installed) == 0:
@@ -210,28 +259,55 @@ def envs_list():
210
259
  )
211
260
  return
212
261
 
213
- alias_path_pairs = [(alias, installed[slug_name]) for alias, slug_name in aliases.items()]
214
- sorted_alias_path_pairs = sorted(
215
- alias_path_pairs,
216
- key=lambda x: (x[0] != "default", str(x[1]).startswith(str(Path.home())), x[0]),
262
+ # Get registry to access name and env_id
263
+ registry_mgr = EnvironmentRegistryManager()
264
+
265
+ # Build list of (name, env_id, path) tuples
266
+ env_list = []
267
+ for env_id, path in installed.items():
268
+ try:
269
+ entry = registry_mgr.get_environment(env_id)
270
+ env_list.append((entry.name, env_id, path))
271
+ except Exception:
272
+ # Fallback if registry lookup fails
273
+ # Try to get name from aliases
274
+ name = None
275
+ for alias, eid in aliases.items():
276
+ if eid == env_id and alias != env_id:
277
+ name = alias
278
+ break
279
+ if not name:
280
+ name = env_id
281
+ env_list.append((name, env_id, path))
282
+
283
+ # Sort: default first, then by name
284
+ sorted_env_list = sorted(
285
+ env_list,
286
+ key=lambda x: (x[0] != "default", str(x[2]).startswith(str(Path.home())), x[0]),
217
287
  )
218
288
 
219
289
  current_env_path = Path(sys.executable).parent.parent.parent
220
290
 
221
- max_alias_length = (
222
- max(len(str(alias)) for alias, envpath in sorted_alias_path_pairs)
223
- if sorted_alias_path_pairs
224
- else 0
225
- )
291
+ # Calculate column widths
292
+ max_name_length = max(len(str(name)) for name, _, _ in sorted_env_list) if sorted_env_list else 0
293
+ max_env_id_length = max(len(str(env_id)) for _, env_id, _ in sorted_env_list) if sorted_env_list else 0
294
+ name_width = max(max_name_length, 4) # At least "Name"
295
+ env_id_width = max(max_env_id_length, 6) # At least "Env ID"
226
296
 
227
297
  output_lines = []
228
298
  output_lines.append("# qbraid environments:")
229
299
  output_lines.append("#")
230
300
  output_lines.append("")
301
+
302
+ # Header
303
+ header = f"{'Name'.ljust(name_width + 2)}{'Env ID'.ljust(env_id_width + 2)}Path"
304
+ output_lines.append(header)
305
+ output_lines.append("")
231
306
 
232
- for alias, path in sorted_alias_path_pairs:
307
+ # Data rows
308
+ for name, env_id, path in sorted_env_list:
233
309
  mark = "*" if path == current_env_path else " "
234
- line = f"{alias.ljust(max_alias_length + 3)}{mark} {path}"
310
+ line = f"{name.ljust(name_width + 2)}{env_id.ljust(env_id_width + 2)}{mark} {path}"
235
311
  output_lines.append(line)
236
312
 
237
313
  final_output = "\n".join(output_lines)
@@ -241,24 +317,575 @@ def envs_list():
241
317
 
242
318
  @envs_app.command(name="activate")
243
319
  def envs_activate(
244
- name: str = typer.Argument(..., help="Name of the environment. Values from 'qbraid envs list'.")
320
+ name: str = typer.Argument(
321
+ ..., help="Name of the environment. Values from 'qbraid envs list'."
322
+ ),
245
323
  ):
246
324
  """Activate qBraid environment.
247
325
 
248
326
  NOTE: Currently only works on qBraid Lab platform, and select few other OS types.
249
327
  """
250
- installed, aliases = installed_envs_data()
251
- if name in aliases:
252
- venv_path: Path = installed[aliases[name]] / "pyenv"
253
- elif name in installed:
254
- venv_path: Path = installed[name] / "pyenv"
255
- else:
328
+ from qbraid_core.services.environments.registry import EnvironmentRegistryManager
329
+
330
+ # Get environment details from registry by looking up alias
331
+ installed, aliases = installed_envs_data(use_registry=True)
332
+ # Find the env_id from the alias (name or env_id)
333
+ env_id = aliases.get(name)
334
+
335
+ if not env_id:
256
336
  raise typer.BadParameter(f"Environment '{name}' not found.")
257
337
 
338
+ # Get the environment entry
339
+ registry_mgr = EnvironmentRegistryManager()
340
+ entry = registry_mgr.get_environment(env_id)
341
+ env_path = Path(entry.path)
342
+
343
+ # The venv is always at pyenv/ subdirectory
344
+ # (shell_prompt is for the PS1 display name, not the directory)
345
+ venv_path = env_path / "pyenv"
346
+
258
347
  from .activate import activate_pyvenv
259
348
 
260
349
  activate_pyvenv(venv_path)
261
350
 
262
351
 
352
+ @envs_app.command(name="available")
353
+ def envs_available():
354
+ """List available pre-built environments for installation."""
355
+
356
+ def get_available_environments():
357
+ """Get available environments from the API."""
358
+ from qbraid_core.services.environments.client import EnvironmentManagerClient
359
+
360
+ emc = EnvironmentManagerClient()
361
+ return emc.get_available_environments()
362
+
363
+ try:
364
+ result = run_progress_task(
365
+ get_available_environments,
366
+ description="Fetching available environments...",
367
+ error_message="Failed to fetch available environments",
368
+ )
369
+ except QbraidException:
370
+ # If API fails, show a helpful message
371
+ console = Console()
372
+ console.print(
373
+ "[yellow]Unable to fetch available environments from qBraid service.[/yellow]"
374
+ )
375
+ console.print("This feature requires:")
376
+ console.print("• qBraid Lab environment, or")
377
+ console.print("• Valid qBraid API credentials configured")
378
+ console.print("\nTo configure credentials, run: [bold]qbraid configure[/bold]")
379
+ return
380
+
381
+ console = Console()
382
+ environments = result.get("environments", [])
383
+
384
+ if not environments:
385
+ console.print("No environments available for installation.")
386
+ return
387
+
388
+ # Check if we're on cloud or local
389
+ is_cloud = any(env.get("available_for_installation", False) for env in environments)
390
+
391
+ # Display header
392
+ if is_cloud:
393
+ status_text = "[bold green]✓ Available for installation[/bold green]"
394
+ else:
395
+ status_text = "[bold yellow]⚠ Not available (local environment)[/bold yellow]"
396
+
397
+ console.print(f"\n# Available qBraid Environments ({status_text})")
398
+ console.print(f"# Total: {len(environments)} environments")
399
+ console.print("#")
400
+ console.print("")
401
+
402
+ # Calculate column widths
403
+ max_name_len = max(len(env.get("displayName", env.get("name", ""))) for env in environments)
404
+ max_slug_len = max(len(env.get("slug", "")) for env in environments)
405
+ name_width = min(max_name_len + 2, 30)
406
+ slug_width = min(max_slug_len + 2, 20)
407
+
408
+ # Display environments
409
+ for env in environments:
410
+ name = env.get("displayName", env.get("name", "N/A"))
411
+ slug = env.get("slug", "N/A")
412
+ description = env.get("description", "No description")
413
+ available = env.get("available_for_installation", False)
414
+
415
+ # Truncate long names/slugs
416
+ if len(name) > 28:
417
+ name = name[:25] + "..."
418
+ if len(slug) > 18:
419
+ slug = slug[:15] + "..."
420
+ if len(description) > 60:
421
+ description = description[:57] + "..."
422
+
423
+ status_icon = "✓" if available else "⚠"
424
+ status_color = "green" if available else "yellow"
425
+
426
+ console.print(
427
+ f"[{status_color}]{status_icon}[/{status_color}] "
428
+ f"{name.ljust(name_width)} "
429
+ f"[dim]({slug.ljust(slug_width)})[/dim] "
430
+ f"{description}"
431
+ )
432
+
433
+ # Show usage hint
434
+ console.print("")
435
+ if is_cloud:
436
+ console.print("[dim]To install an environment, note its slug and use:[/dim]")
437
+ console.print("[dim] qbraid envs install <slug>[/dim]")
438
+ else:
439
+ console.print("[dim]Environment installation is only available on qBraid Lab.[/dim]")
440
+ console.print("[dim]Create custom environments with: qbraid envs create[/dim]")
441
+
442
+
443
+ @envs_app.command(name="install")
444
+ def envs_install(
445
+ env_slug: str = typer.Argument(..., help="Environment slug to install (from 'qbraid envs available')"),
446
+ temp: bool = typer.Option(
447
+ False, "--temp", "-t",
448
+ help="Install as temporary environment (faster, non-persistent)"
449
+ ),
450
+ target_dir: str = typer.Option(
451
+ None, "--target", help="Custom target directory (defaults to ~/.qbraid/environments or /opt/environments)"
452
+ ),
453
+ yes: bool = typer.Option(
454
+ False, "--yes", "-y",
455
+ help="Automatically overwrite existing environment without prompting"
456
+ ),
457
+ ):
458
+ """Install a pre-built environment from cloud storage."""
459
+
460
+ async def install_environment_async():
461
+ """Async wrapper for environment installation."""
462
+ from qbraid_core.services.environments.client import EnvironmentManagerClient
463
+ from pathlib import Path
464
+
465
+ # Determine target directory
466
+ if target_dir:
467
+ install_dir = target_dir
468
+ elif temp:
469
+ install_dir = "/opt/environments"
470
+ else:
471
+ install_dir = str(Path.home() / ".qbraid" / "environments")
472
+
473
+ console = Console()
474
+
475
+ # Check if we're on qBraid Lab for non-temp installs
476
+ emc = EnvironmentManagerClient()
477
+ is_cloud = emc.running_in_lab()
478
+
479
+ if not temp and not is_cloud:
480
+ console.print("[red]❌ Environment installation requires qBraid Lab or use --temp flag[/red]")
481
+ console.print("💡 Try: [bold]qbraid envs install {env_slug} --temp[/bold]")
482
+ raise typer.Exit(1)
483
+
484
+ if temp and not is_cloud:
485
+ console.print("[yellow]⚠️ Local temporary install - environment won't persist after restart[/yellow]")
486
+
487
+ # Install environment
488
+ try:
489
+ console.print(f"🚀 Installing environment: [bold]{env_slug}[/bold]")
490
+ if temp:
491
+ console.print(f"📂 Target: [dim]{install_dir}[/dim] (temporary)")
492
+ else:
493
+ console.print(f"📂 Target: [dim]{install_dir}[/dim] (persistent)")
494
+ console.print("")
495
+
496
+ progress_columns = (
497
+ SpinnerColumn(),
498
+ TextColumn("{task.description}"),
499
+ BarColumn(),
500
+ DownloadColumn(),
501
+ TransferSpeedColumn(),
502
+ TimeElapsedColumn(),
503
+ )
504
+
505
+ with Progress(*progress_columns, transient=True) as progress:
506
+ tasks: dict[str, int] = {}
507
+
508
+ def get_task(stage: str, description: str, total: Optional[int]) -> int:
509
+ task_id = tasks.get(stage)
510
+ if task_id is None:
511
+ task_total = total if total and total > 0 else None
512
+ task_id = progress.add_task(description, total=task_total)
513
+ tasks[stage] = task_id
514
+ return task_id
515
+
516
+ def progress_callback(stage: str, completed: int, total: int) -> None:
517
+ total_value = total if total and total > 0 else None
518
+ if stage == "download":
519
+ task_id = get_task("download", "Downloading environment...", total_value)
520
+ task = progress.tasks[task_id]
521
+ if total_value and (task.total is None or task.total == 0):
522
+ progress.update(task_id, total=total_value)
523
+ progress.update(task_id, completed=completed)
524
+ elif stage == "extract":
525
+ task_id = get_task("extract", "Extracting files...", total_value)
526
+ task = progress.tasks[task_id]
527
+ if total_value:
528
+ if task.total is None or task.total == 0:
529
+ progress.update(task_id, total=total_value)
530
+ progress.update(task_id, completed=completed)
531
+ else:
532
+ progress.update(task_id, completed=task.completed + 1)
533
+
534
+ result = await emc.install_environment_from_storage(
535
+ slug=env_slug,
536
+ target_dir=install_dir,
537
+ temp=temp,
538
+ overwrite=yes,
539
+ progress_callback=progress_callback,
540
+ )
541
+
542
+ console.print("")
543
+ console.print(f"[green]✅ Installation completed![/green]")
544
+ console.print(f"📍 Location: [bold]{result['target_dir']}[/bold]")
545
+ if 'size_mb' in result:
546
+ console.print(f"💾 Size: [dim]{result['size_mb']:.1f} MB[/dim]")
547
+ if 'env_id' in result:
548
+ console.print(f"🆔 Env ID: [dim]{result['env_id']}[/dim]")
549
+
550
+ if temp:
551
+ console.print("[yellow]⚠️ Temporary environment - will be deleted on pod restart[/yellow]")
552
+
553
+ env_activation_id = result.get("env_id", env_slug)
554
+ console.print("")
555
+ console.print("🎯 Next steps:")
556
+ console.print(f"• Activate: [bold]qbraid envs activate {env_activation_id}[/bold]")
557
+ console.print(f"• List all: [bold]qbraid envs list[/bold]")
558
+
559
+ except Exception as e:
560
+ if isinstance(e, EnvironmentValidationError) and "already exists" in str(e):
561
+ console.print("[yellow]⚠️ Installation skipped: Environment already exists.[/yellow]")
562
+ else:
563
+ console.print(f"[red]❌ Installation failed: {e}[/red]")
564
+ raise typer.Exit(1)
565
+ # Run async function
566
+ import asyncio
567
+ try:
568
+ asyncio.run(install_environment_async())
569
+ except KeyboardInterrupt:
570
+ console = Console()
571
+ console.print("\n[yellow]⚠️ Installation cancelled by user[/yellow]")
572
+ raise typer.Exit(1)
573
+
574
+
575
+ @envs_app.command(name="publish")
576
+ def envs_publish(
577
+ slug: str = typer.Argument(..., help="Environment slug identifier"),
578
+ path: Optional[Path] = typer.Option(
579
+ None,
580
+ "--path",
581
+ "-p",
582
+ help="Path to environment directory (default: lookup from registry)"
583
+ ),
584
+ overwrite: bool = typer.Option(
585
+ False,
586
+ "--overwrite",
587
+ "-o",
588
+ help="Overwrite existing published environment"
589
+ ),
590
+ ):
591
+ """
592
+ Publish environment to qBraid cloud storage for global distribution.
593
+
594
+ This command packages and uploads your environment to make it available
595
+ for installation by other users via 'qbraid envs install'.
596
+
597
+ Examples:
598
+ $ qbraid envs publish my_custom_env
599
+ $ qbraid envs publish my_env --path ~/my_environment
600
+ $ qbraid envs publish my_env --overwrite
601
+ """
602
+ from rich.console import Console
603
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
604
+ from qbraid_core.services.environments.client import EnvironmentManagerClient
605
+ from qbraid_core.services.environments.registry import EnvironmentRegistryManager
606
+
607
+ console = Console()
608
+
609
+ # Determine environment path
610
+ if path:
611
+ env_path = path.expanduser().resolve()
612
+ if not env_path.exists():
613
+ console.print(f"[red]❌ Error:[/red] Path does not exist: {env_path}")
614
+ raise typer.Exit(1)
615
+ else:
616
+ # Look up from registry by slug
617
+ try:
618
+ registry_mgr = EnvironmentRegistryManager()
619
+ found = registry_mgr.find_by_slug(slug)
620
+ if not found:
621
+ console.print(f"[red]❌ Error:[/red] Environment with slug '{slug}' not found in registry")
622
+ console.print("\n[yellow]💡 Tip:[/yellow] Use --path to specify environment directory manually.")
623
+ raise typer.Exit(1)
624
+ env_id, env = found
625
+ env_path = Path(env.path)
626
+ except Exception as err:
627
+ console.print(f"[red]❌ Error:[/red] Failed to locate environment: {err}")
628
+ console.print("\n[yellow]💡 Tip:[/yellow] Use --path to specify environment directory manually.")
629
+ raise typer.Exit(1)
630
+
631
+ console.print(f"🚀 Publishing environment: [bold]{slug}[/bold]")
632
+ console.print(f"📂 Source: {env_path}\n")
633
+
634
+ # Define async function
635
+ async def publish_environment_async():
636
+ """Async wrapper for environment publishing."""
637
+ client = EnvironmentManagerClient()
638
+
639
+ # Progress tracking
640
+ stages = {"archive": False, "upload": False}
641
+
642
+ def progress_callback(stage: str, completed: int, total: int):
643
+ """Track progress across stages."""
644
+ stages[stage] = True
645
+
646
+ with Progress(
647
+ SpinnerColumn(),
648
+ TextColumn("[progress.description]{task.description}"),
649
+ BarColumn(),
650
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
651
+ console=console,
652
+ ) as progress:
653
+ # Add tasks
654
+ archive_task = progress.add_task("📦 Creating archive...", total=100)
655
+ upload_task = progress.add_task("☁️ Uploading to cloud...", total=100)
656
+
657
+ try:
658
+ # TODO: Update this when env_share_publish branch is merged
659
+ # This will use remote_publish_environment and handle directory renaming
660
+ raise NotImplementedError(
661
+ "Publish command will be updated when env_share_publish branch is merged. "
662
+ "It will use remote_publish_environment to get slug from API, "
663
+ "rename directory from env_id to slug, update paths, and upload."
664
+ )
665
+ # result = await client.publish_environment(
666
+ # slug=slug,
667
+ # env_path=str(env_path),
668
+ # overwrite=overwrite,
669
+ # progress_callback=progress_callback
670
+ # )
671
+
672
+ # Update progress bars
673
+ if stages.get("archive"):
674
+ progress.update(archive_task, completed=100)
675
+ if stages.get("upload"):
676
+ progress.update(upload_task, completed=100)
677
+
678
+ return result
679
+
680
+ except Exception as err:
681
+ raise err
682
+
683
+ # Run async function
684
+ import asyncio
685
+ try:
686
+ result = asyncio.run(publish_environment_async())
687
+
688
+ console.print(f"\n[green]✅ Published successfully![/green]")
689
+ console.print(f"📍 Bucket: {result.get('bucket', 'N/A')}")
690
+ console.print(f"📄 Path: {result.get('path', 'N/A')}")
691
+ console.print(f"\n🎯 Users can now install with:")
692
+ console.print(f" [bold cyan]qbraid envs install {slug}[/bold cyan]")
693
+
694
+ except KeyboardInterrupt:
695
+ console.print("\n[yellow]⚠️ Publishing cancelled by user[/yellow]")
696
+ raise typer.Exit(1)
697
+ except Exception as err:
698
+ console.print(f"\n[red]❌ Publishing failed:[/red] {err}")
699
+ raise typer.Exit(1)
700
+
701
+
702
+ @envs_app.command(name="add-path")
703
+ def envs_add_path(
704
+ path: Path = typer.Argument(..., exists=True, dir_okay=True, file_okay=False),
705
+ alias: Optional[str] = typer.Option(None, "--alias", "-a", help="Alias for the environment"),
706
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Name/slug for the environment"),
707
+ auto_confirm: bool = typer.Option(False, "--yes", "-y"),
708
+ ):
709
+ """
710
+ Register an external Python environment with qBraid.
711
+
712
+ This allows you to use existing Python environments (conda, venv, etc.)
713
+ with qBraid commands like kernel management and activation.
714
+
715
+ Examples:
716
+ $ qbraid envs add-path /path/to/my_env --alias myenv
717
+ $ qbraid envs add-path ~/conda/envs/quantum --name quantum_abc123
718
+ """
719
+ from qbraid_core.services.environments.client import EnvironmentManagerClient
720
+
721
+ def register():
722
+ emc = EnvironmentManagerClient()
723
+ result = emc.register_external_environment(
724
+ path=path,
725
+ name=alias or name or path.name,
726
+ )
727
+ return result
728
+
729
+ # Get info first to show user
730
+ try:
731
+ emc = EnvironmentManagerClient()
732
+ # Do a dry run to validate and get info
733
+ from qbraid_core.system.executables import is_valid_python
734
+
735
+ python_candidates = [
736
+ path / "bin" / "python",
737
+ path / "bin" / "python3",
738
+ path / "Scripts" / "python.exe",
739
+ ]
740
+
741
+ python_path = None
742
+ for candidate in python_candidates:
743
+ if is_valid_python(candidate):
744
+ python_path = candidate
745
+ break
746
+
747
+ if not python_path:
748
+ handle_error(
749
+ error_type="ValidationError",
750
+ message=f"No valid Python executable found in {path}"
751
+ )
752
+ return
753
+
754
+ # Confirm with user
755
+ if not auto_confirm:
756
+ console = Console()
757
+ console.print("\n[bold]📦 Registering external environment:[/bold]")
758
+ console.print(f" Path: {path}")
759
+ console.print(f" Name: {alias or name or path.name}")
760
+ console.print(f" Python: {python_path}")
761
+
762
+ if not typer.confirm("\nProceed with registration?"):
763
+ typer.echo("❌ Registration cancelled.")
764
+ return
765
+
766
+ result = run_progress_task(
767
+ register,
768
+ description="Registering environment...",
769
+ error_message="Failed to register environment"
770
+ )
771
+
772
+ typer.echo(f"\n✅ Environment '{result['name']}' registered successfully!")
773
+ typer.echo(f" Env ID: {result['env_id']}")
774
+ typer.echo(f"\nYou can now:")
775
+ typer.echo(f" - Add kernel: qbraid kernels add {result['name']}")
776
+ typer.echo(f" - View in list: qbraid envs list")
777
+
778
+ except Exception as e:
779
+ handle_error(message=str(e))
780
+
781
+
782
+ @envs_app.command(name="remove-path")
783
+ def envs_remove_path(
784
+ name: str = typer.Argument(..., help="Name or alias of environment to unregister"),
785
+ auto_confirm: bool = typer.Option(False, "--yes", "-y"),
786
+ ):
787
+ """
788
+ Unregister an external environment from qBraid.
789
+
790
+ This only removes the environment from qBraid's registry.
791
+ The actual environment files are NOT deleted.
792
+ """
793
+ from qbraid_core.services.environments.client import EnvironmentManagerClient
794
+
795
+ def unregister(slug: str):
796
+ emc = EnvironmentManagerClient()
797
+ result = emc.unregister_external_environment(slug)
798
+ return result
799
+
800
+ # Find environment
801
+ try:
802
+ from qbraid_core.services.environments.registry import EnvironmentRegistryManager
803
+
804
+ registry_mgr = EnvironmentRegistryManager()
805
+ # Try to find by name first, then by env_id
806
+ found = registry_mgr.find_by_name(name)
807
+ if not found:
808
+ found = registry_mgr.find_by_env_id(name)
809
+
810
+ if not found:
811
+ handle_error(
812
+ error_type="NotFoundError",
813
+ message=f"Environment '{name}' not found in registry. "
814
+ "Use name (if unique) or env_id to reference the environment."
815
+ )
816
+ return
817
+
818
+ env_id, entry = found
819
+
820
+ if entry.type != "external":
821
+ console = Console()
822
+ console.print(f"[yellow]⚠️ Warning: '{name}' is a qBraid-managed environment.[/yellow]")
823
+ console.print(f" Use 'qbraid envs remove --name {name}' to fully remove it.")
824
+ return
825
+
826
+ if not auto_confirm:
827
+ console = Console()
828
+ console.print(f"\n[yellow]⚠️ Unregistering environment '{entry.name}'[/yellow]")
829
+ console.print(f" Env ID: {env_id}")
830
+ console.print(f" Path: {entry.path}")
831
+ console.print(f" Type: {entry.type}")
832
+ console.print(f"\n Note: Files at {entry.path} will NOT be deleted.")
833
+
834
+ if not typer.confirm("\nProceed?"):
835
+ typer.echo("❌ Unregistration cancelled.")
836
+ return
837
+
838
+ result = run_progress_task(
839
+ unregister,
840
+ env_id, # Can be name or env_id - method handles both
841
+ description="Unregistering environment...",
842
+ error_message="Failed to unregister environment"
843
+ )
844
+
845
+ typer.echo(f"✅ Environment '{name}' unregistered from qBraid.")
846
+
847
+ except Exception as e:
848
+ handle_error(message=str(e))
849
+
850
+
851
+ @envs_app.command(name="sync")
852
+ def envs_sync():
853
+ """
854
+ Synchronize environment registry with filesystem.
855
+
856
+ This will:
857
+ - Remove registry entries for deleted environments
858
+ - Auto-discover new environments in default paths
859
+ - Verify all registered paths still exist
860
+ """
861
+ from qbraid_core.services.environments.client import EnvironmentManagerClient
862
+
863
+ def sync():
864
+ emc = EnvironmentManagerClient()
865
+ stats = emc.sync_registry()
866
+ return stats
867
+
868
+ result = run_progress_task(
869
+ sync,
870
+ description="Synchronizing environment registry...",
871
+ error_message="Failed to sync registry"
872
+ )
873
+
874
+ console = Console()
875
+
876
+ if result["discovered"] > 0:
877
+ console.print(f"[green]✅ Registry synced: {result['discovered']} new environment(s) discovered[/green]")
878
+ elif result["removed"] > 0:
879
+ console.print(f"[green]✅ Registry synced: {result['removed']} invalid entry(ies) removed[/green]")
880
+ else:
881
+ console.print("[green]✅ Registry synced: No changes detected[/green]")
882
+
883
+ # Show summary
884
+ console.print(f"\n[bold]Summary:[/bold]")
885
+ console.print(f" Verified: {result['verified']}")
886
+ console.print(f" Discovered: {result['discovered']}")
887
+ console.print(f" Removed: {result['removed']}")
888
+
889
+
263
890
  if __name__ == "__main__":
264
891
  envs_app()