nepher-cli 0.2.0__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.
@@ -0,0 +1,466 @@
1
+ """envhub command group — list, download, upload, cache, view, config.
2
+
3
+ Talks directly to the envhub-backend REST API. The standalone ``nepher``
4
+ CLI (from the envhub package) is left unchanged; this group provides the
5
+ same operations from inside the unified npcli interface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import tempfile
13
+ import zipfile
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import click
18
+ import httpx
19
+ from rich.console import Console
20
+
21
+ from nepher_cli.config import ENVHUB_BACKEND
22
+ from nepher_cli.core.credentials import get_auth_headers, get_stored_api_key
23
+ from nepher_cli.core.http import parse_error_body
24
+ from nepher_cli.envhub.cache import is_cached_env, list_cached_env_dirs, resolve_cache_dir
25
+ from nepher_cli.envhub.config import (
26
+ get_value as get_envhub_config_value,
27
+ list_values as list_envhub_config_values,
28
+ mask_secret as mask_envhub_config_secret,
29
+ parse_config_value,
30
+ reset_config as reset_envhub_config,
31
+ set_value as set_envhub_config_value,
32
+ )
33
+
34
+ console = Console(stderr=True)
35
+
36
+
37
+ def _headers(api_key: str | None) -> dict[str, str]:
38
+ h = get_auth_headers(api_key)
39
+ if not h:
40
+ ak = get_stored_api_key()
41
+ if ak:
42
+ h = {"X-API-Key": ak}
43
+ return h
44
+
45
+
46
+ def _base() -> str:
47
+ return ENVHUB_BACKEND.rstrip("/")
48
+
49
+
50
+ @click.group("envhub")
51
+ def envhub() -> None:
52
+ """Manage Isaac Lab simulation environment bundles via EnvHub."""
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # list
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @envhub.command("list")
61
+ @click.option("--category", default=None, help="Filter by category.")
62
+ @click.option("--type", "env_type", type=click.Choice(["usd", "preset"]), default=None, help="Filter by type.")
63
+ @click.option("--benchmark", is_flag=True, help="Show only benchmark environments.")
64
+ @click.option("--eval-benchmarks", "eval_benchmarks", is_flag=True, help="Show only evaluation benchmarks.")
65
+ @click.option("--search", default=None, help="Full-text search query.")
66
+ @click.option("--limit", type=int, default=None, help="Maximum number of results.")
67
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
68
+ @click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY")
69
+ def envhub_list(
70
+ category: str | None,
71
+ env_type: str | None,
72
+ benchmark: bool,
73
+ eval_benchmarks: bool,
74
+ search: str | None,
75
+ limit: int | None,
76
+ output_json: bool,
77
+ api_key: str | None,
78
+ ) -> None:
79
+ """List available Isaac Lab environments."""
80
+ params: dict[str, Any] = {}
81
+ if category:
82
+ params["category"] = category
83
+ if env_type:
84
+ params["type"] = env_type
85
+ if benchmark:
86
+ params["benchmark"] = "true"
87
+ if search:
88
+ params["search"] = search
89
+ if limit:
90
+ params["limit"] = limit
91
+
92
+ endpoint = f"{_base()}/api/v1/envs/eval-benchmarks/" if eval_benchmarks else f"{_base()}/api/v1/envs/"
93
+
94
+ headers = _headers(api_key)
95
+ try:
96
+ r = httpx.get(endpoint, headers=headers, params=params, timeout=30.0)
97
+ except httpx.RequestError as e:
98
+ console.print(f"[red]Network error[/red]: {e}")
99
+ raise SystemExit(1) from e
100
+
101
+ if r.status_code != 200:
102
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
103
+ raise SystemExit(1)
104
+
105
+ try:
106
+ data = r.json()
107
+ except Exception:
108
+ console.print("[red]Invalid JSON response.[/red]")
109
+ raise SystemExit(1)
110
+
111
+ if isinstance(data, list):
112
+ envs = data
113
+ elif isinstance(data, dict):
114
+ envs = data.get("environments", data.get("results", data.get("items", [])))
115
+ else:
116
+ envs = []
117
+
118
+ if output_json:
119
+ click.echo(json.dumps(envs, indent=2))
120
+ return
121
+
122
+ if not envs:
123
+ console.print("[dim]No environments found.[/dim]")
124
+ return
125
+
126
+ console.print(f"[bold]Found {len(envs)} environment(s):[/bold]\n")
127
+ for env in envs:
128
+ click.echo(f" {env.get('id', 'N/A')}")
129
+ click.echo(f" Name: {env.get('original_name', 'N/A')}")
130
+ click.echo(f" Version: {env.get('version', 'N/A')}")
131
+ click.echo(f" Category: {env.get('category', 'N/A')}")
132
+ click.echo(f" Type: {env.get('type', 'N/A')}")
133
+ click.echo(f" Status: {env.get('status', 'N/A')}")
134
+ if env.get("is_benchmark"):
135
+ click.echo(" Benchmark: Yes")
136
+ if env.get("description"):
137
+ click.echo(f" Description: {env.get('description')}")
138
+ click.echo()
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # download
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ @envhub.command("download")
147
+ @click.argument("env_id")
148
+ @click.option("--cache-dir", type=click.Path(), default=None, help="Override local cache directory.")
149
+ @click.option("--force", is_flag=True, help="Re-download even if already cached.")
150
+ @click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY")
151
+ def envhub_download(env_id: str, cache_dir: str | None, force: bool, api_key: str | None) -> None:
152
+ """Download an environment bundle and cache it locally.
153
+
154
+ The bundle is extracted to ~/.nepher/cache/<env_id>/ by default.
155
+ """
156
+ cache_root = resolve_cache_dir(cache_dir)
157
+ env_cache = cache_root / env_id
158
+
159
+ if is_cached_env(env_cache) and not force:
160
+ console.print(f"[dim]Already cached:[/dim] {env_cache}")
161
+ return
162
+
163
+ headers = _headers(api_key)
164
+ url = f"{_base()}/api/v1/envs/{env_id}/download"
165
+ console.print(f"Downloading [bold]{env_id}[/bold]...")
166
+ try:
167
+ with httpx.stream("GET", url, headers=headers, timeout=600.0, follow_redirects=True) as r:
168
+ if r.status_code != 200:
169
+ body = r.read().decode(errors="replace")
170
+ console.print(f"[red]{parse_error_body(body) or body.strip() or f'HTTP {r.status_code}'}[/red]")
171
+ raise SystemExit(1)
172
+
173
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
174
+ tmp_path = Path(tmp.name)
175
+ for chunk in r.iter_bytes(chunk_size=65536):
176
+ tmp.write(chunk)
177
+ except httpx.RequestError as e:
178
+ console.print(f"[red]Network error[/red]: {e}")
179
+ raise SystemExit(1) from e
180
+
181
+ console.print("Extracting bundle...")
182
+ env_cache.mkdir(parents=True, exist_ok=True)
183
+ try:
184
+ with zipfile.ZipFile(tmp_path, "r") as zf:
185
+ zf.extractall(env_cache)
186
+ except zipfile.BadZipFile:
187
+ console.print("[red]Downloaded file is not a valid ZIP archive.[/red]")
188
+ shutil.rmtree(env_cache, ignore_errors=True)
189
+ tmp_path.unlink(missing_ok=True)
190
+ raise SystemExit(1)
191
+ finally:
192
+ tmp_path.unlink(missing_ok=True)
193
+
194
+ console.print(f"[green]Downloaded and cached:[/green] {env_cache}")
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # upload
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ @envhub.command("upload")
203
+ @click.argument("path", type=click.Path(exists=True))
204
+ @click.option("--category", required=True, help="Environment category (e.g. navigation, manipulation).")
205
+ @click.option("--benchmark", is_flag=True, help="Mark as a benchmark environment.")
206
+ @click.option("--force", is_flag=True, help="Upload even if a duplicate exists.")
207
+ @click.option("--thumbnail", type=click.Path(exists=True), default=None, help="Optional thumbnail image path.")
208
+ @click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY")
209
+ def envhub_upload(path: str, category: str, benchmark: bool, force: bool, thumbnail: str | None, api_key: str | None) -> None:
210
+ """Upload an Isaac Lab environment bundle.
211
+
212
+ PATH must be a directory containing a valid manifest.yaml, or a pre-built
213
+ .zip archive. Directories are zipped automatically before upload.
214
+ """
215
+ headers = _headers(api_key)
216
+ if not headers:
217
+ console.print("[yellow]Not authenticated.[/yellow] Run [bold]npcli account login[/bold] or pass [bold]--api-key[/bold].")
218
+ raise SystemExit(1)
219
+
220
+ bundle_path = Path(path)
221
+ tmp_zip: Path | None = None
222
+
223
+ try:
224
+ if bundle_path.is_dir():
225
+ manifest = bundle_path / "manifest.yaml"
226
+ if not manifest.exists():
227
+ console.print("[red]Invalid bundle[/red]: manifest.yaml not found in directory.")
228
+ raise SystemExit(1)
229
+ console.print("Zipping bundle...")
230
+ tmp_fd, tmp_name = tempfile.mkstemp(suffix=".zip")
231
+ import os
232
+ os.close(tmp_fd)
233
+ tmp_zip = Path(tmp_name)
234
+ with zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_DEFLATED) as zf:
235
+ for file in bundle_path.rglob("*"):
236
+ if file.is_file():
237
+ zf.write(file, file.relative_to(bundle_path))
238
+ upload_path = tmp_zip
239
+ else:
240
+ upload_path = bundle_path
241
+
242
+ console.print(f"Uploading [bold]{bundle_path.name}[/bold]...")
243
+ data_fields = {"category": category}
244
+ if benchmark:
245
+ data_fields["benchmark"] = "true"
246
+ if force:
247
+ data_fields["force"] = "true"
248
+
249
+ with open(upload_path, "rb") as f:
250
+ files: dict[str, Any] = {"file": (upload_path.name, f, "application/zip")}
251
+ if thumbnail:
252
+ thumb_path = Path(thumbnail)
253
+ files["thumbnail"] = (thumb_path.name, open(thumbnail, "rb"), "image/jpeg")
254
+
255
+ try:
256
+ r = httpx.post(
257
+ f"{_base()}/api/v1/envs/",
258
+ headers=headers,
259
+ data=data_fields,
260
+ files=files,
261
+ timeout=600.0,
262
+ )
263
+ except httpx.RequestError as e:
264
+ console.print(f"[red]Network error[/red]: {e}")
265
+ raise SystemExit(1) from e
266
+
267
+ if r.status_code in (200, 201):
268
+ body = r.json()
269
+ console.print("[green]Environment uploaded successfully.[/green]")
270
+ console.print(f" ID: {body.get('id', '?')}")
271
+ else:
272
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
273
+ raise SystemExit(1)
274
+
275
+ finally:
276
+ if tmp_zip and tmp_zip.exists():
277
+ tmp_zip.unlink()
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # cache sub-group
282
+ # ---------------------------------------------------------------------------
283
+
284
+
285
+ @envhub.group("cache")
286
+ def envhub_cache() -> None:
287
+ """Manage the local environment bundle cache."""
288
+
289
+
290
+ @envhub_cache.command("list")
291
+ @click.option("--cache-dir", type=click.Path(), default=None)
292
+ def cache_list(cache_dir: str | None) -> None:
293
+ """List locally cached environments."""
294
+ root = resolve_cache_dir(cache_dir)
295
+ entries = list_cached_env_dirs(root)
296
+ if not entries:
297
+ console.print("[dim]No cached environments.[/dim]")
298
+ return
299
+
300
+ console.print(f"[bold]Cached environments ({len(entries)}):[/bold]")
301
+ for e in sorted(entries):
302
+ size = sum(f.stat().st_size for f in e.rglob("*") if f.is_file())
303
+ console.print(f" {e.name} ({size / 1024 / 1024:.1f} MB)")
304
+
305
+
306
+ @envhub_cache.command("clear")
307
+ @click.argument("env_id", required=False)
308
+ @click.option("--cache-dir", type=click.Path(), default=None)
309
+ def cache_clear(env_id: str | None, cache_dir: str | None) -> None:
310
+ """Clear cache — all environments or a specific one."""
311
+ root = resolve_cache_dir(cache_dir)
312
+ if env_id:
313
+ target = root / env_id
314
+ if target.exists():
315
+ shutil.rmtree(target)
316
+ console.print(f"[green]Cleared cache for[/green] {env_id}")
317
+ else:
318
+ console.print(f"[yellow]{env_id} is not cached.[/yellow]")
319
+ else:
320
+ if root.exists():
321
+ shutil.rmtree(root)
322
+ console.print("[green]Cleared all cached environments.[/green]")
323
+
324
+
325
+ @envhub_cache.command("info")
326
+ @click.option("--cache-dir", type=click.Path(), default=None)
327
+ def cache_info(cache_dir: str | None) -> None:
328
+ """Show cache size and location."""
329
+ root = resolve_cache_dir(cache_dir)
330
+ console.print(f"Cache directory: {root}")
331
+ if not root.exists():
332
+ console.print(" (empty — nothing cached yet)")
333
+ return
334
+
335
+ entries = list_cached_env_dirs(root)
336
+ total = sum(f.stat().st_size for d in entries for f in d.rglob("*") if f.is_file())
337
+ console.print(f" Environments: {len(entries)}")
338
+ console.print(f" Total size: {total / 1024 / 1024:.2f} MB")
339
+
340
+ if entries:
341
+ click.echo("\n Environments:")
342
+ for e in sorted(entries):
343
+ size = sum(f.stat().st_size for f in e.rglob("*") if f.is_file())
344
+ click.echo(f" {e.name}: {size / 1024 / 1024:.2f} MB")
345
+
346
+
347
+ @envhub_cache.command("migrate")
348
+ @click.argument("new_path", type=click.Path())
349
+ @click.option("--cache-dir", type=click.Path(), default=None)
350
+ def cache_migrate(new_path: str, cache_dir: str | None) -> None:
351
+ """Move the local cache to a new directory."""
352
+ old_root = resolve_cache_dir(cache_dir)
353
+ new_root = Path(new_path)
354
+ if not old_root.exists():
355
+ console.print("[yellow]Nothing to migrate — cache is empty.[/yellow]")
356
+ return
357
+ new_root.parent.mkdir(parents=True, exist_ok=True)
358
+ shutil.move(str(old_root), str(new_root))
359
+ console.print(f"[green]Cache migrated to[/green] {new_root}")
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # view
364
+ # ---------------------------------------------------------------------------
365
+
366
+
367
+ @envhub.command("view")
368
+ @click.argument("env_id")
369
+ @click.option("--category", default=None, help="Environment category (resolved from manifest if omitted).")
370
+ @click.option("--scene", default=None, help="Scene name or index.")
371
+ @click.option("--cache-dir", type=click.Path(), default=None)
372
+ def envhub_view(env_id: str, category: str | None, scene: str | None, cache_dir: str | None) -> None:
373
+ """View an environment in Isaac Sim (requires isaaclab on PATH).
374
+
375
+ The environment must be downloaded first via [bold]npcli envhub download[/bold].
376
+ """
377
+ try:
378
+ from nepher.loader.registry import load_env, load_scene # type: ignore[import]
379
+ except ImportError:
380
+ console.print(
381
+ "[red]Isaac Lab not available.[/red]\n\n"
382
+ "The [bold]view[/bold] command requires Isaac Lab to be installed in the current Python "
383
+ "environment. Run it through Isaac Lab's Python interpreter:\n\n"
384
+ " [bold]isaaclab.bat -p -c 'import nepher_cli; nepher_cli.cli.main()' "
385
+ "envhub view <env_id>[/bold]\n\n"
386
+ "Or install Isaac Lab: https://isaac-sim.github.io/IsaacLab/"
387
+ )
388
+ raise SystemExit(1)
389
+
390
+ root = resolve_cache_dir(cache_dir)
391
+ env_path = root / env_id
392
+ if not is_cached_env(env_path):
393
+ console.print(
394
+ f"[yellow]{env_id} is not cached.[/yellow] "
395
+ f"Run [bold]npcli envhub download {env_id}[/bold] first."
396
+ )
397
+ raise SystemExit(1)
398
+
399
+ try:
400
+ env = load_env(env_id, category)
401
+ except Exception as e:
402
+ console.print(f"[red]Failed to load environment[/red]: {e}")
403
+ raise SystemExit(1) from e
404
+
405
+ if not scene:
406
+ click.echo(f"Environment: {env_id}")
407
+ scenes = env.get_all_scenes() if hasattr(env, "get_all_scenes") else []
408
+ click.echo(f"Scenes ({len(scenes)}):")
409
+ for i, s in enumerate(scenes):
410
+ click.echo(f" [{i}] {s.name}")
411
+ return
412
+
413
+ console.print(f"Launching scene [bold]{scene}[/bold] in Isaac Sim...")
414
+ # Actual rendering requires the full IsaacLab runtime; delegate back to the
415
+ # installed nepher package's view script which handles AppLauncher setup.
416
+ import subprocess, sys
417
+ subprocess.run([sys.executable, "-m", "nepher", "view", env_id, "--scene", scene], check=False)
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # config sub-group
422
+ # ---------------------------------------------------------------------------
423
+
424
+
425
+ @envhub.group("config")
426
+ def envhub_config() -> None:
427
+ """Manage EnvHub configuration (shared with the ``nepher`` CLI)."""
428
+
429
+
430
+ @envhub_config.command("get")
431
+ @click.argument("key")
432
+ def config_get(key: str) -> None:
433
+ """Get a configuration value."""
434
+ val = get_envhub_config_value(key)
435
+ if val is None:
436
+ console.print(f"[yellow]Key '{key}' not set.[/yellow]")
437
+ else:
438
+ click.echo(mask_envhub_config_secret(key, val))
439
+
440
+
441
+ @envhub_config.command("set")
442
+ @click.argument("key")
443
+ @click.argument("value")
444
+ def config_set(key: str, value: str) -> None:
445
+ """Set a configuration value."""
446
+ parsed = parse_config_value(value)
447
+ set_envhub_config_value(key, parsed)
448
+ display = mask_envhub_config_secret(key, parsed) if isinstance(parsed, str) else parsed
449
+ console.print(f"[green]Set[/green] {key} = {display}")
450
+
451
+
452
+ @envhub_config.command("list")
453
+ def config_list() -> None:
454
+ """List EnvHub configuration values."""
455
+ console.print("[bold]Configuration:[/bold]")
456
+ for key, value in list_envhub_config_values().items():
457
+ click.echo(f" {key}: {value}")
458
+
459
+
460
+ @envhub_config.command("reset")
461
+ def config_reset() -> None:
462
+ """Reset configuration to defaults."""
463
+ if reset_envhub_config():
464
+ console.print("[green]Configuration reset to defaults.[/green]")
465
+ else:
466
+ console.print("[dim]No configuration file to reset.[/dim]")