hypercli-cli 0.4.6__tar.gz → 0.4.7__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.
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/PKG-INFO +1 -1
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/billing.py +3 -3
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/cli.py +15 -15
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/comfyui.py +51 -32
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/instances.py +5 -4
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/jobs.py +7 -6
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/llm.py +3 -3
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/renders.py +7 -7
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/tui/job_monitor.py +46 -29
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/user.py +2 -2
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/pyproject.toml +1 -1
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/.gitignore +0 -0
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/README.md +0 -0
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/__init__.py +0 -0
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/output.py +0 -0
- {hypercli_cli-0.4.6 → hypercli_cli-0.4.7}/hypercli_cli/tui/__init__.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""hyper billing commands"""
|
|
2
2
|
import typer
|
|
3
|
-
from hypercli import
|
|
3
|
+
from hypercli import HyperCLI
|
|
4
4
|
from .output import output, console, spinner
|
|
5
5
|
|
|
6
6
|
app = typer.Typer(help="Billing and balance commands")
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def get_client() ->
|
|
10
|
-
return
|
|
9
|
+
def get_client() -> HyperCLI:
|
|
10
|
+
return HyperCLI()
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@app.command("balance")
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""HyperCLI - Main entry point"""
|
|
2
2
|
import sys
|
|
3
3
|
import typer
|
|
4
4
|
from rich.console import Console
|
|
5
5
|
from rich.prompt import Prompt
|
|
6
6
|
|
|
7
|
-
from hypercli import
|
|
7
|
+
from hypercli import HyperCLI, APIError, configure
|
|
8
8
|
from hypercli.config import CONFIG_FILE
|
|
9
9
|
|
|
10
10
|
from . import billing, comfyui, instances, jobs, llm, renders, user
|
|
@@ -49,8 +49,8 @@ def fuzzy_match(input_str: str, options: list[str], threshold: float = 0.5) -> l
|
|
|
49
49
|
return [opt for opt, _ in matches[:3]]
|
|
50
50
|
|
|
51
51
|
app = typer.Typer(
|
|
52
|
-
name="
|
|
53
|
-
help="HyperCLI
|
|
52
|
+
name="hyper",
|
|
53
|
+
help="HyperCLI - GPU orchestration and LLM API",
|
|
54
54
|
no_args_is_help=True,
|
|
55
55
|
rich_markup_mode="rich",
|
|
56
56
|
)
|
|
@@ -67,11 +67,11 @@ app.add_typer(user.app, name="user")
|
|
|
67
67
|
|
|
68
68
|
@app.command("configure")
|
|
69
69
|
def configure_cmd():
|
|
70
|
-
"""Configure
|
|
70
|
+
"""Configure HyperCLI with your API key and API URL"""
|
|
71
71
|
import getpass
|
|
72
72
|
from hypercli.config import get_api_key, get_api_url, DEFAULT_API_URL
|
|
73
73
|
|
|
74
|
-
console.print("\n[bold cyan]
|
|
74
|
+
console.print("\n[bold cyan]HyperCLI Configuration[/bold cyan]\n")
|
|
75
75
|
|
|
76
76
|
# Show current config
|
|
77
77
|
current_key = get_api_key()
|
|
@@ -111,7 +111,7 @@ def configure_cmd():
|
|
|
111
111
|
console.print(f" API key: {preview}")
|
|
112
112
|
if final_url:
|
|
113
113
|
console.print(f" API URL: {final_url}")
|
|
114
|
-
console.print("\nTest your setup with: [cyan]
|
|
114
|
+
console.print("\nTest your setup with: [cyan]hyper billing balance[/cyan]\n")
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
@app.callback()
|
|
@@ -119,19 +119,19 @@ def main(
|
|
|
119
119
|
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
120
120
|
):
|
|
121
121
|
"""
|
|
122
|
-
[bold cyan]
|
|
122
|
+
[bold cyan]HyperCLI[/bold cyan] - GPU orchestration and LLM API
|
|
123
123
|
|
|
124
|
-
Set your API key: [green]
|
|
124
|
+
Set your API key: [green]hyper configure[/green]
|
|
125
125
|
|
|
126
126
|
Get started:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
hyper instances list Browse available GPUs
|
|
128
|
+
hyper instances launch Launch a GPU instance
|
|
129
|
+
hyper jobs list View your running jobs
|
|
130
|
+
hyper llm chat -i Start a chat
|
|
131
131
|
"""
|
|
132
132
|
if version:
|
|
133
133
|
from . import __version__
|
|
134
|
-
console.print(f"
|
|
134
|
+
console.print(f"hyper version {__version__}")
|
|
135
135
|
raise typer.Exit()
|
|
136
136
|
|
|
137
137
|
|
|
@@ -167,7 +167,7 @@ def cli():
|
|
|
167
167
|
# Check for region errors
|
|
168
168
|
if "region" in detail.lower() and "not found" in detail.lower():
|
|
169
169
|
console.print(f"[bold red]Error:[/bold red] {detail}")
|
|
170
|
-
console.print("\n[dim]Tip: Use '
|
|
170
|
+
console.print("\n[dim]Tip: Use 'hyper jobs regions' to see available regions[/dim]")
|
|
171
171
|
sys.exit(1)
|
|
172
172
|
|
|
173
173
|
# Generic API error
|
|
@@ -7,15 +7,15 @@ from typing import Optional
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from hypercli import
|
|
10
|
+
from hypercli import HyperCLI, ComfyUIJob, APIError, apply_params, apply_graph_modes, load_template, graph_to_api
|
|
11
11
|
from .output import console, error, success, spinner
|
|
12
12
|
from .tui import JobStatus, run_job_monitor
|
|
13
13
|
|
|
14
14
|
app = typer.Typer(help="Run ComfyUI workflows on GPU")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def get_client() ->
|
|
18
|
-
return
|
|
17
|
+
def get_client() -> HyperCLI:
|
|
18
|
+
return HyperCLI()
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _run_workflow(
|
|
@@ -220,14 +220,16 @@ def run(
|
|
|
220
220
|
gpu_count: int = typer.Option(1, "--count", help="Number of GPUs"),
|
|
221
221
|
interruptible: bool = typer.Option(True, "--interruptible/--on-demand", help="Use interruptible instances"),
|
|
222
222
|
region: Optional[str] = typer.Option(None, "--region", help="Region (e.g., us-east, eu-west, asia-east)"),
|
|
223
|
+
runtime: int = typer.Option(3600, "--runtime", help="Instance runtime in seconds"),
|
|
223
224
|
output_dir: str = typer.Option(".", "--output-dir", "-d", help="Output directory"),
|
|
224
|
-
timeout: int = typer.Option(600, "--timeout", "-t", help="
|
|
225
|
+
timeout: int = typer.Option(600, "--timeout", "-t", help="Workflow timeout in seconds"),
|
|
225
226
|
num: int = typer.Option(1, "--num", "-N", help="Number of images to generate"),
|
|
226
227
|
new: bool = typer.Option(False, "--new", help="Always launch new instance"),
|
|
227
228
|
instance: Optional[str] = typer.Option(None, "--instance", "-i", help="Connect to job by ID, hostname, or IP"),
|
|
228
|
-
lb:
|
|
229
|
-
auth: bool = typer.Option(
|
|
229
|
+
lb: int = typer.Option(8188, "--lb", help="HTTPS load balancer port (default: 8188, use 0 to disable)"),
|
|
230
|
+
auth: bool = typer.Option(True, "--auth/--no-auth", help="Bearer token auth (default: enabled)"),
|
|
230
231
|
stdout: bool = typer.Option(False, "--stdout", help="Output to stdout instead of TUI"),
|
|
232
|
+
cancel_on_exit: bool = typer.Option(False, "--cancel-on-exit", help="Cancel job when exiting with Ctrl+C"),
|
|
231
233
|
debug: bool = typer.Option(False, "--debug", help="Debug mode: stdout + verbose errors"),
|
|
232
234
|
workflow_json: bool = typer.Option(False, "--workflow-json", help="Output generated workflow JSON and exit (don't run)"),
|
|
233
235
|
nodes: Optional[str] = typer.Option(None, "--nodes", help="Node-specific params as JSON: '{\"node_id\": {\"image\": \"file.png\"}}'"),
|
|
@@ -301,24 +303,28 @@ def run(
|
|
|
301
303
|
|
|
302
304
|
# Get or create job
|
|
303
305
|
try:
|
|
306
|
+
# lb=0 means disabled
|
|
307
|
+
lb_port = lb if lb else None
|
|
308
|
+
|
|
304
309
|
if instance:
|
|
305
310
|
# Connect to specific instance by ID, hostname, or IP
|
|
306
311
|
with spinner(f"Connecting to instance {instance}..."):
|
|
307
|
-
job = ComfyUIJob.get_by_instance(
|
|
312
|
+
job = ComfyUIJob.get_by_instance(client, instance)
|
|
308
313
|
job.template = template
|
|
309
|
-
job.use_lb =
|
|
314
|
+
job.use_lb = lb_port is not None
|
|
310
315
|
job.use_auth = auth
|
|
311
316
|
else:
|
|
312
317
|
# Get or create job with template env var
|
|
313
318
|
with spinner(f"Getting ComfyUI instance for {template}..."):
|
|
314
319
|
job = ComfyUIJob.get_or_create_for_template(
|
|
315
|
-
|
|
320
|
+
client,
|
|
316
321
|
template=template,
|
|
317
322
|
gpu_type=gpu_type,
|
|
318
323
|
gpu_count=gpu_count,
|
|
319
324
|
region=region,
|
|
325
|
+
runtime=runtime,
|
|
320
326
|
reuse=not new,
|
|
321
|
-
lb=
|
|
327
|
+
lb=lb_port,
|
|
322
328
|
auth=auth,
|
|
323
329
|
interruptible=interruptible,
|
|
324
330
|
)
|
|
@@ -438,26 +444,39 @@ def run(
|
|
|
438
444
|
seed_info = f"seed: {iteration_seed}" if iteration_seed else "default seed"
|
|
439
445
|
console.print(f"\n[bold]Image {i+1}/{num}[/bold] ({seed_info})")
|
|
440
446
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
447
|
+
try:
|
|
448
|
+
if stdout or debug:
|
|
449
|
+
# Simple mode without TUI
|
|
450
|
+
_run_simple(job, template, params, timeout, Path(output_dir), debug=debug)
|
|
451
|
+
else:
|
|
452
|
+
# TUI mode with status pane
|
|
453
|
+
status_q: Queue = Queue()
|
|
454
|
+
|
|
455
|
+
# Start workflow in background thread
|
|
456
|
+
workflow_thread = threading.Thread(
|
|
457
|
+
target=_run_workflow,
|
|
458
|
+
args=(job, template, params, timeout, Path(output_dir), status_q),
|
|
459
|
+
daemon=True,
|
|
460
|
+
)
|
|
461
|
+
workflow_thread.start()
|
|
455
462
|
|
|
456
|
-
|
|
457
|
-
|
|
463
|
+
# Run TUI in main thread
|
|
464
|
+
run_job_monitor(job.job_id, status_q=status_q, stop_on_status_complete=True, cancel_on_exit=cancel_on_exit)
|
|
458
465
|
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
# Wait for workflow thread to finish
|
|
467
|
+
workflow_thread.join(timeout=5)
|
|
468
|
+
except KeyboardInterrupt:
|
|
469
|
+
console.print("\n[dim]Interrupted[/dim]")
|
|
470
|
+
if cancel_on_exit:
|
|
471
|
+
try:
|
|
472
|
+
job.refresh()
|
|
473
|
+
if job.job.state in ("queued", "assigned", "running"):
|
|
474
|
+
console.print("[yellow]Cancelling job...[/yellow]")
|
|
475
|
+
client.jobs.cancel(job.job_id)
|
|
476
|
+
console.print("[green]Job cancelled[/green]")
|
|
477
|
+
except Exception as e:
|
|
478
|
+
console.print(f"[red]Failed to cancel: {e}[/red]")
|
|
479
|
+
raise typer.Exit(1)
|
|
461
480
|
|
|
462
481
|
|
|
463
482
|
def _run_simple(job: ComfyUIJob, template: str, params: dict, timeout: int, output_dir: Path, debug: bool = False):
|
|
@@ -680,7 +699,7 @@ def status():
|
|
|
680
699
|
client = get_client()
|
|
681
700
|
|
|
682
701
|
with spinner("Checking for running jobs..."):
|
|
683
|
-
job = ComfyUIJob.get_running(
|
|
702
|
+
job = ComfyUIJob.get_running(client)
|
|
684
703
|
|
|
685
704
|
if not job:
|
|
686
705
|
console.print("[dim]No running ComfyUI job[/dim]")
|
|
@@ -717,10 +736,10 @@ def download(
|
|
|
717
736
|
# Get job
|
|
718
737
|
if instance:
|
|
719
738
|
with spinner(f"Connecting to {instance}..."):
|
|
720
|
-
job = ComfyUIJob.get_by_instance(
|
|
739
|
+
job = ComfyUIJob.get_by_instance(client, instance)
|
|
721
740
|
else:
|
|
722
741
|
with spinner("Finding running job..."):
|
|
723
|
-
job = ComfyUIJob.get_running(
|
|
742
|
+
job = ComfyUIJob.get_running(client)
|
|
724
743
|
if not job:
|
|
725
744
|
error("No running ComfyUI job found. Use --instance to specify one.")
|
|
726
745
|
raise typer.Exit(1)
|
|
@@ -809,7 +828,7 @@ def stop():
|
|
|
809
828
|
client = get_client()
|
|
810
829
|
|
|
811
830
|
with spinner("Finding running job..."):
|
|
812
|
-
job = ComfyUIJob.get_running(
|
|
831
|
+
job = ComfyUIJob.get_running(client)
|
|
813
832
|
|
|
814
833
|
if not job:
|
|
815
834
|
console.print("[dim]No running ComfyUI job[/dim]")
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""hyper instances commands"""
|
|
2
2
|
import typer
|
|
3
3
|
from typing import Optional
|
|
4
|
-
from hypercli import
|
|
4
|
+
from hypercli import HyperCLI
|
|
5
5
|
from .output import output, console, success, spinner
|
|
6
6
|
|
|
7
7
|
app = typer.Typer(help="GPU instances - browse and launch")
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def get_client() ->
|
|
11
|
-
return
|
|
10
|
+
def get_client() -> HyperCLI:
|
|
11
|
+
return HyperCLI()
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@app.command("list")
|
|
@@ -140,6 +140,7 @@ def launch(
|
|
|
140
140
|
env: Optional[list[str]] = typer.Option(None, "--env", "-e", help="Env vars (KEY=VALUE)"),
|
|
141
141
|
port: Optional[list[str]] = typer.Option(None, "--port", "-p", help="Ports (name:port)"),
|
|
142
142
|
follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs after creation"),
|
|
143
|
+
cancel_on_exit: bool = typer.Option(False, "--cancel-on-exit", help="Cancel job when exiting with Ctrl+C"),
|
|
143
144
|
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
144
145
|
):
|
|
145
146
|
"""Launch a new GPU instance"""
|
|
@@ -190,4 +191,4 @@ def launch(
|
|
|
190
191
|
if follow:
|
|
191
192
|
console.print()
|
|
192
193
|
from .tui.job_monitor import run_job_monitor
|
|
193
|
-
run_job_monitor(job.job_id)
|
|
194
|
+
run_job_monitor(job.job_id, cancel_on_exit=cancel_on_exit)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""hyper jobs commands"""
|
|
2
2
|
import typer
|
|
3
3
|
from typing import Optional
|
|
4
|
-
from hypercli import
|
|
4
|
+
from hypercli import HyperCLI
|
|
5
5
|
from .output import output, console, success, spinner
|
|
6
6
|
|
|
7
7
|
app = typer.Typer(help="Manage running jobs")
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def get_client() ->
|
|
11
|
-
return
|
|
10
|
+
def get_client() -> HyperCLI:
|
|
11
|
+
return HyperCLI()
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@app.command("list")
|
|
@@ -46,12 +46,13 @@ def get_job(
|
|
|
46
46
|
def logs(
|
|
47
47
|
job_id: str = typer.Argument(..., help="Job ID"),
|
|
48
48
|
follow: bool = typer.Option(False, "--follow", "-f", help="Stream logs"),
|
|
49
|
+
cancel_on_exit: bool = typer.Option(False, "--cancel-on-exit", help="Cancel job when exiting with Ctrl+C"),
|
|
49
50
|
):
|
|
50
51
|
"""Get job logs"""
|
|
51
52
|
client = get_client()
|
|
52
53
|
|
|
53
54
|
if follow:
|
|
54
|
-
_follow_job(job_id)
|
|
55
|
+
_follow_job(job_id, cancel_on_exit=cancel_on_exit)
|
|
55
56
|
else:
|
|
56
57
|
with spinner("Fetching logs..."):
|
|
57
58
|
logs_str = client.jobs.logs(job_id)
|
|
@@ -167,10 +168,10 @@ def _make_bar(value: float, max_val: float, warn: float = None, crit: float = No
|
|
|
167
168
|
return f"[{color}]{bar}[/{color}]"
|
|
168
169
|
|
|
169
170
|
|
|
170
|
-
def _follow_job(job_id: str):
|
|
171
|
+
def _follow_job(job_id: str, cancel_on_exit: bool = False):
|
|
171
172
|
"""Follow job with TUI"""
|
|
172
173
|
from .tui.job_monitor import run_job_monitor
|
|
173
|
-
run_job_monitor(job_id)
|
|
174
|
+
run_job_monitor(job_id, cancel_on_exit=cancel_on_exit)
|
|
174
175
|
|
|
175
176
|
|
|
176
177
|
def _watch_metrics(job_id: str):
|
|
@@ -9,10 +9,10 @@ app = typer.Typer(help="LLM API commands")
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def get_openai_client() -> OpenAI:
|
|
12
|
-
"""Get OpenAI client configured for
|
|
12
|
+
"""Get OpenAI client configured for HyperCLI"""
|
|
13
13
|
api_key = get_api_key()
|
|
14
14
|
if not api_key:
|
|
15
|
-
raise typer.Exit("HYPERCLI_API_KEY not set. Run:
|
|
15
|
+
raise typer.Exit("HYPERCLI_API_KEY not set. Run: hyper configure")
|
|
16
16
|
|
|
17
17
|
base_url = get_api_url()
|
|
18
18
|
return OpenAI(api_key=api_key, base_url=f"{base_url}/v1")
|
|
@@ -126,7 +126,7 @@ def _interactive_chat(client: OpenAI, model: Optional[str], system: Optional[str
|
|
|
126
126
|
sys_preview = current_system[:50] + "..." if len(current_system) > 50 else current_system
|
|
127
127
|
console.print(f"[dim]System: {sys_preview}[/]")
|
|
128
128
|
|
|
129
|
-
console.print("\n[bold cyan]
|
|
129
|
+
console.print("\n[bold cyan]HyperCLI Chat[/bold cyan]")
|
|
130
130
|
show_status()
|
|
131
131
|
console.print("[dim]Type /help for commands, /quit to exit[/dim]\n")
|
|
132
132
|
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import time
|
|
3
3
|
import typer
|
|
4
4
|
from typing import Optional
|
|
5
|
-
from hypercli import
|
|
5
|
+
from hypercli import HyperCLI
|
|
6
6
|
from .output import output, console, success, spinner
|
|
7
7
|
|
|
8
8
|
app = typer.Typer(help="Manage renders")
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def get_client() ->
|
|
12
|
-
return
|
|
11
|
+
def get_client() -> HyperCLI:
|
|
12
|
+
return HyperCLI()
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
@app.command("list")
|
|
@@ -77,7 +77,7 @@ def create_render(
|
|
|
77
77
|
console.print(f" State: {render.state}")
|
|
78
78
|
|
|
79
79
|
if wait:
|
|
80
|
-
_wait_for_render(
|
|
80
|
+
_wait_for_render(client, render.render_id, fmt)
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
@app.command("status")
|
|
@@ -90,7 +90,7 @@ def status(
|
|
|
90
90
|
client = get_client()
|
|
91
91
|
|
|
92
92
|
if watch:
|
|
93
|
-
_watch_status(
|
|
93
|
+
_watch_status(client, render_id)
|
|
94
94
|
else:
|
|
95
95
|
with spinner("Fetching status..."):
|
|
96
96
|
s = client.renders.status(render_id)
|
|
@@ -108,7 +108,7 @@ def cancel(
|
|
|
108
108
|
success(f"Render {render_id} cancelled")
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
def _wait_for_render(
|
|
111
|
+
def _wait_for_render(client: HyperCLI, render_id: str, fmt: str, poll_interval: float = 2.0):
|
|
112
112
|
"""Wait for render to complete"""
|
|
113
113
|
console.print("Waiting for completion...")
|
|
114
114
|
|
|
@@ -138,7 +138,7 @@ def _wait_for_render(c3: C3, render_id: str, fmt: str, poll_interval: float = 2.
|
|
|
138
138
|
console.print(f" Error: {render.error}")
|
|
139
139
|
|
|
140
140
|
|
|
141
|
-
def _watch_status(
|
|
141
|
+
def _watch_status(client: HyperCLI, render_id: str, poll_interval: float = 2.0):
|
|
142
142
|
"""Watch render status live"""
|
|
143
143
|
from rich.live import Live
|
|
144
144
|
from rich.panel import Panel
|
|
@@ -12,7 +12,7 @@ from rich.panel import Panel
|
|
|
12
12
|
from rich.layout import Layout
|
|
13
13
|
from rich import box
|
|
14
14
|
|
|
15
|
-
from hypercli import
|
|
15
|
+
from hypercli import HyperCLI, LogStream, fetch_logs
|
|
16
16
|
|
|
17
17
|
console = Console()
|
|
18
18
|
|
|
@@ -70,7 +70,7 @@ def build_header(job, elapsed: int, metrics) -> Panel:
|
|
|
70
70
|
mem_pct = (s.memory_used / s.memory_limit * 100) if s.memory_limit else 0
|
|
71
71
|
mem_c = "red" if mem_pct >= 90 else "yellow" if mem_pct >= 70 else "green"
|
|
72
72
|
|
|
73
|
-
line = f"[bold]CPU[/] {bar(cpu_pct, 20, cpu_c)} {cpu_pct:4.0f}% "
|
|
73
|
+
line = f"[bold]CPU [/] {bar(cpu_pct, 20, cpu_c)} {cpu_pct:4.0f}% "
|
|
74
74
|
line += f"RAM {bar(mem_pct, 15, mem_c)} {s.memory_used/1024:.1f}/{s.memory_limit/1024:.1f}GB"
|
|
75
75
|
parts.append(line)
|
|
76
76
|
|
|
@@ -162,9 +162,11 @@ async def _run_job_monitor_async(
|
|
|
162
162
|
job_id: str,
|
|
163
163
|
status_q: Queue = None,
|
|
164
164
|
stop_on_status_complete: bool = False,
|
|
165
|
+
cancel_on_exit: bool = False,
|
|
165
166
|
):
|
|
166
167
|
"""Async job monitor - uses SDK LogStream for logs"""
|
|
167
|
-
client =
|
|
168
|
+
client = HyperCLI()
|
|
169
|
+
should_cancel = False
|
|
168
170
|
logs: deque[str] = deque(maxlen=MAX_LOG_LINES)
|
|
169
171
|
log_stream: Optional[LogStream] = None
|
|
170
172
|
log_task: Optional[asyncio.Task] = None
|
|
@@ -173,7 +175,8 @@ async def _run_job_monitor_async(
|
|
|
173
175
|
ws_status = "[dim]● waiting[/]"
|
|
174
176
|
stop_event = asyncio.Event()
|
|
175
177
|
|
|
176
|
-
|
|
178
|
+
exit_msg = "Ctrl+C to exit and cancel job" if cancel_on_exit else "Ctrl+C to exit (job keeps running)"
|
|
179
|
+
console.print(f"[dim]{exit_msg}[/]\n")
|
|
177
180
|
|
|
178
181
|
# Wait for job
|
|
179
182
|
job = None
|
|
@@ -185,28 +188,15 @@ async def _run_job_monitor_async(
|
|
|
185
188
|
await asyncio.sleep(1)
|
|
186
189
|
|
|
187
190
|
async def stream_logs_task(stream: LogStream):
|
|
188
|
-
"""Background task that streams logs continuously
|
|
191
|
+
"""Background task that streams logs continuously"""
|
|
189
192
|
nonlocal ws_status
|
|
190
|
-
log_batch = []
|
|
191
|
-
last_flush = asyncio.get_event_loop().time()
|
|
192
|
-
FLUSH_INTERVAL = 0.05 # Flush every 50ms
|
|
193
193
|
|
|
194
194
|
try:
|
|
195
195
|
async for line in stream:
|
|
196
196
|
if stop_event.is_set():
|
|
197
197
|
break
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
# Flush batch periodically
|
|
201
|
-
now = asyncio.get_event_loop().time()
|
|
202
|
-
if now - last_flush >= FLUSH_INTERVAL:
|
|
203
|
-
logs.extend(log_batch)
|
|
204
|
-
log_batch.clear()
|
|
205
|
-
last_flush = now
|
|
206
|
-
|
|
207
|
-
# Flush remaining
|
|
208
|
-
if log_batch:
|
|
209
|
-
logs.extend(log_batch)
|
|
198
|
+
# Append directly - the deque handles thread safety
|
|
199
|
+
logs.append(line)
|
|
210
200
|
except Exception:
|
|
211
201
|
pass
|
|
212
202
|
finally:
|
|
@@ -226,6 +216,8 @@ async def _run_job_monitor_async(
|
|
|
226
216
|
async def fetch_job_task():
|
|
227
217
|
"""Background task that fetches job state periodically"""
|
|
228
218
|
nonlocal job, log_stream, log_task, ws_status
|
|
219
|
+
ws_retries = 0
|
|
220
|
+
initial_logs_fetched = False
|
|
229
221
|
while not stop_event.is_set():
|
|
230
222
|
try:
|
|
231
223
|
job = await asyncio.to_thread(client.jobs.get, job_id)
|
|
@@ -233,21 +225,35 @@ async def _run_job_monitor_async(
|
|
|
233
225
|
# Start log stream when job is ready
|
|
234
226
|
if job.state in ("assigned", "running") and log_stream is None and job.job_key:
|
|
235
227
|
# Fetch initial logs ONCE
|
|
236
|
-
if job.state == "running":
|
|
237
|
-
initial = await asyncio.to_thread(fetch_logs,
|
|
228
|
+
if job.state == "running" and not initial_logs_fetched:
|
|
229
|
+
initial = await asyncio.to_thread(fetch_logs, client, job_id, MAX_LOG_LINES)
|
|
238
230
|
for line in initial:
|
|
239
231
|
logs.append(line)
|
|
232
|
+
initial_logs_fetched = True
|
|
240
233
|
|
|
241
234
|
# Connect websocket and start streaming task
|
|
242
|
-
|
|
243
|
-
|
|
235
|
+
temp_stream = LogStream(
|
|
236
|
+
client, job_id,
|
|
244
237
|
job_key=job.job_key,
|
|
245
238
|
fetch_initial=False,
|
|
246
239
|
max_buffer=MAX_LOG_LINES,
|
|
247
240
|
)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
241
|
+
try:
|
|
242
|
+
retry_msg = f" (retry {ws_retries})" if ws_retries > 0 else ""
|
|
243
|
+
ws_status = f"[yellow]● connecting{retry_msg}...[/]"
|
|
244
|
+
await temp_stream.connect()
|
|
245
|
+
log_stream = temp_stream
|
|
246
|
+
log_task = asyncio.create_task(stream_logs_task(log_stream))
|
|
247
|
+
ws_status = "[green]● live[/]"
|
|
248
|
+
ws_retries = 0
|
|
249
|
+
except asyncio.TimeoutError:
|
|
250
|
+
ws_retries += 1
|
|
251
|
+
ws_status = f"[red]● timeout (retry {ws_retries})[/]"
|
|
252
|
+
await asyncio.sleep(2) # Wait before retry
|
|
253
|
+
except Exception as e:
|
|
254
|
+
ws_retries += 1
|
|
255
|
+
ws_status = f"[red]● error (retry {ws_retries})[/]"
|
|
256
|
+
await asyncio.sleep(2) # Wait before retry
|
|
251
257
|
except Exception:
|
|
252
258
|
pass
|
|
253
259
|
await asyncio.sleep(1)
|
|
@@ -288,7 +294,7 @@ async def _run_job_monitor_async(
|
|
|
288
294
|
# Job terminated
|
|
289
295
|
if job and job.state in ("succeeded", "failed", "canceled", "terminated"):
|
|
290
296
|
# Fetch final logs ONCE
|
|
291
|
-
final = await asyncio.to_thread(fetch_logs,
|
|
297
|
+
final = await asyncio.to_thread(fetch_logs, client, job_id, MAX_LOG_LINES)
|
|
292
298
|
logs.clear()
|
|
293
299
|
for line in final:
|
|
294
300
|
logs.append(line)
|
|
@@ -309,6 +315,7 @@ async def _run_job_monitor_async(
|
|
|
309
315
|
console.print(f"\n[bold]Job {job.state}[/]")
|
|
310
316
|
|
|
311
317
|
except KeyboardInterrupt:
|
|
318
|
+
should_cancel = cancel_on_exit
|
|
312
319
|
console.print("\n[dim]Stopped[/]")
|
|
313
320
|
finally:
|
|
314
321
|
stop_event.set()
|
|
@@ -319,11 +326,20 @@ async def _run_job_monitor_async(
|
|
|
319
326
|
metrics_task.cancel()
|
|
320
327
|
job_task.cancel()
|
|
321
328
|
|
|
329
|
+
if should_cancel and job and job.state in ("queued", "assigned", "running"):
|
|
330
|
+
console.print("[yellow]Cancelling job...[/]")
|
|
331
|
+
try:
|
|
332
|
+
client.jobs.cancel(job_id)
|
|
333
|
+
console.print("[green]Job cancelled[/]")
|
|
334
|
+
except Exception as e:
|
|
335
|
+
console.print(f"[red]Failed to cancel: {e}[/]")
|
|
336
|
+
|
|
322
337
|
|
|
323
338
|
def run_job_monitor(
|
|
324
339
|
job_id: str,
|
|
325
340
|
status_q: Queue = None,
|
|
326
341
|
stop_on_status_complete: bool = False,
|
|
342
|
+
cancel_on_exit: bool = False,
|
|
327
343
|
):
|
|
328
344
|
"""Run the job monitor TUI (sync wrapper).
|
|
329
345
|
|
|
@@ -331,5 +347,6 @@ def run_job_monitor(
|
|
|
331
347
|
job_id: The job ID to monitor
|
|
332
348
|
status_q: Optional queue receiving JobStatus updates for status pane
|
|
333
349
|
stop_on_status_complete: If True, exit when JobStatus.complete is True
|
|
350
|
+
cancel_on_exit: If True, cancel the job when exiting via Ctrl+C
|
|
334
351
|
"""
|
|
335
|
-
asyncio.run(_run_job_monitor_async(job_id, status_q, stop_on_status_complete))
|
|
352
|
+
asyncio.run(_run_job_monitor_async(job_id, status_q, stop_on_status_complete, cancel_on_exit))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""hyper user commands"""
|
|
2
2
|
import typer
|
|
3
|
-
from hypercli import
|
|
3
|
+
from hypercli import HyperCLI
|
|
4
4
|
from .output import output, spinner
|
|
5
5
|
|
|
6
6
|
app = typer.Typer(help="User account commands")
|
|
@@ -13,7 +13,7 @@ def user_info(
|
|
|
13
13
|
):
|
|
14
14
|
"""Get current user info"""
|
|
15
15
|
if ctx.invoked_subcommand is None:
|
|
16
|
-
client =
|
|
16
|
+
client = HyperCLI()
|
|
17
17
|
with spinner("Fetching user info..."):
|
|
18
18
|
user = client.user.get()
|
|
19
19
|
output(user, fmt)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|