Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 72e6151

Browse files
authored
Merge pull request #720 from jumpstarter-dev/backport-704-to-release-0.7
[Backport release-0.7] client lease improvements
2 parents 1cf4799 + 2bbdeaf commit 72e6151

17 files changed

Lines changed: 510 additions & 106 deletions

File tree

Makefile

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ help:
88
@echo ""
99
@echo "Build targets:"
1010
@echo " build - Build all packages"
11-
@echo " generate - Generate code from protobuf definitions"
11+
@echo " protobuf-gen - Generate code from protobuf definitions"
1212
@echo " sync - Sync all packages and extras"
1313
@echo ""
1414
@echo "Documentation targets:"
@@ -75,8 +75,18 @@ pkg-ty-all: $(addprefix pkg-ty-,$(PKG_TARGETS))
7575
build:
7676
uv build --all --out-dir dist
7777

78-
generate:
79-
buf generate
78+
protobuf-gen:
79+
podman run --volume "$(shell pwd):/workspace" --workdir /workspace docker.io/bufbuild/buf:latest generate
80+
# Fix Python imports: convert absolute imports to relative imports
81+
# In jumpstarter/client/v1: from jumpstarter.v1 import -> from ...v1 import
82+
find packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1 -name "*_pb2*.py" -type f -exec sed -i.bak \
83+
-e 's|^from jumpstarter\.v1 import|from ...v1 import|g' \
84+
-e 's|^from jumpstarter\.client\.v1 import|from . import|g' \
85+
{} \; -exec rm {}.bak \;
86+
# In jumpstarter/v1: from jumpstarter.v1 import -> from . import
87+
find packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/v1 -name "*_pb2*.py" -type f -exec sed -i.bak \
88+
-e 's|^from jumpstarter\.v1 import|from . import|g' \
89+
{} \; -exec rm {}.bak \;
8090

8191
sync:
8292
uv sync --all-packages --all-extras

buf.gen.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ plugins:
88
out: ./packages/jumpstarter-protocol/jumpstarter_protocol
99
inputs:
1010
- git_repo: https://github.com/jumpstarter-dev/jumpstarter-protocol.git
11+
branch: main
1112
subdir: proto

packages/jumpstarter-cli/jumpstarter_cli/common.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from datetime import timedelta
1+
from datetime import datetime, timedelta
22
from functools import partial
33

44
import click
5-
from pydantic import TypeAdapter
5+
from pydantic import TypeAdapter, ValidationError
66

77
opt_selector = click.option(
88
"-l",
@@ -21,7 +21,7 @@ def convert(self, value, param, ctx):
2121

2222
try:
2323
return TypeAdapter(timedelta).validate_python(value)
24-
except ValueError:
24+
except (ValueError, ValidationError):
2525
self.fail(f"{value!r} is not a valid duration", param, ctx)
2626

2727

@@ -44,3 +44,37 @@ def convert(self, value, param, ctx):
4444
See https://docs.rs/speedate/latest/speedate/ for details
4545
""",
4646
)
47+
48+
49+
class DateTimeParamType(click.ParamType):
50+
name = "datetime"
51+
52+
def convert(self, value, param, ctx):
53+
if isinstance(value, datetime):
54+
dt = value
55+
else:
56+
try:
57+
dt = TypeAdapter(datetime).validate_python(value)
58+
except (ValueError, ValidationError):
59+
self.fail(f"{value!r} is not a valid datetime", param, ctx)
60+
61+
# Normalize naive datetimes to local timezone
62+
if dt.tzinfo is None:
63+
dt = dt.astimezone()
64+
65+
return dt
66+
67+
68+
DATETIME = DateTimeParamType()
69+
70+
opt_begin_time = click.option(
71+
"--begin-time",
72+
"begin_time",
73+
type=DATETIME,
74+
default=None,
75+
help="""
76+
Begin time for the lease in ISO 8601 format (e.g., 2024-01-01T12:00:00 or 2024-01-01T12:00:00Z).
77+
If not specified, the lease tries to be acquired immediately. The lease duration always starts
78+
at the actual time of acquisition.
79+
""",
80+
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from datetime import datetime, timedelta, timezone
2+
3+
import click
4+
import pytest
5+
6+
from jumpstarter_cli.common import DATETIME, DURATION, DateTimeParamType, DurationParamType
7+
8+
9+
class TestDateTimeParamType:
10+
"""Test DateTimeParamType parameter parsing and normalization."""
11+
12+
def test_parse_iso8601_with_timezone(self):
13+
"""Test parsing ISO 8601 datetime with timezone."""
14+
dt = DATETIME.convert("2024-01-01T12:00:00Z", None, None)
15+
assert dt.year == 2024
16+
assert dt.month == 1
17+
assert dt.day == 1
18+
assert dt.hour == 12
19+
assert dt.minute == 0
20+
assert dt.second == 0
21+
assert dt.tzinfo is not None
22+
assert dt.tzinfo == timezone.utc
23+
24+
def test_parse_iso8601_naive_gets_normalized(self):
25+
"""Test that naive datetime gets normalized to local timezone."""
26+
dt = DATETIME.convert("2024-01-01T12:00:00", None, None)
27+
assert dt.year == 2024
28+
assert dt.month == 1
29+
assert dt.day == 1
30+
assert dt.hour == 12
31+
assert dt.minute == 0
32+
assert dt.second == 0
33+
# Should have been normalized to local timezone
34+
assert dt.tzinfo is not None
35+
36+
def test_pass_through_datetime_object_with_timezone(self):
37+
"""Test that datetime object with timezone passes through."""
38+
input_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
39+
dt = DATETIME.convert(input_dt, None, None)
40+
assert dt == input_dt
41+
assert dt.tzinfo == timezone.utc
42+
43+
def test_pass_through_datetime_object_naive_gets_normalized(self):
44+
"""Test that naive datetime object gets normalized."""
45+
input_dt = datetime(2024, 1, 1, 12, 0, 0) # Naive
46+
dt = DATETIME.convert(input_dt, None, None)
47+
assert dt.year == 2024
48+
assert dt.month == 1
49+
assert dt.day == 1
50+
assert dt.hour == 12
51+
# Should have been normalized to local timezone
52+
assert dt.tzinfo is not None
53+
54+
def test_invalid_datetime_raises_click_exception(self):
55+
"""Test that invalid datetime string raises click exception."""
56+
param_type = DateTimeParamType()
57+
with pytest.raises(click.BadParameter, match="is not a valid datetime"):
58+
param_type.convert("not-a-datetime", None, None)
59+
60+
61+
class TestDurationParamType:
62+
"""Test DurationParamType parameter parsing."""
63+
64+
def test_parse_iso8601_duration(self):
65+
"""Test parsing ISO 8601 duration."""
66+
td = DURATION.convert("PT1H30M", None, None)
67+
assert td == timedelta(hours=1, minutes=30)
68+
69+
def test_parse_time_format(self):
70+
"""Test parsing HH:MM:SS format."""
71+
td = DURATION.convert("01:30:00", None, None)
72+
assert td == timedelta(hours=1, minutes=30)
73+
74+
def test_parse_days_and_time(self):
75+
"""Test parsing 'D days, HH:MM:SS' format."""
76+
td = DURATION.convert("2 days, 01:30:00", None, None)
77+
assert td == timedelta(days=2, hours=1, minutes=30)
78+
79+
def test_pass_through_timedelta_object(self):
80+
"""Test that timedelta object passes through."""
81+
input_td = timedelta(hours=1, minutes=30)
82+
td = DURATION.convert(input_td, None, None)
83+
assert td == input_td
84+
85+
def test_invalid_duration_raises_click_exception(self):
86+
"""Test that invalid duration string raises click exception."""
87+
param_type = DurationParamType()
88+
with pytest.raises(click.BadParameter, match="is not a valid duration"):
89+
param_type.convert("not-a-duration", None, None)
90+

packages/jumpstarter-cli/jumpstarter_cli/create.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from datetime import timedelta
1+
from datetime import datetime, timedelta
22

33
import click
44
from jumpstarter_cli_common.config import opt_config
55
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
66
from jumpstarter_cli_common.opt import OutputType, opt_output_all
77
from jumpstarter_cli_common.print import model_print
88

9-
from .common import opt_duration_partial, opt_selector
9+
from .common import opt_begin_time, opt_duration_partial, opt_selector
1010
from .login import relogin_client
1111

1212

@@ -21,9 +21,10 @@ def create():
2121
@opt_config(exporter=False)
2222
@opt_selector
2323
@opt_duration_partial(required=True)
24+
@opt_begin_time
2425
@opt_output_all
2526
@handle_exceptions_with_reauthentication(relogin_client)
26-
def create_lease(config, selector: str, duration: timedelta, output: OutputType):
27+
def create_lease(config, selector: str, duration: timedelta, begin_time: datetime | None, output: OutputType):
2728
"""
2829
Create a lease
2930
@@ -49,6 +50,6 @@ def create_lease(config, selector: str, duration: timedelta, output: OutputType)
4950
5051
"""
5152

52-
lease = config.create_lease(selector=selector, duration=duration)
53+
lease = config.create_lease(selector=selector, duration=duration, begin_time=begin_time)
5354

5455
model_print(lease, output)

packages/jumpstarter-cli/jumpstarter_cli/get.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,19 @@ def get_exporters(config, selector: str | None, output: OutputType, with_options
4141
@opt_config(exporter=False)
4242
@opt_selector
4343
@opt_output_all
44+
@click.option(
45+
"--all",
46+
"show_all",
47+
is_flag=True,
48+
default=False,
49+
help="Show all leases including expired ones"
50+
)
4451
@handle_exceptions_with_reauthentication(relogin_client)
45-
def get_leases(config, selector: str | None, output: OutputType):
52+
def get_leases(config, selector: str | None, output: OutputType, show_all: bool):
4653
"""
4754
Display one or many leases
4855
"""
4956

50-
leases = config.list_leases(filter=selector)
57+
leases = config.list_leases(filter=selector, only_active=not show_all)
5158

5259
model_print(leases, output)

packages/jumpstarter-cli/jumpstarter_cli/get_test.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
from datetime import datetime, timedelta
12
from unittest.mock import Mock
23

34
import click
45
import pytest
56
from jumpstarter_cli_common.opt import parse_comma_separated
67

7-
from jumpstarter.client.grpc import Exporter, ExporterList, Lease
8+
from jumpstarter.client.grpc import Exporter, ExporterList, Lease, LeaseList
89
from jumpstarter.config.client import ClientConfigV1Alpha1
910

1011

@@ -239,3 +240,109 @@ def test_exporter_to_exporter_list_flow(self):
239240
assert exporter_list.exporters[1].name == "server-001"
240241
assert exporter_list.include_online is True
241242
assert exporter_list.include_leases is False
243+
244+
245+
class TestGetLeasesLogic:
246+
"""Tests for get leases command logic (simulating server-side filtering)"""
247+
248+
def create_test_lease(self, namespace="default", name="lease-1", status="In-Use",
249+
effective_begin_time=None, effective_end_time=None,
250+
duration=timedelta(hours=1)):
251+
"""Create a mock lease for testing"""
252+
lease = Mock(spec=Lease)
253+
lease.namespace = namespace
254+
lease.name = name
255+
lease.client = "test-client"
256+
lease.exporter = "test-exporter"
257+
lease.get_status.return_value = status
258+
lease.effective_begin_time = effective_begin_time
259+
lease.effective_end_time = effective_end_time
260+
lease.duration = duration
261+
lease.effective_duration = timedelta(minutes=30) if effective_begin_time else None
262+
lease.begin_time = None
263+
return lease
264+
265+
def test_only_active_excludes_expired_leases(self):
266+
"""Test that server returns only active leases when only_active=True"""
267+
# When only_active=True, server returns only active lease
268+
active_lease = self.create_test_lease(
269+
name="active-lease",
270+
status="In-Use",
271+
effective_begin_time=datetime(2023, 1, 1, 10, 0, 0)
272+
)
273+
274+
leases_from_server = LeaseList(leases=[active_lease], next_page_token=None)
275+
276+
assert len(leases_from_server.leases) == 1
277+
assert leases_from_server.leases[0].name == "active-lease"
278+
assert leases_from_server.leases[0].get_status() == "In-Use"
279+
280+
def test_show_all_includes_expired_leases(self):
281+
"""Test that server returns all leases including expired when only_active=False"""
282+
# When only_active=False, server returns both active and expired
283+
active_lease = self.create_test_lease(
284+
name="active-lease",
285+
status="In-Use",
286+
effective_begin_time=datetime(2023, 1, 1, 10, 0, 0)
287+
)
288+
expired_lease = self.create_test_lease(
289+
name="expired-lease",
290+
status="Expired",
291+
effective_begin_time=datetime(2023, 1, 1, 8, 0, 0),
292+
effective_end_time=datetime(2023, 1, 1, 9, 0, 0)
293+
)
294+
295+
leases_from_server = LeaseList(leases=[active_lease, expired_lease], next_page_token=None)
296+
297+
assert len(leases_from_server.leases) == 2
298+
assert leases_from_server.leases[0].name == "active-lease"
299+
assert leases_from_server.leases[1].name == "expired-lease"
300+
301+
def test_multiple_active_leases_returned(self):
302+
"""Test that server returns all active leases when only_active=True"""
303+
# Server returns multiple active leases (different statuses but all non-expired)
304+
lease1 = self.create_test_lease(
305+
name="lease-1",
306+
status="In-Use",
307+
effective_begin_time=datetime(2023, 1, 1, 10, 0, 0)
308+
)
309+
lease2 = self.create_test_lease(
310+
name="lease-2",
311+
status="Waiting",
312+
effective_begin_time=datetime(2023, 1, 1, 11, 0, 0)
313+
)
314+
lease3 = self.create_test_lease(
315+
name="lease-3",
316+
status="In-Use",
317+
effective_begin_time=datetime(2023, 1, 1, 12, 0, 0)
318+
)
319+
320+
leases_from_server = LeaseList(leases=[lease1, lease2, lease3], next_page_token=None)
321+
322+
assert len(leases_from_server.leases) == 3
323+
assert all(lease.get_status() != "Expired" for lease in leases_from_server.leases)
324+
325+
def test_all_expired_when_show_all(self):
326+
"""Test that server can return only expired leases when only_active=False"""
327+
# When only_active=False and all leases happen to be expired
328+
expired1 = self.create_test_lease(
329+
name="expired-1",
330+
status="Expired",
331+
effective_end_time=datetime(2023, 1, 1, 8, 0, 0)
332+
)
333+
expired2 = self.create_test_lease(
334+
name="expired-2",
335+
status="Expired",
336+
effective_end_time=datetime(2023, 1, 1, 9, 0, 0)
337+
)
338+
339+
leases_from_server = LeaseList(leases=[expired1, expired2], next_page_token=None)
340+
341+
assert len(leases_from_server.leases) == 2
342+
assert all(lease.get_status() == "Expired" for lease in leases_from_server.leases)
343+
344+
def test_empty_lease_list(self):
345+
"""Test that server can return empty lease list"""
346+
leases_from_server = LeaseList(leases=[], next_page_token=None)
347+
348+
assert len(leases_from_server.leases) == 0

0 commit comments

Comments
 (0)