Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions arrow/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,12 +662,21 @@ def _parse_token(

# floating-point (IEEE-754) defaults to half-to-even rounding
seventh_digit = int(value[6])
if seventh_digit == 5:
rounding = int(value[5]) % 2
elif seventh_digit > 5:
if seventh_digit > 5:
# Strictly above the half-way point, always round up.
rounding = 1
else:
elif seventh_digit < 5:
# Strictly below the half-way point, always round down.
rounding = 0
else:
# Exactly at the half-way point: only use round-half-to-even
# (banker's rounding) when the trailing digits are all zero.
# If anything non-zero follows the 5, the truncated value is
# strictly above the midpoint and we must round up.
if any(ch != "0" for ch in value[7:]):
rounding = 1
else:
rounding = int(value[5]) % 2

parts["microsecond"] = int(value[:6]) + rounding

Expand Down Expand Up @@ -715,8 +724,13 @@ def _build_datetime(parts: _Parts) -> datetime:

date_string = f"{year}-{week}-{_day}"

# tokens for ISO 8601 weekdates
dt = datetime.strptime(date_string, "%G-%V-%u")
# tokens for ISO 8601 weekdates
try:
dt = datetime.strptime(date_string, "%G-%V-%u")
except ValueError as e:
raise ParserError(
f"Invalid ISO week date: {year}-W{week:02d}-{_day} ({e})"
)

parts["year"] = dt.year
parts["month"] = dt.month
Expand Down
9 changes: 7 additions & 2 deletions arrow/util.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Helpful functions used internally within arrow."""

import datetime
import math
from typing import Any, Optional

from dateutil.rrule import WEEKLY, rrule
Expand Down Expand Up @@ -49,10 +50,14 @@ def is_timestamp(value: Any) -> bool:
if not isinstance(value, (int, float, str)):
return False
try:
float(value)
return True
parsed = float(value)
except ValueError:
return False
# NaN and ±inf are float-parseable but not valid timestamps: passing
# them to ``datetime.fromtimestamp`` raises ``OverflowError`` for -inf
# and a generic ``ValueError`` for the others, neither of which names
# the actual problem ("this is not a timestamp").
return math.isfinite(parsed)


def validate_ordinal(value: Any) -> None:
Expand Down
59 changes: 56 additions & 3 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,16 +393,29 @@ def test_parse_subsecond_rounding(self):
assert self.parser.parse(string, datetime_format) == self.expected
assert self.parser.parse_iso(string) == self.expected

# round half-up
# round up (non-zero trailing past 5)
string = "2013-01-01 12:30:45.987653521"
assert self.parser.parse(string, datetime_format) == self.expected
assert self.parser.parse_iso(string) == self.expected

# round half-down
# round up (6th=4, non-zero trailing past 5) - 6th=4, 7th=5, trailing "210" non-zero,
# so the value is strictly above the half-way point and must round up
string = "2013-01-01 12:30:45.9876545210"
expected_round_up = datetime(2013, 1, 1, 12, 30, 45, 987655)
assert self.parser.parse(string, datetime_format) == expected_round_up
assert self.parser.parse_iso(string) == expected_round_up

# round half-to-even (6th=3, exact .5) - 6th is odd, so round up to even
string = "2013-01-01 12:30:45.9876535"
assert self.parser.parse(string, datetime_format) == self.expected
assert self.parser.parse_iso(string) == self.expected

# round half-to-even (6th=4, exact .5) - 6th is even, so round down to even
string = "2013-01-01 12:30:45.9876545"
expected_half_to_even = datetime(2013, 1, 1, 12, 30, 45, 987654)
assert self.parser.parse(string, datetime_format) == expected_half_to_even
assert self.parser.parse_iso(string) == expected_half_to_even

# overflow (zero out the subseconds and increment the seconds)
# regression tests for issue #636
def test_parse_subsecond_rounding_overflow(self):
Expand Down Expand Up @@ -746,12 +759,34 @@ def test_parse_W(self):
"thstrdjtrsrd676776r65",
"2002-W66-1T14:17:01",
"2002-W23-03T14:17:01",
"2023-W53-1",
"2024-W53-1",
]

for fmt in bad_formats:
with pytest.raises(ParserError):
self.parser.parse(fmt, "W")

def test_parse_weekdate_nonexistent_week_raises_parser_error(self):
# ISO 8601 only has a week 53 in years where Jan 1 is a Thursday
# or where the year is a leap year that starts on Wednesday.
# 2023 and 2024 do not qualify, so W53-1 is invalid; the parser
# should raise a ParserError (wrapping the underlying strptime
# ValueError) so callers can catch a single typed exception.
for bad in ("2023-W53-1", "2024-W53-1"):
with pytest.raises(ParserError):
self.parser.parse(bad, "W")
with pytest.raises(ParserError):
self.parser.parse_iso(bad)

# 2020 and 2026 *do* have a week 53, so the same shape is valid.
for good, expected in (
("2020-W53-1", datetime(2020, 12, 28)),
("2026-W53-1", datetime(2026, 12, 28)),
):
assert self.parser.parse(good, "W") == expected
assert self.parser.parse_iso(good) == expected

def test_parse_normalize_whitespace(self):
assert self.parser.parse(
"Jun 1 2005 1:33PM", "MMM D YYYY H:mmA", normalize_whitespace=True
Expand Down Expand Up @@ -1058,6 +1093,22 @@ def test_YYYY_MM_DDTHH_mm_ss_S(self):
2013, 2, 3, 4, 5, 6, 789124
)

def test_YYYY_MM_DDTHH_mm_ss_S_round_half_up(self):
# When the 7th digit is 5, the truncated value sits at the half-way
# point of the 6th digit. The value is strictly greater than the
# midpoint iff at least one trailing digit is non-zero, in which
# case we must round up rather than apply banker's rounding to
# the 6th digit.
# 6th digit even, 7th == 5, nothing trailing -> banker's rounds down
assert self.parser.parse_iso("2013-02-03T04:05:06.789124500") == datetime(
2013, 2, 3, 4, 5, 6, 789124
)
# 6th digit even, 7th == 5, non-zero digit after -> strictly above
# midpoint, must round up (was rounding down before the fix)
assert self.parser.parse_iso("2013-02-03T04:05:06.789124501") == datetime(
2013, 2, 3, 4, 5, 6, 789125
)

def test_YYYY_MM_DDTHH_mm_ss_SZ(self):
assert self.parser.parse_iso("2013-02-03T04:05:06.7+01:00") == datetime(
2013, 2, 3, 4, 5, 6, 700000, tzinfo=tz.tzoffset(None, 3600)
Expand Down Expand Up @@ -1144,8 +1195,10 @@ def test_parse_subsecond(self):
def test_gnu_date(self):
"""Regression tests for parsing output from GNU date."""
# date -Ins
# 7th digit is 5, trailing "57" is non-zero, so the value is strictly
# above the half-way point and must round up from 895636 to 895637
assert self.parser.parse_iso("2016-11-16T09:46:30,895636557-0800") == datetime(
2016, 11, 16, 9, 46, 30, 895636, tzinfo=tz.tzoffset(None, -3600 * 8)
2016, 11, 16, 9, 46, 30, 895637, tzinfo=tz.tzoffset(None, -3600 * 8)
)

# date --rfc-3339=ns
Expand Down
11 changes: 11 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ class InvalidTimestamp:
full_datetime = "2019-06-23T13:12:42"
assert not util.is_timestamp(full_datetime)

# NaN and ±inf are float-parseable but not real timestamps: passing
# them to ``datetime.fromtimestamp`` would raise ``OverflowError`` for
# -inf and a generic ``ValueError`` for the others, neither of which
# names the actual problem.
assert not util.is_timestamp(float("nan"))
assert not util.is_timestamp(float("inf"))
assert not util.is_timestamp(float("-inf"))
assert not util.is_timestamp("nan")
assert not util.is_timestamp("inf")
assert not util.is_timestamp("-inf")

def test_validate_ordinal(self):
timestamp_float = 1607066816.815537
timestamp_int = int(timestamp_float)
Expand Down
Loading