From 1a0ddebd750df11d7b1cf325cd031c29af7b0476 Mon Sep 17 00:00:00 2001 From: Luke Piette Date: Tue, 28 Jul 2026 15:44:41 -0400 Subject: [PATCH] feat: --json output for rp flash deploy and dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents and CI need a stable machine-readable contract from the flash lifecycle commands instead of scraping rich-rendered text. With --json, human-facing output moves to stderr and the final line of stdout is a JSON summary: deployed endpoints with ids/urls per app, built artifact paths, or the dev --once resource table and entrypoint verdict. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- runpod/rp_cli/console.py | 18 ++- runpod/rp_cli/main.py | 106 ++++++++++++++++-- tests/test_cli/test_deploy_command.py | 154 ++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 8 deletions(-) diff --git a/runpod/rp_cli/console.py b/runpod/rp_cli/console.py index f165ff56..50a4dff3 100644 --- a/runpod/rp_cli/console.py +++ b/runpod/rp_cli/console.py @@ -90,7 +90,23 @@ } ) -console = Console(highlight=False, theme=theme) + +def _build_console(stderr: bool = False) -> Console: + return Console(highlight=False, theme=theme, stderr=stderr) + + +console = _build_console() + + +def route_to_stderr() -> None: + """rebind human-facing rendering to stderr. + + machine-readable modes (--json) own stdout; every rich line moves + to stderr so stdout stays parseable. Console(stderr=True) resolves + sys.stderr per write, keeping capture-based tests working. + """ + global console + console = _build_console(stderr=True) def _spinner_column(finished_text: str = "[ok]✓[/ok]") -> SpinnerColumn: diff --git a/runpod/rp_cli/main.py b/runpod/rp_cli/main.py index 9026d538..b64d36d7 100644 --- a/runpod/rp_cli/main.py +++ b/runpod/rp_cli/main.py @@ -166,8 +166,16 @@ def update(version_opt): help="Build the artifact without deploying; writes " "-artifact.tar.gz to the current directory.", ) -def deploy(target, env, python_version, exclude, build_only): +@click.option( + "--json", + "json_output", + is_flag=True, + help="Print a machine-readable JSON summary as the final line of " + "stdout; human-facing output moves to stderr.", +) +def deploy(target, env, python_version, exclude, build_only, json_output): """Package and deploy all apps found in TARGET (default: cwd).""" + import json as json_lib import logging from runpod.apps.deploy import build_artifact, deploy_app @@ -176,6 +184,14 @@ def deploy(target, env, python_version, exclude, build_only): logging.getLogger("runpod.apps").setLevel(logging.WARNING) + if json_output: + ui.route_to_stderr() + + def _abort(message: str) -> None: + if json_output: + click.echo(json_lib.dumps({"ok": False, "error": message})) + _fail(message) + if python_version is None: from runpod.apps.images import ( DEFAULT_PYTHON_VERSION, @@ -196,23 +212,24 @@ def deploy(target, env, python_version, exclude, build_only): target = (target or Path.cwd()).resolve() if not target.exists(): - _fail(f"{target} does not exist") + _abort(f"{target} does not exist") project_root = target if target.is_dir() else target.parent try: apps = discover_apps(target) except DiscoveryError as exc: - _fail(str(exc)) + _abort(str(exc)) if not apps: - _fail( + _abort( "no runpod.App found. define one with:\n\n" " from runpod import App\n" ' app = App("my-app")' ) if build_only: + artifacts = [] for found in apps: ui.set_name_width(list(found.resources)) events = ui.DeployEvents() @@ -226,13 +243,28 @@ def deploy(target, env, python_version, exclude, build_only): output=Path.cwd() / f"{found.name}-artifact.tar.gz", ) except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + if json_output: + click.echo(json_lib.dumps({"ok": False, "error": str(exc)})) raise click.ClickException(str(exc)) from exc finally: events.close() - size_mb = artifact.stat().st_size / (1024 * 1024) + size_bytes = artifact.stat().st_size + artifacts.append( + { + "app": found.name, + "path": str(artifact), + "size_bytes": size_bytes, + } + ) ui.success( f"built [white]{found.name}[/white] " - f"[dim]{artifact.name} ({size_mb:.1f} MB)[/dim]" + f"[dim]{artifact.name} ({size_bytes / (1024 * 1024):.1f} MB)[/dim]" + ) + if json_output: + click.echo( + json_lib.dumps( + {"ok": True, "command": "build", "artifacts": artifacts} + ) ) return @@ -248,6 +280,7 @@ def deploy(target, env, python_version, exclude, build_only): ] ) + summaries = [] for index, found in enumerate(apps): if index or len(apps) > 1: ui.console.print() @@ -272,9 +305,22 @@ def deploy(target, env, python_version, exclude, build_only): ) ) except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + if json_output: + click.echo(json_lib.dumps({"ok": False, "error": str(exc)})) raise click.ClickException(str(exc)) from exc finally: events.close() + summaries.append( + { + "app": result.app_name, + "env": env or found.env, + "elapsed_s": round(t.elapsed, 1), + "endpoints": { + name: {"id": endpoint_id, "url": ui.endpoint_url(endpoint_id)} + for name, endpoint_id in sorted(result.endpoints.items()) + }, + } + ) ui.success( f"[bold white]{result.app_name}/{env or found.env}[/bold white] " f"is live [dim]{t.elapsed:.1f}s[/dim]" @@ -288,6 +334,10 @@ def deploy(target, env, python_version, exclude, build_only): f"{ui.endpoint_link(endpoint_id)}" ) ui.console.print() + if json_output: + click.echo( + json_lib.dumps({"ok": True, "command": "deploy", "apps": summaries}) + ) # ------------------------------------------------------------------- dev @@ -300,7 +350,14 @@ def deploy(target, env, python_version, exclude, build_only): is_flag=True, help="Run the entrypoint once, tear down, and exit (for scripts/CI).", ) -def dev(module, once): +@click.option( + "--json", + "json_output", + is_flag=True, + help="Print a machine-readable JSON summary as the final line of " + "stdout; human-facing output moves to stderr. Requires --once.", +) +def dev(module, once, json_output): """Start an interactive dev session for MODULE. Provisions temporary live endpoints, runs the module's @@ -308,6 +365,7 @@ def dev(module, once): refreshing endpoints so requests run fresh code), and deletes the endpoints on exit. """ + import json as json_lib import logging from runpod.apps.dev import DevSession @@ -318,6 +376,11 @@ def dev(module, once): logging.getLogger("runpod.apps").setLevel(logging.WARNING) + if json_output and not once: + _fail("--json requires --once") + if json_output: + ui.route_to_stderr() + module = module.resolve() if not module.is_file(): _fail(f"{module} is not a file") @@ -436,6 +499,31 @@ def _runner() -> None: threading.Thread(target=_runner, daemon=True).start() return await done + def _emit_json(session: "DevSession", error, elapsed: float) -> None: + payload = { + "ok": error is None, + "command": "dev", + "module": str(module), + "apps": [a.name for a in session.apps], + "resources": [ + { + "name": name, + "kind": kind, + "hardware": hardware, + "endpoint_id": endpoint_id, + } + for name, kind, hardware, endpoint_id in _table_rows(session) + ], + "entrypoint": { + "name": getattr(entrypoint, "__name__", ""), + "ok": error is None, + "elapsed_s": round(elapsed, 2), + }, + } + if error is not None: + payload["entrypoint"]["error"] = error + click.echo(json_lib.dumps(payload)) + async def _session() -> int: nonlocal apps, entrypoint ui.set_name_width(_all_resource_names(apps)) @@ -458,8 +546,12 @@ async def _session() -> int: except Exception as exc: # noqa: BLE001 - dev loop survives user errors ui.entrypoint_failure(t.so_far, str(exc)) if once: + if json_output: + _emit_json(session, str(exc), t.so_far) return 1 if once: + if json_output: + _emit_json(session, None, t.so_far) return 0 reason = await _wait_for_rerun(watcher) if reason == "changed": diff --git a/tests/test_cli/test_deploy_command.py b/tests/test_cli/test_deploy_command.py index 6a6a1735..e95f8d25 100644 --- a/tests/test_cli/test_deploy_command.py +++ b/tests/test_cli/test_deploy_command.py @@ -1,5 +1,6 @@ """tests for rp flash deploy and rp flash dev command wiring.""" +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -17,6 +18,28 @@ def clean_entrypoints(): yield _clear_entrypoints() + +@pytest.fixture(autouse=True) +def reset_console(): + # --json reroutes the module console to stderr; restore stdout + # rendering so later tests can assert on result.output + yield + from runpod.rp_cli import console as ui + + ui.console = ui._build_console() + + +def _last_json(result): + # CliRunner mixes stderr into output, so scan from the end for + # the machine-readable line (stdout's final line in real usage) + for line in reversed(result.output.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no json line in output: {result.output!r}") + + APP_SOURCE = ''' import runpod @@ -221,3 +244,134 @@ def test_missing_module(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = _runner().invoke(cli, ["flash", "dev", "missing.py", "--once"]) assert result.exit_code == 2 + + +class TestDeployJson: + def test_summary(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(return_value=_result()) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["flash", "deploy", "--json"]) + assert result.exit_code == 0, result.output + payload = _last_json(result) + assert payload["ok"] is True + assert payload["command"] == "deploy" + [app] = payload["apps"] + assert app["app"] == "demo" + assert app["env"] == "default" + assert app["endpoints"]["chat"]["id"] == "ep1" + assert "url" in app["endpoints"]["chat"] + + def test_multi_app_summary(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(TWO_APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock( + side_effect=[_result("demo"), _result("other", {"embed": "ep2"})] + ) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["flash", "deploy", "--json"]) + assert result.exit_code == 0, result.output + payload = _last_json(result) + assert [a["app"] for a in payload["apps"]] == ["demo", "other"] + + def test_engine_error(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(side_effect=RuntimeError("upload failed")) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["flash", "deploy", "--json"]) + assert result.exit_code == 1 + payload = _last_json(result) + assert payload == {"ok": False, "error": "upload failed"} + + def test_no_apps_found(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text("x = 1\n") + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["flash", "deploy", "--json"]) + assert result.exit_code == 1 + payload = _last_json(result) + assert payload["ok"] is False + assert "no runpod.App found" in payload["error"] + + def test_build_only(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + artifact = tmp_path / "demo-artifact.tar.gz" + artifact.write_bytes(b"x" * 100) + build = MagicMock(return_value=artifact) + with patch("runpod.apps.deploy.build_artifact", build): + result = _runner().invoke( + cli, ["flash", "deploy", "--build-only", "--json"] + ) + assert result.exit_code == 0, result.output + payload = _last_json(result) + assert payload["ok"] is True + assert payload["command"] == "build" + [built] = payload["artifacts"] + assert built["app"] == "demo" + assert built["size_bytes"] == 100 + assert built["path"].endswith("demo-artifact.tar.gz") + + +class TestDevJson: + def _session(self): + session = MagicMock() + session.start = AsyncMock() + session.stop = AsyncMock() + session.refresh = AsyncMock() + session._endpoints = {"dev-demo-chat": "ep1"} + + def make_session(apps, events=None): + session.apps = apps + return session + + return session, make_session + + def test_requires_once(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text(ENTRYPOINT_SOURCE) + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["flash", "dev", str(module), "--json"]) + assert result.exit_code == 1 + assert "--json requires --once" in result.output + + def test_summary(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text(ENTRYPOINT_SOURCE) + monkeypatch.chdir(tmp_path) + session, make_session = self._session() + with patch("runpod.apps.dev.DevSession", side_effect=make_session): + result = _runner().invoke( + cli, ["flash", "dev", str(module), "--once", "--json"] + ) + assert result.exit_code == 0, result.output + payload = _last_json(result) + assert payload["ok"] is True + assert payload["command"] == "dev" + assert payload["apps"] == ["demo"] + [resource] = payload["resources"] + assert resource["name"] == "chat" + assert resource["kind"] == "queue" + assert resource["endpoint_id"] == "ep1" + assert payload["entrypoint"]["ok"] is True + assert payload["entrypoint"]["name"] == "main" + + def test_entrypoint_failure(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text( + APP_SOURCE + + "\n@runpod.local_entrypoint\ndef main():\n" + + " raise RuntimeError('assertion blew up')\n" + ) + monkeypatch.chdir(tmp_path) + session, make_session = self._session() + with patch("runpod.apps.dev.DevSession", side_effect=make_session): + result = _runner().invoke( + cli, ["flash", "dev", str(module), "--once", "--json"] + ) + assert result.exit_code == 1 + payload = _last_json(result) + assert payload["ok"] is False + assert payload["entrypoint"]["ok"] is False + assert "assertion blew up" in payload["entrypoint"]["error"]