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

Commit 66691f4

Browse files
authored
Merge pull request #740 from jumpstarter-dev/acquisition-timeout
acquisition timeout support and humanized durations
2 parents 68125a7 + 2461a69 commit 66691f4

5 files changed

Lines changed: 105 additions & 16 deletions

File tree

packages/jumpstarter-cli/jumpstarter_cli/common.py

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import click
55
from pydantic import TypeAdapter, ValidationError
6+
from pytimeparse2 import parse as parse_duration
67

78
opt_selector = click.option(
89
"-l",
@@ -15,14 +16,59 @@
1516
class DurationParamType(click.ParamType):
1617
name = "duration"
1718

19+
def __init__(self, minimum: timedelta | None = None):
20+
super().__init__()
21+
self.minimum = minimum
22+
1823
def convert(self, value, param, ctx):
1924
if isinstance(value, timedelta):
20-
return value
25+
td = value
26+
elif isinstance(value, int):
27+
# Integer as seconds (backward compatibility)
28+
td = timedelta(seconds=value)
29+
elif isinstance(value, str):
30+
# Try parsing as plain integer first (backward compatibility)
31+
try:
32+
int_value = int(value)
33+
td = timedelta(seconds=int_value)
34+
except ValueError:
35+
# Parse with pytimeparse2 first (supports human-readable formats)
36+
td = None
37+
try:
38+
seconds = parse_duration(value)
39+
if seconds is not None:
40+
td = timedelta(seconds=seconds)
41+
except (ValueError, TypeError):
42+
pass
43+
44+
# Fall back to pydantic/speedate for ISO 8601 and other formats
45+
if td is None:
46+
try:
47+
td = TypeAdapter(timedelta).validate_python(value)
48+
except (ValueError, ValidationError):
49+
self.fail(
50+
(
51+
f"{value!r} is not a valid duration "
52+
"(e.g., '30m', '3h30m', '1d', '1d3h40m', 'PT1H30M', '01:30:00')"
53+
),
54+
param,
55+
ctx,
56+
)
57+
else:
58+
self.fail(
59+
f"{value!r} is not a valid duration (e.g., '30m', '3h30m', '1d', '1d3h40m')",
60+
param,
61+
ctx,
62+
)
2163

22-
try:
23-
return TypeAdapter(timedelta).validate_python(value)
24-
except (ValueError, ValidationError):
25-
self.fail(f"{value!r} is not a valid duration", param, ctx)
64+
# Validate minimum if specified
65+
if self.minimum is not None and td < self.minimum:
66+
min_seconds = int(self.minimum.total_seconds())
67+
self.fail(
68+
f"{value!r} must be at least {min_seconds} seconds", param, ctx
69+
)
70+
71+
return td
2672

2773

2874
DURATION = DurationParamType()
@@ -36,12 +82,11 @@ def convert(self, value, param, ctx):
3682
Accepted duration formats:
3783
3884
\b
39-
PnYnMnDTnHnMnS - ISO 8601 duration format
40-
HH:MM:SS - time in hours, minutes, seconds
41-
D days, HH:MM:SS - time prefixed by X days
42-
D d, HH:MM:SS - time prefixed by X d
85+
Human-readable: 30m, 3h30m, 1d, 1d3h40m, etc.
86+
ISO 8601: PT1H30M, P1DT2H30M, etc.
87+
Time format: 01:30:00, 2 days, 01:30:00, etc.
4388
44-
See https://docs.rs/speedate/latest/speedate/ for details
89+
See https://github.com/wroberts/pytimeparse2 for details
4590
""",
4691
)
4792

@@ -67,6 +112,21 @@ def convert(self, value, param, ctx):
67112

68113
DATETIME = DateTimeParamType()
69114

115+
116+
ACQUISITION_TIMEOUT = DurationParamType(minimum=timedelta(seconds=5))
117+
118+
opt_acquisition_timeout = partial(
119+
click.option,
120+
"--acquisition-timeout",
121+
"acquisition_timeout",
122+
type=ACQUISITION_TIMEOUT,
123+
default=None,
124+
help=(
125+
"Override acquisition timeout (e.g., '30m', '3h30m', '1d', '1d3h40m', "
126+
"or seconds as integer). Must be >= 5 seconds."
127+
),
128+
)
129+
70130
opt_begin_time = click.option(
71131
"--begin-time",
72132
"begin_time",

packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
99
from jumpstarter_cli_common.signal import signal_handler
1010

11-
from .common import opt_duration_partial, opt_selector
11+
from .common import opt_acquisition_timeout, opt_duration_partial, opt_selector
1212
from .login import relogin_client
1313
from jumpstarter.common.utils import launch_shell
1414
from jumpstarter.config.client import ClientConfigV1Alpha1
@@ -33,7 +33,9 @@ def launch_remote_shell(path: str) -> int:
3333
return launch_remote_shell(path)
3434

3535

36-
async def _shell_with_signal_handling(config, selector, lease_name, duration, exporter_logs, command):
36+
async def _shell_with_signal_handling(
37+
config, selector, lease_name, duration, exporter_logs, command, acquisition_timeout
38+
):
3739
"""Handle lease acquisition and shell execution with signal handling."""
3840
exit_code = 0
3941
cancelled_exc_class = get_cancelled_exc_class()
@@ -43,7 +45,7 @@ async def _shell_with_signal_handling(config, selector, lease_name, duration, ex
4345
try:
4446
try:
4547
async with anyio.from_thread.BlockingPortal() as portal:
46-
async with config.lease_async(selector, lease_name, duration, portal) as lease:
48+
async with config.lease_async(selector, lease_name, duration, portal, acquisition_timeout) as lease:
4749
exit_code = await anyio.to_thread.run_sync(
4850
_run_shell_with_lease, lease, exporter_logs, config, command
4951
)
@@ -70,9 +72,10 @@ async def _shell_with_signal_handling(config, selector, lease_name, duration, ex
7072
@opt_selector
7173
@opt_duration_partial(default=timedelta(minutes=30), show_default="00:30:00")
7274
@click.option("--exporter-logs", is_flag=True, help="Enable exporter log streaming")
75+
@opt_acquisition_timeout()
7376
# end client specific
7477
@handle_exceptions_with_reauthentication(relogin_client)
75-
def shell(config, command: tuple[str, ...], lease_name, selector, duration, exporter_logs):
78+
def shell(config, command: tuple[str, ...], lease_name, selector, duration, exporter_logs, acquisition_timeout):
7679
"""
7780
Spawns a shell (or custom command) connecting to a local or remote exporter
7881
@@ -88,7 +91,14 @@ def shell(config, command: tuple[str, ...], lease_name, selector, duration, expo
8891
match config:
8992
case ClientConfigV1Alpha1():
9093
exit_code = anyio.run(
91-
_shell_with_signal_handling, config, selector, lease_name, duration, exporter_logs, command
94+
_shell_with_signal_handling,
95+
config,
96+
selector,
97+
lease_name,
98+
duration,
99+
exporter_logs,
100+
command,
101+
acquisition_timeout,
92102
)
93103
sys.exit(exit_code)
94104

packages/jumpstarter-cli/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ requires-python = ">=3.11"
1313
dependencies = [
1414
"jumpstarter-cli-admin",
1515
"jumpstarter-cli-driver",
16+
"pytimeparse2>=1.7.1",
1617
]
1718

1819
[dependency-groups]

packages/jumpstarter/jumpstarter/config/client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ async def lease_async(
255255
lease_name: str | None,
256256
duration: timedelta,
257257
portal: BlockingPortal,
258+
acquisition_timeout: timedelta | None = None,
258259
):
259260
from jumpstarter.client import Lease
260261

@@ -263,6 +264,12 @@ async def lease_async(
263264
# when no lease name is provided, release the lease on exit
264265
release_lease = lease_name == ""
265266
try:
267+
# Convert timedelta to seconds for acquisition_timeout
268+
acquisition_timeout_seconds = (
269+
int(acquisition_timeout.total_seconds())
270+
if acquisition_timeout is not None
271+
else self.leases.acquisition_timeout
272+
)
266273
async with Lease(
267274
channel=await self.channel(),
268275
namespace=self.metadata.namespace,
@@ -275,7 +282,7 @@ async def lease_async(
275282
release=release_lease,
276283
tls_config=self.tls,
277284
grpc_options=self.grpcOptions,
278-
acquisition_timeout=self.leases.acquisition_timeout,
285+
acquisition_timeout=acquisition_timeout_seconds,
279286
) as lease:
280287
yield lease
281288

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)