11import inspect
22from contextlib import asynccontextmanager
3- from datetime import timedelta
3+ from datetime import datetime , timedelta
44from unittest .mock import AsyncMock , Mock , patch
55
66import anyio
77import click
88import pytest
99
10- from jumpstarter_cli .shell import _shell_with_signal_handling , shell
10+ from jumpstarter_cli .shell import _resolve_lease_from_active_async , _shell_with_signal_handling , shell
1111
12+ from jumpstarter .client .grpc import Lease , LeaseList
1213from jumpstarter .config .client import ClientConfigV1Alpha1
1314from jumpstarter .config .env import JMP_LEASE
1415
1516
17+ def _make_lease (name : str , client : str = "test-client" ) -> Lease :
18+ return Lease (
19+ namespace = "default" ,
20+ name = name ,
21+ selector = "" ,
22+ exporter_name = None ,
23+ duration = timedelta (minutes = 30 ),
24+ effective_duration = None ,
25+ begin_time = datetime .now (),
26+ client = client ,
27+ exporter = "test-exporter" ,
28+ conditions = [],
29+ effective_begin_time = None ,
30+ effective_end_time = None ,
31+ )
32+
33+
34+ def _make_lease_list (names : list [str ]) -> LeaseList :
35+ return LeaseList (
36+ leases = [_make_lease (n ) for n in names ],
37+ next_page_token = None ,
38+ )
39+
40+
1641class _DummyConfig :
1742 def __init__ (self ):
1843 self .captured = None
@@ -48,10 +73,13 @@ def test_shell_passes_exporter_name_to_lease_async():
4873 assert config .captured [1 ] == "laptop-test-exporter"
4974
5075
51- def test_shell_requires_selector_or_name ():
52- with pytest .raises (click .UsageError , match = "one of --selector/-l or --name/-n is required" ):
53- inspect .unwrap (shell .callback )(
54- config = Mock (spec = ClientConfigV1Alpha1 ),
76+ def test_shell_requires_selector_or_name_when_no_leases ():
77+ config = Mock (spec = ClientConfigV1Alpha1 )
78+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
79+ config .list_leases = AsyncMock (return_value = _make_lease_list ([]))
80+ with pytest .raises (click .UsageError , match = "no active leases found" ):
81+ shell .callback .__wrapped__ .__wrapped__ (
82+ config = config ,
5583 command = (),
5684 lease_name = None ,
5785 selector = None ,
@@ -81,6 +109,118 @@ def test_shell_allows_existing_lease_name_without_selector_or_name():
81109 mock_exit .assert_called_once_with (0 )
82110
83111
112+ def test_shell_auto_connects_single_lease ():
113+ config = Mock (spec = ClientConfigV1Alpha1 )
114+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
115+ with (
116+ patch ("jumpstarter_cli.shell.anyio.run" , side_effect = ["my-only-lease" , 0 ]) as mock_run ,
117+ patch ("jumpstarter_cli.shell.sys.exit" ) as mock_exit ,
118+ ):
119+ shell .callback .__wrapped__ .__wrapped__ (
120+ config = config ,
121+ command = (),
122+ lease_name = None ,
123+ selector = None ,
124+ exporter_name = None ,
125+ duration = timedelta (minutes = 1 ),
126+ exporter_logs = False ,
127+ acquisition_timeout = None ,
128+ )
129+
130+ resolve_call_args = mock_run .call_args_list [0 ]
131+ assert resolve_call_args [0 ][0 ] is _resolve_lease_from_active_async
132+ assert resolve_call_args [0 ][1 ] is config
133+ shell_call_args = mock_run .call_args_list [1 ]
134+ assert shell_call_args [0 ][4 ] == "my-only-lease"
135+ mock_exit .assert_called_once_with (0 )
136+
137+
138+ def test_shell_no_leases_shows_guidance ():
139+ config = Mock (spec = ClientConfigV1Alpha1 )
140+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
141+ config .list_leases = AsyncMock (return_value = _make_lease_list ([]))
142+ with pytest .raises (click .UsageError , match = "no active leases found" ):
143+ shell .callback .__wrapped__ .__wrapped__ (
144+ config = config ,
145+ command = (),
146+ lease_name = None ,
147+ selector = None ,
148+ exporter_name = None ,
149+ duration = timedelta (minutes = 1 ),
150+ exporter_logs = False ,
151+ acquisition_timeout = None ,
152+ )
153+ config .list_leases .assert_called_once_with (only_active = True )
154+
155+
156+ def test_shell_multi_lease_tty_picker ():
157+ config = Mock (spec = ClientConfigV1Alpha1 )
158+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
159+ config .list_leases = AsyncMock (return_value = _make_lease_list (["lease-a" , "lease-b" , "lease-c" ]))
160+ with (
161+ patch ("jumpstarter_cli.shell.sys.stdin" ) as mock_stdin ,
162+ patch ("jumpstarter_cli.shell.click.prompt" , return_value = 2 ),
163+ ):
164+ mock_stdin .isatty .return_value = True
165+ selected = anyio .run (_resolve_lease_from_active_async , config )
166+
167+ assert selected == "lease-b"
168+ config .list_leases .assert_called_once_with (only_active = True )
169+
170+
171+ def test_shell_multi_lease_no_tty_error ():
172+ config = Mock (spec = ClientConfigV1Alpha1 )
173+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
174+ config .list_leases = AsyncMock (return_value = _make_lease_list (["lease-a" , "lease-b" ]))
175+ with (
176+ patch ("jumpstarter_cli.shell.sys.stdin" ) as mock_stdin ,
177+ pytest .raises (click .UsageError , match = "lease-a" ),
178+ ):
179+ mock_stdin .isatty .return_value = False
180+ shell .callback .__wrapped__ .__wrapped__ (
181+ config = config ,
182+ command = (),
183+ lease_name = None ,
184+ selector = None ,
185+ exporter_name = None ,
186+ duration = timedelta (minutes = 1 ),
187+ exporter_logs = False ,
188+ acquisition_timeout = None ,
189+ )
190+
191+
192+ def test_shell_filters_leases_by_current_client ():
193+ other_user_lease = _make_lease ("other-user-lease" , client = "other-client" )
194+ my_lease = _make_lease ("my-lease" , client = "test-client" )
195+ lease_list = LeaseList (leases = [other_user_lease , my_lease ], next_page_token = None )
196+ config = Mock (spec = ClientConfigV1Alpha1 )
197+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
198+ config .list_leases = AsyncMock (return_value = lease_list )
199+
200+ selected = anyio .run (_resolve_lease_from_active_async , config )
201+ assert selected == "my-lease"
202+ config .list_leases .assert_called_once_with (only_active = True )
203+
204+
205+ def test_shell_no_own_leases_among_others ():
206+ other_lease = _make_lease ("other-lease" , client = "other-client" )
207+ lease_list = LeaseList (leases = [other_lease ], next_page_token = None )
208+ config = Mock (spec = ClientConfigV1Alpha1 )
209+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
210+ config .list_leases = AsyncMock (return_value = lease_list )
211+ with pytest .raises (click .UsageError , match = "no active leases found" ):
212+ shell .callback .__wrapped__ .__wrapped__ (
213+ config = config ,
214+ command = (),
215+ lease_name = None ,
216+ selector = None ,
217+ exporter_name = None ,
218+ duration = timedelta (minutes = 1 ),
219+ exporter_logs = False ,
220+ acquisition_timeout = None ,
221+ )
222+
223+
84224def test_shell_allows_env_lease_without_selector_or_name ():
85225 with (
86226 patch ("jumpstarter_cli.shell.anyio.run" , return_value = 0 ),
@@ -99,3 +239,12 @@ def test_shell_allows_env_lease_without_selector_or_name():
99239 )
100240
101241 mock_exit .assert_called_once_with (0 )
242+
243+ def test_resolve_lease_handles_async_list_leases ():
244+ config = Mock (spec = ClientConfigV1Alpha1 )
245+ config .metadata = type ("Metadata" , (), {"name" : "test-client" })()
246+ config .list_leases = AsyncMock (return_value = _make_lease_list (["async-lease" ]))
247+
248+ selected = anyio .run (_resolve_lease_from_active_async , config )
249+ assert selected == "async-lease"
250+ config .list_leases .assert_called_once_with (only_active = True )
0 commit comments