From 6c0aafe4ed85a4f1024ec366a5058359e0329fb4 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 19 Mar 2026 13:43:59 +0100 Subject: [PATCH 1/2] feat: show expires at and remaining columns in lease listing (#32) Co-Authored-By: Claude Opus 4.6 --- e2e/tests.bats | 14 +++ .../jumpstarter/jumpstarter/client/grpc.py | 50 +++++++--- .../jumpstarter/client/grpc_test.py | 98 +++++++++++++++++++ 3 files changed, 149 insertions(+), 13 deletions(-) diff --git a/e2e/tests.bats b/e2e/tests.bats index 715473160..0cd3cfb22 100644 --- a/e2e/tests.bats +++ b/e2e/tests.bats @@ -383,6 +383,20 @@ EOF done } +@test "lease listing shows expires at and remaining columns" { + wait_for_exporter + + jmp config client use test-client-oidc + + jmp create lease --selector example.com/board=oidc --duration 1d + + run jmp get leases + assert_success + assert_output --partial "EXPIRES AT" + assert_output --partial "REMAINING" + jmp delete leases --all +} + @test "can transfer lease to another client" { wait_for_exporter diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc.py b/python/packages/jumpstarter/jumpstarter/client/grpc.py index 29f99da1e..f6e3f3fe4 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc.py @@ -203,27 +203,51 @@ def from_protobuf(cls, data: client_pb2.Lease) -> Lease: def rich_add_columns(cls, table): table.add_column("NAME", no_wrap=True) table.add_column("SELECTOR") - table.add_column("BEGIN TIME") - table.add_column("DURATION") + table.add_column("EXPIRES AT") + table.add_column("REMAINING") table.add_column("CLIENT") table.add_column("EXPORTER") - def rich_add_rows(self, table): - # Show effective_begin_time if active, otherwise show scheduled begin_time - begin_time = "" - if self.effective_begin_time: - begin_time = self.effective_begin_time.strftime("%Y-%m-%d %H:%M:%S") - elif self.begin_time: - begin_time = self.begin_time.strftime("%Y-%m-%d %H:%M:%S") + def _compute_expires_at(self): + if self.effective_end_time: + return self.effective_end_time + if self.effective_begin_time and self.duration: + return self.effective_begin_time + self.duration + if self.begin_time and self.duration: + return self.begin_time + self.duration + return None + + @staticmethod + def _format_remaining(expires_at): + if expires_at is None: + return "" + now = datetime.now(tz=expires_at.tzinfo) + remaining = expires_at - now + if remaining.total_seconds() <= 0: + return "expired" + total_seconds = int(remaining.total_seconds()) + days, remainder = divmod(total_seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes or not parts: + parts.append(f"{minutes}m") + return " ".join(parts) - # Show actual duration for ended leases, requested duration otherwise - duration = str(self.effective_duration if self.effective_end_time else self.duration or "") + def rich_add_rows(self, table): + expires_at = self._compute_expires_at() + expires_at_str = expires_at.strftime("%Y-%m-%d %H:%M:%S") if expires_at else "" + remaining_str = self._format_remaining(expires_at) table.add_row( self.name, self.selector, - begin_time, - duration, + expires_at_str, + remaining_str, self.client, self.exporter, ) diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index 49a1ed376..6f773e811 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -374,3 +374,101 @@ def test_exporter_scheduled_lease_expected_release(self): assert "my-client" in output assert "Scheduled" in output assert "2023-01-01 11:00:00" in output # begin_time (10:00) + duration (1h) + + +class TestLeaseRichDisplay: + def create_lease( + self, + name="test-lease", + selector="env=test", + duration=timedelta(hours=1), + effective_duration=None, + begin_time=None, + effective_begin_time=None, + effective_end_time=None, + client="test-client", + exporter="test-exporter", + ): + return Lease( + namespace="default", + name=name, + selector=selector, + duration=duration, + effective_duration=effective_duration, + begin_time=begin_time, + effective_begin_time=effective_begin_time, + effective_end_time=effective_end_time, + client=client, + exporter=exporter, + conditions=[], + ) + + def test_rich_add_columns_has_expires_at_and_remaining(self): + table = Table() + Lease.rich_add_columns(table) + columns = [col.header for col in table.columns] + assert columns == ["NAME", "SELECTOR", "EXPIRES AT", "REMAINING", "CLIENT", "EXPORTER"] + + def test_rich_add_columns_excludes_begin_time_and_duration(self): + table = Table() + Lease.rich_add_columns(table) + columns = [col.header for col in table.columns] + assert "BEGIN TIME" not in columns + assert "DURATION" not in columns + + def test_compute_expires_at_from_effective_end_time(self): + lease = self.create_lease( + effective_end_time=datetime(2023, 1, 1, 11, 0, 0), + ) + assert lease._compute_expires_at() == datetime(2023, 1, 1, 11, 0, 0) + + def test_compute_expires_at_from_effective_begin_and_duration(self): + lease = self.create_lease( + effective_begin_time=datetime(2023, 6, 15, 14, 30, 0), + duration=timedelta(hours=2), + ) + assert lease._compute_expires_at() == datetime(2023, 6, 15, 16, 30, 0) + + def test_compute_expires_at_from_begin_time_and_duration(self): + lease = self.create_lease( + begin_time=datetime(2023, 3, 10, 8, 0, 0), + duration=timedelta(minutes=30), + ) + assert lease._compute_expires_at() == datetime(2023, 3, 10, 8, 30, 0) + + def test_compute_expires_at_none_when_no_begin_time(self): + lease = self.create_lease() + assert lease._compute_expires_at() is None + + def test_format_remaining_expired(self): + past = datetime(2020, 1, 1, 0, 0, 0) + assert Lease._format_remaining(past) == "expired" + + def test_format_remaining_none(self): + assert Lease._format_remaining(None) == "" + + def test_rich_add_rows_shows_expires_at(self): + lease = self.create_lease( + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0), + effective_end_time=datetime(2023, 1, 1, 11, 0, 0), + ) + table = Table() + Lease.rich_add_columns(table) + lease.rich_add_rows(table) + + console = Console(file=StringIO(), width=200) + console.print(table) + output = console.file.getvalue() + assert "2023-01-01 11:00:00" in output + + def test_rich_add_rows_empty_when_no_timing_data(self): + lease = self.create_lease() + table = Table() + Lease.rich_add_columns(table) + lease.rich_add_rows(table) + + console = Console(file=StringIO(), width=200) + console.print(table) + output = console.file.getvalue() + assert "test-lease" in output + assert "test-client" in output From 583f539438c298cd4e697457a9ac5eb41d69be14 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Fri, 20 Mar 2026 16:37:08 +0100 Subject: [PATCH 2/2] fix: add missing all_clients argument to test_get_leases_calls_list_leases Co-Authored-By: Claude Opus 4.6 --- python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py index 8f3c15cef..eeebfeb6e 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py @@ -241,7 +241,7 @@ def test_get_leases_calls_list_leases(self): with patch("jumpstarter_cli.get.model_print"): get_leases.callback.__wrapped__.__wrapped__( - config=config, selector=None, output="text", show_all=False + config=config, selector=None, output="text", show_all=False, all_clients=False ) config.list_leases.assert_called_once_with(filter=None, only_active=True)