diff --git a/ops/hookcmds/_utils.py b/ops/hookcmds/_utils.py index 7a85f9de6..912322cc1 100644 --- a/ops/hookcmds/_utils.py +++ b/ops/hookcmds/_utils.py @@ -17,6 +17,8 @@ import datetime import subprocess +from .._private import timeconv + class Error(Exception): """Raised when a hook command exits with a non-zero code.""" @@ -56,8 +58,11 @@ def run( def datetime_from_iso(dt: str) -> datetime.datetime: """Converts a Juju-specific ISO 8601 string to a datetime object.""" - # Older versions of Python cannot handle the 'Z'. - return datetime.datetime.fromisoformat(dt.replace('Z', '+00:00')) + # parse_rfc3339 handles arbitrary precision fractional seconds, but requires a timezone. + # If no timezone is present, assume UTC (add 'Z'). + if not dt.endswith('Z') and not ('+' in dt[-6:] or '-' in dt[-6:]): + dt = dt + 'Z' + return timeconv.parse_rfc3339(dt) def datetime_to_iso(dt: datetime.datetime) -> str: diff --git a/test/test_hookcmds.py b/test/test_hookcmds.py index 6f0ea9885..4a48eabae 100644 --- a/test/test_hookcmds.py +++ b/test/test_hookcmds.py @@ -897,3 +897,43 @@ def test_storage_list_named(run: Run): run.handle(['storage-list', '--format=json', 'stor'], stdout='["stor/1", "stor/2"]') result = hookcmds.storage_list('stor') assert result == ['stor/1', 'stor/2'] + + +@pytest.mark.parametrize( + 'timestamp,expected', + [ + # Juju 3.6 format (no fractional seconds) + ( + '2026-01-05T23:28:38Z', + datetime.datetime(2026, 1, 5, 23, 28, 38, tzinfo=datetime.timezone.utc), + ), + # Juju 4.0 format (8 digits) + ( + '2026-01-05T23:34:25.50029526Z', + datetime.datetime(2026, 1, 5, 23, 34, 25, 500295, tzinfo=datetime.timezone.utc), + ), + # 5 digits (from issue) + ( + '2026-04-10T18:34:45.65844+00:00', + datetime.datetime(2026, 4, 10, 18, 34, 45, 658440, tzinfo=datetime.timezone.utc), + ), + # Edge case: 1 digit + ( + '2026-01-05T23:34:25.1Z', + datetime.datetime(2026, 1, 5, 23, 34, 25, 100000, tzinfo=datetime.timezone.utc), + ), + # Edge case: 9 digits (nanosecond precision) + ( + '2026-01-05T23:34:25.123456789Z', + datetime.datetime(2026, 1, 5, 23, 34, 25, 123457, tzinfo=datetime.timezone.utc), + ), + # No timezone (assumes UTC) + ( + '2025-08-28T13:20:00', + datetime.datetime(2025, 8, 28, 13, 20, 0, tzinfo=datetime.timezone.utc), + ), + ], +) +def test_datetime_from_iso(timestamp: str, expected: datetime.datetime): + result = hookcmds._utils.datetime_from_iso(timestamp) + assert result == expected