Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion runpod/rp_cli/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
106 changes: 99 additions & 7 deletions runpod/rp_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,16 @@ def update(version_opt):
help="Build the artifact without deploying; writes "
"<app>-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
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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

Expand All @@ -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()
Expand All @@ -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]"
Expand All @@ -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
Expand All @@ -300,14 +350,22 @@ 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
@runpod.local_entrypoint, watches for file changes (re-scanning and
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
Expand All @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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":
Expand Down
Loading