Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions python/packages/jumpstarter-cli/jumpstarter_cli/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def get_exporters(
):
"""
Display one or many exporters

\b
Status icons: + available, x offline, ~ leased,
* hook running, ! hook failed, ? unknown
"""

include_leases = "leases" in with_options
Expand Down
13 changes: 13 additions & 0 deletions python/packages/jumpstarter/jumpstarter/client/grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_serializer

from jumpstarter.client.selectors import extract_match_labels_filter, selector_contains
from jumpstarter.client.status import status_icon
from jumpstarter.common import ExporterStatus
from jumpstarter.common.grpc import translate_grpc_exceptions

Expand All @@ -32,6 +33,8 @@ def add_display_columns(table, options: WithOptions = None):
if options is None:
options = WithOptions()
table.add_column("NAME")
if not options.show_status:
table.add_column(" ")
if options.show_disabled:
table.add_column("ENABLED")
if options.show_online:
Expand All @@ -50,6 +53,8 @@ def add_exporter_row(table, exporter, options: WithOptions = None, lease_info: t
options = WithOptions()
row_data = []
row_data.append(exporter.name)
if not options.show_status:
row_data.append(exporter.status_icon())
if options.show_disabled:
row_data.append("yes" if exporter.enabled else "no")
if options.show_online:
Expand Down Expand Up @@ -149,6 +154,14 @@ def rich_add_rows(self, table, options: WithOptions = None):
lease_info = ("", "Available", "")
add_exporter_row(table, self, options, lease_info)

def status_icon(self) -> str:
"""Return an icon representing the exporter's runtime status.

Delegates to :func:`jumpstarter.client.status.status_icon` which
selects emoji or ASCII based on terminal capabilities.
"""
return status_icon(self.status)

def rich_add_names(self, names):
names.append(self.name)

Expand Down
115 changes: 96 additions & 19 deletions python/packages/jumpstarter/jumpstarter/client/grpc_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
add_display_columns,
add_exporter_row,
)
from jumpstarter.common.enums import ExporterStatus


class TestWithOptions:
Expand All @@ -38,31 +39,49 @@ def test_basic_columns(self):
add_display_columns(table)

columns = [col.header for col in table.columns]
assert columns == ["NAME", "LABELS"]
assert columns == ["NAME", " ", "LABELS"]

def test_with_online_column(self):
table = Table()
options = WithOptions(show_online=True)
add_display_columns(table, options)

columns = [col.header for col in table.columns]
assert columns == ["NAME", "ONLINE", "LABELS"]
assert columns == ["NAME", " ", "ONLINE", "LABELS"]

def test_with_leases_columns(self):
table = Table()
options = WithOptions(show_leases=True)
add_display_columns(table, options)

columns = [col.header for col in table.columns]
assert columns == ["NAME", "LABELS", "LEASED BY", "LEASE STATUS", "RELEASE TIME"]
assert columns == ["NAME", " ", "LABELS", "LEASED BY", "LEASE STATUS", "RELEASE TIME"]

def test_with_all_columns(self):
table = Table()
options = WithOptions(show_online=True, show_leases=True)
add_display_columns(table, options)

columns = [col.header for col in table.columns]
assert columns == ["NAME", "ONLINE", "LABELS", "LEASED BY", "LEASE STATUS", "RELEASE TIME"]
assert columns == ["NAME", " ", "ONLINE", "LABELS", "LEASED BY", "LEASE STATUS", "RELEASE TIME"]

def test_with_status_suppresses_icon_column(self):
table = Table()
options = WithOptions(show_status=True)
add_display_columns(table, options)

columns = [col.header for col in table.columns]
assert columns == ["NAME", "STATUS", "LABELS"]
assert " " not in columns

def test_with_status_and_online(self):
table = Table()
options = WithOptions(show_status=True, show_online=True)
add_display_columns(table, options)

columns = [col.header for col in table.columns]
assert columns == ["NAME", "ONLINE", "STATUS", "LABELS"]
assert " " not in columns


class TestAddExporterRow:
Expand All @@ -80,7 +99,7 @@ def test_basic_row(self):

# Just verify a row was added and correct number of columns
assert len(table.rows) == 1
assert len(table.columns) == 2 # NAME, LABELS
assert len(table.columns) == 3 # NAME, icon, LABELS

def test_row_with_lease_info(self):
table = Table()
Expand All @@ -92,7 +111,7 @@ def test_row_with_lease_info(self):
add_exporter_row(table, exporter, options, lease_info)

assert len(table.rows) == 1
assert len(table.columns) == 5 # NAME, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 6 # NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME

def test_row_with_lease_info_available(self):
table = Table()
Expand All @@ -104,7 +123,7 @@ def test_row_with_lease_info_available(self):
add_exporter_row(table, exporter, options, lease_info)

assert len(table.rows) == 1
assert len(table.columns) == 5
assert len(table.columns) == 6 # NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME

def test_row_with_all_options(self):
table = Table()
Expand All @@ -116,7 +135,7 @@ def test_row_with_all_options(self):
add_exporter_row(table, exporter, options, lease_info)

assert len(table.rows) == 1
assert len(table.columns) == 6 # NAME, ONLINE, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 7 # NAME, icon, ONLINE, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME


class TestWithDisabledOption:
Expand All @@ -125,7 +144,7 @@ def test_show_disabled_adds_enabled_column(self):
options = WithOptions(show_disabled=True)
add_display_columns(table, options)
columns = [col.header for col in table.columns]
assert columns == ["NAME", "ENABLED", "LABELS"]
assert columns == ["NAME", " ", "ENABLED", "LABELS"]

def test_show_disabled_adds_enabled_value_to_row(self):
table = Table()
Expand All @@ -134,7 +153,64 @@ def test_show_disabled_adds_enabled_value_to_row(self):
exporter = Exporter(namespace="default", name="test", labels={}, enabled=False)
add_exporter_row(table, exporter, options)
assert len(table.rows) == 1
assert len(table.columns) == 3 # NAME, ENABLED, LABELS
assert len(table.columns) == 4 # NAME, icon, ENABLED, LABELS


class TestExporterStatusIconDelegation:
"""Verify Exporter.status_icon() delegates to the status module."""

@patch("jumpstarter.client.status._use_emoji", return_value=True)
def test_emoji_icon_appears_in_table_output(self, _mock):
"""Verify the emoji icon column is rendered in table output."""
exporter = Exporter(namespace="default", name="my-exporter", labels={}, status=ExporterStatus.AVAILABLE)
table = Table()
Exporter.rich_add_columns(table)
exporter.rich_add_rows(table)

columns = [col.header for col in table.columns]
assert columns[0] == "NAME"
assert columns[1] == " "

console = Console(file=StringIO(), width=80)
console.print(table)
output = console.file.getvalue()
assert "βšͺ" in output
assert "my-exporter" in output

@patch("jumpstarter.client.status._use_emoji", return_value=False)
def test_ascii_icon_appears_in_table_output(self, _mock):
"""Verify the ASCII icon column is rendered in table output."""
exporter = Exporter(namespace="default", name="my-exporter", labels={}, status=ExporterStatus.AVAILABLE)
table = Table()
Exporter.rich_add_columns(table)
exporter.rich_add_rows(table)

console = Console(file=StringIO(), width=80)
console.print(table)
output = console.file.getvalue()
assert "+" in output
assert "my-exporter" in output

@patch("jumpstarter.client.status._use_emoji", return_value=False)
def test_icon_column_suppressed_when_show_status(self, _mock):
"""When show_status=True, icon column is replaced by STATUS column."""
exporter = Exporter(
namespace="default", name="my-exporter", labels={}, status=ExporterStatus.AVAILABLE
)
table = Table()
options = WithOptions(show_status=True)
Exporter.rich_add_columns(table, options)
exporter.rich_add_rows(table, options)

columns = [col.header for col in table.columns]
assert " " not in columns
assert "STATUS" in columns

console = Console(file=StringIO(), width=80)
console.print(table)
output = console.file.getvalue()
assert "AVAILABLE" in output
assert "my-exporter" in output


class TestExporterList:
Expand Down Expand Up @@ -166,7 +242,7 @@ def test_exporter_without_lease(self):
exporter.rich_add_rows(table)

assert len(table.rows) == 1
assert len(table.columns) == 2 # NAME, LABELS
assert len(table.columns) == 3 # NAME, icon, LABELS

def test_exporter_with_lease_no_display(self):
lease = self.create_test_lease()
Expand All @@ -180,7 +256,7 @@ def test_exporter_with_lease_no_display(self):

# Should not show lease info when show_leases=False
assert len(table.rows) == 1
assert len(table.columns) == 2 # NAME, LABELS
assert len(table.columns) == 3 # NAME, icon, LABELS

def test_exporter_with_lease_display(self):
lease = self.create_test_lease()
Expand All @@ -194,7 +270,7 @@ def test_exporter_with_lease_display(self):
exporter.rich_add_rows(table, options)

assert len(table.rows) == 1
assert len(table.columns) == 5 # NAME, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 6 # NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME

# Test actual table content by rendering it
console = Console(file=StringIO(), width=120)
Expand All @@ -217,7 +293,7 @@ def test_exporter_without_lease_but_show_leases(self):
exporter.rich_add_rows(table, options)

assert len(table.rows) == 1
assert len(table.columns) == 5 # NAME, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 6 # NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME

# Test actual table content by rendering it
console = Console(file=StringIO(), width=120)
Expand Down Expand Up @@ -249,7 +325,7 @@ def test_exporter_online_status_display(self):
exporter_offline.rich_add_rows(table, options)

assert len(table.rows) == 2
assert len(table.columns) == 3 # NAME, ONLINE, LABELS
assert len(table.columns) == 4 # NAME, icon, ONLINE, LABELS

# Test actual table content by rendering it
console = Console(file=StringIO(), width=120)
Expand Down Expand Up @@ -287,7 +363,7 @@ def test_exporter_all_features_display(self):
exporter_offline_no_lease.rich_add_rows(table, options)

assert len(table.rows) == 2
assert len(table.columns) == 6 # NAME, ONLINE, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 7 # NAME, icon, ONLINE, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME

# Test actual table content by rendering it
console = Console(file=StringIO(), width=150)
Expand Down Expand Up @@ -384,8 +460,8 @@ def test_exporter_scheduled_lease_expected_release(self):
Exporter.rich_add_columns(table, options)
exporter.rich_add_rows(table, options)

# Should have 5 columns: NAME, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 5
# Should have 6 columns: NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME
assert len(table.columns) == 6
assert len(table.rows) == 1

# Test actual table content by rendering it
Expand Down Expand Up @@ -444,8 +520,9 @@ def test_rich_add_rows_includes_disabled_when_requested(self):
el.rich_add_columns(table)
el.rich_add_rows(table)
assert len(table.rows) == 2
# Should have ENABLED column when include_disabled is set
# Should have icon column and ENABLED column when include_disabled is set
columns = [col.header for col in table.columns]
assert " " in columns
assert "ENABLED" in columns

def test_rich_add_names_skips_disabled(self):
Expand Down
77 changes: 77 additions & 0 deletions python/packages/jumpstarter/jumpstarter/client/status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Exporter status display: icons, ASCII fallbacks, and help text.

This module centralises the mapping between ``ExporterStatus`` values and
the visual indicators shown in CLI output. It is deliberately kept
separate from ``grpc.py`` so that display logic does not leak into the
gRPC data-model layer.
"""

from __future__ import annotations

import os
import sys

from jumpstarter.common import ExporterStatus

_EMOJI_TERM_PREFIXES = (
"xterm",
"screen",
"tmux",
"rxvt",
"alacritty",
"kitty",
"wezterm",
"foot",
"ghostty",
"contour",
"rio",
)
"""Terminal type prefixes whose modern implementations reliably render emoji."""


def _use_emoji() -> bool:
"""Return True when the output terminal is likely to support emoji.

Falls back to ASCII indicators when any of the following is true:
* ``NO_COLOR`` environment variable is set (spirit: plain text output).
* ``stdout`` is not a TTY (output piped to a file / another process).
* ``TERM`` is not set or does not match a known emoji-capable prefix
(e.g. ``linux``, ``vt100``, ``dumb``, ``ansi`` all fall back to ASCII).
"""
if os.environ.get("NO_COLOR") is not None:
return False
if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
return False
term = os.environ.get("TERM", "")
return term.startswith(_EMOJI_TERM_PREFIXES)


# Central mapping: status -> (emoji, ascii, description)
# Keep the help text in ``STATUS_HELP_TEXT`` in sync when editing this dict.
STATUS_ICONS: dict[ExporterStatus | None, tuple[str, str, str]] = {
ExporterStatus.AVAILABLE: ("βšͺ", "+", "available"),
ExporterStatus.OFFLINE: ("❌", "x", "offline"),
ExporterStatus.BEFORE_LEASE_HOOK: ("βš™οΈ", "*", "hook running"),
ExporterStatus.AFTER_LEASE_HOOK: ("βš™οΈ", "*", "hook running"),
ExporterStatus.LEASE_READY: ("πŸ”’", "~", "leased"),
ExporterStatus.BEFORE_LEASE_HOOK_FAILED: ("❗", "!", "hook failed"),
ExporterStatus.AFTER_LEASE_HOOK_FAILED: ("❗", "!", "hook failed"),
None: ("❓", "?", "unknown"),
}

_FALLBACK = STATUS_ICONS[None]


def status_icon(status: ExporterStatus | None) -> str:
"""Return a single-character icon for *status*.

Uses emoji when the terminal supports it, otherwise falls back to
ASCII characters (respects ``NO_COLOR``, non-TTY output, and
terminals without known emoji support).
"""
emoji_idx = 0 if _use_emoji() else 1
return STATUS_ICONS.get(status, _FALLBACK)[emoji_idx]


# Static help text kept next to the mapping so updates stay in sync.
STATUS_HELP_TEXT = "Status icons: + available, x offline, ~ leased, * hook running, ! hook failed, ? unknown"
Loading
Loading