diff --git a/nats/js/api.py b/nats/js/api.py index d3aae15f..cf994f49 100644 --- a/nats/js/api.py +++ b/nats/js/api.py @@ -96,25 +96,32 @@ def _convert_rfc3339(resp: Dict[str, Any], field: str) -> None: val = resp.get(field, None) if val is None: return None - # Handle Zulu - offset = "+00:00" + # Handle Zulu (UTC) + # Until python < 3.11, fromisoformat() do not accept "Z" as a valid + # timezone. See: https://github.com/python/cpython/issues/80010 if val.endswith("Z"): + offset = "+00:00" raw_date = val[:-1] - # There MUST be an offset if not Zulu + # There MUST be an offset if not Zulu. + # Until python3.11, fromisoformat() only accepts 3 or 6 decimal places for + # fractional seconds. See: https://github.com/python/cpython/issues/95221 + # In order to pad missing microseconds, we need to temporary remove the offset. + # Offset has a fixed sized of 5 characters. else: offset = val[-6:] raw_date = val[:-6] - # Padd missing milliseconds - if "." not in raw_date: - raw_date += ".000000" - else: + # Pad missing microseconds by adding "0" until length is 26. + # 26 is the length of a valid RFC3339 string with microseconds precision. + # Since python datetimes do not support more than 6 decimal places + # we need to truncate the string if it has more than 6 decimal places + if "." in raw_date: raw_date = raw_date[:26] length = len(raw_date) if length < 26: raw_date += "0" * (26 - length) - # Add offset + # Add offset back raw_date = raw_date + offset - # Parse into datetime using fromisoformat + # Parse string into datetime using fromisoformat resp[field] = datetime.datetime.fromisoformat(raw_date).astimezone( datetime.timezone.utc ) diff --git a/tests/test_js.py b/tests/test_js.py index 758355ca..7ce88ccf 100644 --- a/tests/test_js.py +++ b/tests/test_js.py @@ -1400,7 +1400,9 @@ async def test_consumer_with_opt_start_time_date_only(self): deliver_policy=api.DeliverPolicy.BY_START_TIME, ) assert isinstance(con.created, datetime.datetime) - assert con.config.opt_start_time == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + assert con.config.opt_start_time == datetime.datetime( + 1970, 1, 1, tzinfo=datetime.timezone.utc + ) await nc.close() @async_test @@ -1415,7 +1417,9 @@ async def test_consumer_with_opt_start_time_timestamp(self): deliver_policy=api.DeliverPolicy.BY_START_TIME, ) assert isinstance(con.created, datetime.datetime) - assert con.config.opt_start_time == datetime.datetime(1970, 1, 1, 1, 1, 1, tzinfo=datetime.timezone.utc) + assert con.config.opt_start_time == datetime.datetime( + 1970, 1, 1, 1, 1, 1, tzinfo=datetime.timezone.utc + ) await nc.close() @async_test @@ -1426,12 +1430,21 @@ async def test_consumer_with_opt_start_time_microseconds(self): await jsm.add_stream(name="ctests", subjects=["a", "b", "c.>"]) con = await jsm.add_consumer( "ctests", - opt_start_time=datetime.datetime(1970, 1, 1, 1, 1, 1, microsecond=123456), + opt_start_time=datetime.datetime( + 1970, 1, 1, 1, 1, 1, microsecond=123456 + ), deliver_policy=api.DeliverPolicy.BY_START_TIME, ) assert isinstance(con.created, datetime.datetime) assert con.config.opt_start_time == datetime.datetime( - 1970, 1, 1, 1, 1, 1, microsecond=123456, tzinfo=datetime.timezone.utc + 1970, + 1, + 1, + 1, + 1, + 1, + microsecond=123456, + tzinfo=datetime.timezone.utc ) await nc.close() @@ -1443,11 +1456,15 @@ async def test_consumer_with_opt_start_time_date_tz(self): await jsm.add_stream(name="ctests", subjects=["a", "b", "c.>"]) con = await jsm.add_consumer( "ctests", - opt_start_time=datetime.datetime(1970, 1, 1, tzinfo=pytz.timezone("Europe/Paris")), + opt_start_time=datetime.datetime( + 1970, 1, 1, tzinfo=pytz.timezone("Europe/Paris") + ), deliver_policy=api.DeliverPolicy.BY_START_TIME, ) assert isinstance(con.created, datetime.datetime) - assert con.config.opt_start_time == datetime.datetime(1970, 1, 1, tzinfo=pytz.timezone("Europe/Paris")) + assert con.config.opt_start_time == datetime.datetime( + 1970, 1, 1, tzinfo=pytz.timezone("Europe/Paris") + ) await nc.close() @async_test @@ -1458,11 +1475,15 @@ async def test_consumer_with_opt_start_time_timestamp_tz(self): await jsm.add_stream(name="ctests", subjects=["a", "b", "c.>"]) con = await jsm.add_consumer( "ctests", - opt_start_time=datetime.datetime(1970, 1, 1, 1, 1, 1, tzinfo=pytz.timezone("Europe/Paris")), + opt_start_time=datetime.datetime( + 1970, 1, 1, 1, 1, 1, tzinfo=pytz.timezone("Europe/Paris") + ), deliver_policy=api.DeliverPolicy.BY_START_TIME, ) assert isinstance(con.created, datetime.datetime) - assert con.config.opt_start_time == datetime.datetime(1970, 1, 1, 1, 1, 1, tzinfo=pytz.timezone("Europe/Paris")) + assert con.config.opt_start_time == datetime.datetime( + 1970, 1, 1, 1, 1, 1, tzinfo=pytz.timezone("Europe/Paris") + ) await nc.close() @async_test @@ -1474,30 +1495,44 @@ async def test_consumer_with_opt_start_time_microseconds_tz(self): con = await jsm.add_consumer( "ctests", opt_start_time=datetime.datetime( - 1970, 1, 1, 1, 1, 1, microsecond=123456, tzinfo=pytz.timezone("Europe/Paris") + 1970, + 1, + 1, + 1, + 1, + 1, + microsecond=123456, + tzinfo=pytz.timezone("Europe/Paris") ), deliver_policy=api.DeliverPolicy.BY_START_TIME, ) assert isinstance(con.created, datetime.datetime) assert con.config.opt_start_time == datetime.datetime( - 1970, 1, 1, 1, 1, 1, microsecond=123456, tzinfo=pytz.timezone("Europe/Paris") + 1970, + 1, + 1, + 1, + 1, + 1, + microsecond=123456, + tzinfo=pytz.timezone("Europe/Paris") ) await nc.close() def test_parser_consumer_info_with_created_timestamp(self): for created in [ - "1970-01-01T01:02:03Z", - "1970-01-01T02:02:03+01:00", - "1970-01-01T01:02:03.0Z", - "1970-01-01T01:02:03.00Z", - "1970-01-01T01:02:03.000Z", - "1970-01-01T01:02:03.0000Z", - "1970-01-01T01:02:03.00000Z", - "1970-01-01T01:02:03.000000Z", - "1970-01-01T01:02:03.0000000Z", - "1970-01-01T01:02:03.00000000Z", - "1970-01-01T01:02:03.000000000Z", - "1970-01-01T02:02:03.000000000Z+01:00", + "1970-01-01T01:02:03Z", + "1970-01-01T02:02:03+01:00", + "1970-01-01T01:02:03.0Z", + "1970-01-01T01:02:03.00Z", + "1970-01-01T01:02:03.000Z", + "1970-01-01T01:02:03.0000Z", + "1970-01-01T01:02:03.00000Z", + "1970-01-01T01:02:03.000000Z", + "1970-01-01T01:02:03.0000000Z", + "1970-01-01T01:02:03.00000000Z", + "1970-01-01T01:02:03.000000000Z", + "1970-01-01T02:02:03.000000000Z+01:00", ]: info = api.ConsumerInfo.from_response({ "name": "test", @@ -1510,21 +1545,21 @@ def test_parser_consumer_info_with_created_timestamp(self): 1970, 1, 1, 1, 2, 3, tzinfo=datetime.timezone.utc ) for created in [ - "1970-01-01T01:02:03.4Z", - "1970-01-01T01:02:03.4+00:00", - "1970-01-01T01:02:03.40Z", - "1970-01-01T02:02:03.40+01:00", - "1970-01-01T01:02:03.400Z", - "1970-01-01T04:02:03.400+03:00", - "1970-01-01T01:02:03.4000Z", - "1970-01-01T07:22:03.4000+06:20", - "1970-01-01T01:02:03.40000Z", - "1970-01-01T00:02:03.400000-01:00", - "1970-01-01T01:02:03.400000Z", - "1970-01-01T01:02:03.4000000Z", - "1970-01-01T01:02:03.40000000Z", - "1970-01-01T01:02:03.400000000Z", - "1970-01-01T02:02:03.400000000Z+01:00", + "1970-01-01T01:02:03.4Z", + "1970-01-01T01:02:03.4+00:00", + "1970-01-01T01:02:03.40Z", + "1970-01-01T02:02:03.40+01:00", + "1970-01-01T01:02:03.400Z", + "1970-01-01T04:02:03.400+03:00", + "1970-01-01T01:02:03.4000Z", + "1970-01-01T07:22:03.4000+06:20", + "1970-01-01T01:02:03.40000Z", + "1970-01-01T00:02:03.400000-01:00", + "1970-01-01T01:02:03.400000Z", + "1970-01-01T01:02:03.4000000Z", + "1970-01-01T01:02:03.40000000Z", + "1970-01-01T01:02:03.400000000Z", + "1970-01-01T02:02:03.400000000Z+01:00", ]: info = api.ConsumerInfo.from_response({ "name": "test",