vmux-cli 0.5.4__tar.gz → 0.5.5__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.
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/PKG-INFO +1 -1
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/pyproject.toml +1 -1
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/uv.lock +1 -1
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/cli.py +120 -14
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/client.py +12 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/deps.py +12 -1
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/terminal.py +23 -4
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/.gitignore +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/README.md +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/tests/__init__.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/tests/benchmark_bundle_delivery.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/tests/benchmark_latency.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/tests/test_config.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/tests/test_packager.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/__init__.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/auth.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/config.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/core.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/packager.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/runner.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/types.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.5.5}/vmux/ui.py +0 -0
|
@@ -61,7 +61,7 @@ def run(detach: bool, port: tuple[int, ...], preview: bool, env: tuple[str, ...]
|
|
|
61
61
|
sys.exit(1)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _list_jobs(limit: int) -> None:
|
|
64
|
+
def _list_jobs(limit: int, all_jobs: bool = False) -> None:
|
|
65
65
|
"""Shared implementation for ps/ls."""
|
|
66
66
|
from datetime import datetime
|
|
67
67
|
|
|
@@ -69,8 +69,12 @@ def _list_jobs(limit: int) -> None:
|
|
|
69
69
|
with TupClient(config) as client:
|
|
70
70
|
jobs = client.list_jobs(limit=limit)
|
|
71
71
|
|
|
72
|
+
# Filter out completed/failed unless --all
|
|
73
|
+
if not all_jobs:
|
|
74
|
+
jobs = [j for j in jobs if j.get("status") in ("running", "pending")]
|
|
75
|
+
|
|
72
76
|
if not jobs:
|
|
73
|
-
warning("No jobs found.")
|
|
77
|
+
warning("No running jobs." if not all_jobs else "No jobs found.")
|
|
74
78
|
return
|
|
75
79
|
|
|
76
80
|
# Sort by created_at ascending (oldest first, newest at bottom)
|
|
@@ -119,28 +123,62 @@ def _list_jobs(limit: int) -> None:
|
|
|
119
123
|
|
|
120
124
|
@cli.command(name="ps")
|
|
121
125
|
@click.option("--limit", "-l", default=20, help="Number of jobs to show")
|
|
122
|
-
|
|
126
|
+
@click.option("--all", "-a", "all_jobs", is_flag=True, help="Show all jobs including completed/failed")
|
|
127
|
+
def ps_cmd(limit: int, all_jobs: bool) -> None:
|
|
123
128
|
"""List running jobs."""
|
|
124
|
-
_list_jobs(limit)
|
|
129
|
+
_list_jobs(limit, all_jobs)
|
|
125
130
|
|
|
126
131
|
|
|
127
|
-
|
|
132
|
+
# Hidden alias for ps
|
|
133
|
+
@cli.command(name="ls", hidden=True)
|
|
128
134
|
@click.option("--limit", "-l", default=20, help="Number of jobs to show")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
@click.option("--all", "-a", "all_jobs", is_flag=True, help="Show all jobs including completed/failed")
|
|
136
|
+
def ls_cmd(limit: int, all_jobs: bool) -> None:
|
|
137
|
+
"""List running jobs."""
|
|
138
|
+
_list_jobs(limit, all_jobs)
|
|
132
139
|
|
|
133
140
|
|
|
134
141
|
@cli.command()
|
|
135
|
-
@click.argument("
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
@click.argument("job_ids", nargs=-1)
|
|
143
|
+
@click.option("--all", "-a", "stop_all", is_flag=True, help="Stop all running jobs")
|
|
144
|
+
def stop(job_ids: tuple[str, ...], stop_all: bool) -> None:
|
|
145
|
+
"""Stop one or more jobs.
|
|
146
|
+
|
|
147
|
+
\b
|
|
148
|
+
Examples:
|
|
149
|
+
vmux stop abc123 # Stop one job
|
|
150
|
+
vmux stop abc123 def456 # Stop multiple jobs
|
|
151
|
+
vmux stop -a # Stop all running jobs
|
|
152
|
+
"""
|
|
138
153
|
config = load_config()
|
|
139
154
|
with TupClient(config) as client:
|
|
140
|
-
if
|
|
141
|
-
|
|
155
|
+
if stop_all:
|
|
156
|
+
jobs = client.list_jobs(limit=100)
|
|
157
|
+
running = [j for j in jobs if j.get("status") == "running"]
|
|
158
|
+
if not running:
|
|
159
|
+
warning("No running jobs to stop.")
|
|
160
|
+
return
|
|
161
|
+
job_ids = tuple(j["job_id"] for j in running)
|
|
162
|
+
console.print(f"Stopping {len(job_ids)} running jobs...")
|
|
163
|
+
elif not job_ids:
|
|
164
|
+
error("Specify job ID(s) or use --all")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
stopped = 0
|
|
168
|
+
for job_id in job_ids:
|
|
169
|
+
try:
|
|
170
|
+
if client.stop_job(job_id):
|
|
171
|
+
console.print(f" [green]✓[/green] {job_id}")
|
|
172
|
+
stopped += 1
|
|
173
|
+
else:
|
|
174
|
+
console.print(f" [red]✗[/red] {job_id} (failed)")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
console.print(f" [red]✗[/red] {job_id} ({e})")
|
|
177
|
+
|
|
178
|
+
if stopped > 0:
|
|
179
|
+
success(f"Stopped {stopped} job(s)")
|
|
142
180
|
else:
|
|
143
|
-
error("
|
|
181
|
+
error("No jobs stopped")
|
|
144
182
|
sys.exit(1)
|
|
145
183
|
|
|
146
184
|
|
|
@@ -236,6 +274,74 @@ def logout() -> None:
|
|
|
236
274
|
success("Logged out.")
|
|
237
275
|
|
|
238
276
|
|
|
277
|
+
@cli.command()
|
|
278
|
+
def status() -> None:
|
|
279
|
+
"""Show global capacity status."""
|
|
280
|
+
config = load_config()
|
|
281
|
+
if not config.auth_token:
|
|
282
|
+
error("Not logged in. Run: vmux login")
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
|
|
285
|
+
with TupClient(config) as client:
|
|
286
|
+
try:
|
|
287
|
+
data = client.get_status()
|
|
288
|
+
current = data.get("current_jobs", 0)
|
|
289
|
+
max_jobs = data.get("max_jobs", 90)
|
|
290
|
+
percent = data.get("capacity_percent", 0)
|
|
291
|
+
|
|
292
|
+
# Color based on capacity
|
|
293
|
+
if percent < 50:
|
|
294
|
+
color = "green"
|
|
295
|
+
elif percent < 80:
|
|
296
|
+
color = "yellow"
|
|
297
|
+
else:
|
|
298
|
+
color = "red"
|
|
299
|
+
|
|
300
|
+
console.print()
|
|
301
|
+
console.print(f" [dim]Capacity:[/dim] [{color}]{current}[/{color}] / {max_jobs} jobs ({percent}%)")
|
|
302
|
+
console.print()
|
|
303
|
+
except Exception as e:
|
|
304
|
+
error(f"Failed to get status: {e}")
|
|
305
|
+
sys.exit(1)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@cli.command()
|
|
309
|
+
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
|
|
310
|
+
def prune(force: bool) -> None:
|
|
311
|
+
"""Remove completed/failed jobs from history.
|
|
312
|
+
|
|
313
|
+
\b
|
|
314
|
+
Examples:
|
|
315
|
+
vmux prune # Remove old jobs (asks for confirmation)
|
|
316
|
+
vmux prune -f # Remove without confirmation
|
|
317
|
+
"""
|
|
318
|
+
config = load_config()
|
|
319
|
+
with TupClient(config) as client:
|
|
320
|
+
jobs = client.list_jobs(limit=200)
|
|
321
|
+
stale = [j for j in jobs if j.get("status") in ("completed", "failed")]
|
|
322
|
+
|
|
323
|
+
if not stale:
|
|
324
|
+
warning("No completed/failed jobs to prune.")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
console.print(f"Found {len(stale)} completed/failed jobs.")
|
|
328
|
+
if not force:
|
|
329
|
+
if not click.confirm("Remove from history?"):
|
|
330
|
+
console.print("Cancelled.")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
removed = 0
|
|
334
|
+
for job in stale:
|
|
335
|
+
try:
|
|
336
|
+
# Stop will mark as failed and clean up
|
|
337
|
+
client.stop_job(job["job_id"])
|
|
338
|
+
removed += 1
|
|
339
|
+
except Exception:
|
|
340
|
+
pass # Already gone or can't remove
|
|
341
|
+
|
|
342
|
+
success(f"Pruned {removed} jobs")
|
|
343
|
+
|
|
344
|
+
|
|
239
345
|
@cli.command()
|
|
240
346
|
def whoami() -> None:
|
|
241
347
|
"""Show current user."""
|
|
@@ -182,6 +182,12 @@ class TupClient:
|
|
|
182
182
|
response.raise_for_status()
|
|
183
183
|
return response.json().get("stopped", False)
|
|
184
184
|
|
|
185
|
+
def purge_job(self, job_id: str) -> bool:
|
|
186
|
+
"""Permanently delete a job from history."""
|
|
187
|
+
response = self._client.delete(f"/jobs/{job_id}?purge=true", headers=self._headers())
|
|
188
|
+
response.raise_for_status()
|
|
189
|
+
return response.json().get("purged", False)
|
|
190
|
+
|
|
185
191
|
def get_logs(self, job_id: str) -> str:
|
|
186
192
|
"""Get job logs."""
|
|
187
193
|
response = self._client.get(f"/jobs/{job_id}/logs", headers=self._headers())
|
|
@@ -193,3 +199,9 @@ class TupClient:
|
|
|
193
199
|
response = self._client.get("/usage", headers=self._headers())
|
|
194
200
|
response.raise_for_status()
|
|
195
201
|
return response.json()
|
|
202
|
+
|
|
203
|
+
def get_status(self) -> dict:
|
|
204
|
+
"""Get global capacity status."""
|
|
205
|
+
response = self._client.get("/status", headers=self._headers())
|
|
206
|
+
response.raise_for_status()
|
|
207
|
+
return response.json()
|
|
@@ -135,12 +135,17 @@ def find_editable(pkg_name: str) -> EditablePackage | None:
|
|
|
135
135
|
)
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def detect_script_deps(script_path: Path) -> ScriptDeps:
|
|
138
|
+
def detect_script_deps(script_path: Path, verbose: bool = True) -> ScriptDeps:
|
|
139
139
|
"""Detect dependencies for a standalone script.
|
|
140
140
|
|
|
141
141
|
Parses imports, finds editable installs.
|
|
142
142
|
The container will install dependencies from the editable's uv.lock/pyproject.toml.
|
|
143
143
|
"""
|
|
144
|
+
from . import ui
|
|
145
|
+
|
|
146
|
+
if verbose:
|
|
147
|
+
ui.dim(f"→ Scanning {script_path.name} imports...")
|
|
148
|
+
|
|
144
149
|
imports = set(parse_imports(script_path))
|
|
145
150
|
|
|
146
151
|
editables: list[EditablePackage] = []
|
|
@@ -154,6 +159,9 @@ def detect_script_deps(script_path: Path) -> ScriptDeps:
|
|
|
154
159
|
return find_cache[name]
|
|
155
160
|
|
|
156
161
|
# Direct editables imported by the script
|
|
162
|
+
if verbose and imports:
|
|
163
|
+
ui.dim(f"→ Checking {len(imports)} imports for editables...")
|
|
164
|
+
|
|
157
165
|
for pkg_name in sorted(imports):
|
|
158
166
|
editable = cached_find(pkg_name)
|
|
159
167
|
if editable and all(e.name != editable.name for e in editables):
|
|
@@ -168,6 +176,9 @@ def detect_script_deps(script_path: Path) -> ScriptDeps:
|
|
|
168
176
|
continue
|
|
169
177
|
scanned_paths.add(editable_path)
|
|
170
178
|
|
|
179
|
+
if verbose:
|
|
180
|
+
ui.dim(f"→ Scanning {editable.name} for transitive deps...")
|
|
181
|
+
|
|
171
182
|
for dep_name in sorted(scan_imports_in_tree(editable_path)):
|
|
172
183
|
dep_editable = cached_find(dep_name)
|
|
173
184
|
if dep_editable and all(e.name != dep_editable.name for e in editables):
|
|
@@ -48,8 +48,7 @@ def print_attach_header(job_id: str) -> None:
|
|
|
48
48
|
print(f"{c.CYAN}{c.BOLD} VMUX ATTACH: {c.GREEN}{job_id}{c.RESET}")
|
|
49
49
|
print(f"{c.CYAN}{c.BOLD}{'=' * width}{c.RESET}")
|
|
50
50
|
print()
|
|
51
|
-
print(f"{c.DIM} Connecting to cloud container...{c.RESET}")
|
|
52
|
-
print()
|
|
51
|
+
print(f"{c.DIM} Connecting to cloud container...{c.RESET}", end="", flush=True)
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
def print_attach_connected(job_id: str) -> None:
|
|
@@ -133,8 +132,28 @@ async def run_attach(job_id: str, config: "VmuxConfig") -> None:
|
|
|
133
132
|
ws_url,
|
|
134
133
|
additional_headers={"Authorization": f"Bearer {config.auth_token}"},
|
|
135
134
|
) as ws:
|
|
136
|
-
# Wait for connected message
|
|
137
|
-
|
|
135
|
+
# Wait for connected message with spinner
|
|
136
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
137
|
+
frame_idx = 0
|
|
138
|
+
|
|
139
|
+
async def show_spinner() -> None:
|
|
140
|
+
nonlocal frame_idx
|
|
141
|
+
while True:
|
|
142
|
+
print(f"\r{Colors.DIM} Connecting to cloud container... {spinner_frames[frame_idx]}{Colors.RESET}", end="", flush=True)
|
|
143
|
+
frame_idx = (frame_idx + 1) % len(spinner_frames)
|
|
144
|
+
await asyncio.sleep(0.1)
|
|
145
|
+
|
|
146
|
+
spinner_task = asyncio.create_task(show_spinner())
|
|
147
|
+
try:
|
|
148
|
+
msg = await asyncio.wait_for(ws.recv(), timeout=30.0) # Increased timeout for cold containers
|
|
149
|
+
finally:
|
|
150
|
+
spinner_task.cancel()
|
|
151
|
+
try:
|
|
152
|
+
await spinner_task
|
|
153
|
+
except asyncio.CancelledError:
|
|
154
|
+
pass
|
|
155
|
+
print("\r" + " " * 50 + "\r", end="") # Clear spinner line
|
|
156
|
+
|
|
138
157
|
data = json.loads(msg)
|
|
139
158
|
|
|
140
159
|
if data.get("type") == "error":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|