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

Commit 2f1762a

Browse files
add pydantic validation and normalize durations to always include timezone info
(cherry picked from commit 97b31dc)
1 parent ffee9fa commit 2f1762a

2 files changed

Lines changed: 104 additions & 8 deletions

File tree

packages/jumpstarter-cli/jumpstarter_cli/common.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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

@@ -51,12 +51,18 @@ class DateTimeParamType(click.ParamType):
5151

5252
def convert(self, value, param, ctx):
5353
if isinstance(value, datetime):
54-
return value
55-
56-
try:
57-
return TypeAdapter(datetime).validate_python(value)
58-
except ValueError:
59-
self.fail(f"{value!r} is not a valid datetime", param, ctx)
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
6066

6167

6268
DATETIME = DateTimeParamType()
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+

0 commit comments

Comments
 (0)