fix(datetime): only complete an hour-only UTC offset in the parse fallback (#1350)

* fix(datetime): only complete an hour-only UTC offset in the parse fallback

parse() appended ":00" to any string the format list rejected. The retry
exists to turn a trailing "+01" into "+01:00", but unguarded it also
completed truncated datetimes: "2024-06-15 12" became "2024-06-15 12:00"
and "2024-06-15T12:30" became "2024-06-15T12:30:00", both of which then
matched. Local mode therefore accepted datetimes qdrant core rejects.

Guard the retry on the string actually ending in an hour-only offset.

Fixes: #1349

* fix: remove regex, move tests

* fix: fix datetime parsing to be closer to the server

---------

Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
This commit is contained in:
Aryan Pardeshi
2026-09-16 00:27:31 +07:00
committed by George Panchuk
co-authored by George Panchuk
parent 52153fc338
commit 3e682a0896
2 changed files with 91 additions and 2 deletions
+49 -2
View File
@@ -6,20 +6,56 @@ available_formats = [
"%Y-%m-%d %H:%M:%S.%f%z",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%d %H:%M:%S%z",
# core reads back its own Display output, which puts a space before the offset
"%Y-%m-%dT%H:%M:%S.%f %z",
"%Y-%m-%d %H:%M:%S.%f %z",
"%Y-%m-%dT%H:%M:%S %z",
"%Y-%m-%d %H:%M:%S %z",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
]
def normalize(date_str: str) -> str:
"""Rewrites the leniencies of core's parser that strptime does not share.
Core skips whitespace ahead of the date, takes a lowercase "z", spells UTC out the way
chrono's Display does, and keeps up to nanoseconds where "%f" stops at microseconds.
"""
date_str = date_str.lstrip()
if date_str.endswith(" UTC"):
date_str = date_str[: -len(" UTC")] + "+00:00"
elif date_str.endswith("z"):
date_str = date_str[:-1] + "Z"
# microseconds are as fine as datetime gets, so a fraction longer than 6 digits is cut
# short rather than rejected. The value is then core's, rounded down by less than a
# microsecond. Anything after the fraction, such as an offset, is kept.
# dt examples to handle:
# "2021-01-01T00:00:00.123456789" -> "2021-01-01T00:00:00.123456"
# "2021-01-01 00:00:00.1234567+01:00" -> "2021-01-01 00:00:00.123456+01:00"
dot = date_str.find(".")
if dot != -1:
end = dot + 1
while end < len(date_str) and date_str[end].isdigit():
end += 1
if end - dot > 7: # the dot plus more than 6 digits
date_str = date_str[: dot + 7] + date_str[end:]
return date_str
def parse(date_str: str) -> datetime | None:
"""Parses one section of the date string at a time.
Args:
date_str (str): Accepts any of the formats in qdrant core (see https://github.com/qdrant/qdrant/blob/0ed86ce0575d35930268db19e1f7680287072c58/lib/segment/src/types.rs#L1388-L1410)
date_str (str): Accepts any of the formats in qdrant core (see https://github.com/qdrant/qdrant/blob/81d27d9baf13ea43b8c9398b914d36ee160cf60e/lib/segment/src/types.rs#L114-L149)
Returns:
Optional[datetime]: the datetime if the string is valid, otherwise None
@@ -37,6 +73,8 @@ def parse(date_str: str) -> datetime | None:
pass
return None
date_str = normalize(date_str)
parsed_dt = parse_available_formats(date_str)
if parsed_dt is not None:
return parsed_dt
@@ -46,4 +84,13 @@ def parse(date_str: str) -> datetime | None:
# dt examples to handle:
# "2021-01-01 00:00:00.000+01"
# "2021-01-01 00:00:00.000-10"
return parse_available_formats(date_str + ":00")
#
# Only strings ending in an hour-only offset get the retry. Appending ":00"
# unconditionally also completed truncated datetimes, e.g. "2024-06-15 12"
# became "2024-06-15 12:00" and "2024-06-15T12:30" became
# "2024-06-15T12:30:00", so local mode accepted values qdrant core rejects.
offset = date_str[-3:]
if len(offset) == 3 and offset[0] in "+-" and offset[1:].isdigit():
return parse_available_formats(date_str + ":00")
return None
@@ -47,6 +47,34 @@ from qdrant_client.local.datetime_utils import parse
"2021-01-01 00:00:00.000-10",
datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=-10))),
),
("2021-01-01T00:00", datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)),
# core reads these too: a lowercase "z", whitespace ahead of the date, and the two
# shapes chrono's Display writes
("2021-01-01T00:00:00z", datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)),
(" 2021-01-01T00:00:00", datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)),
("2021-01-01 00:00:00 UTC", datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)),
(
"2021-01-01 00:00:00.123 +01:00",
datetime(2021, 1, 1, 0, 0, 0, 123000, tzinfo=timezone(timedelta(hours=1))),
),
(
"2021-01-01 00:00:00 +0530",
datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))),
),
# core keeps nanoseconds, datetime stops at microseconds, so a longer fraction is
# cut short rather than rejected
(
"2021-01-01T00:00:00.123456789",
datetime(2021, 1, 1, 0, 0, 0, 123456, tzinfo=timezone.utc),
),
(
"2021-01-01 00:00:00.1234567+01:00",
datetime(2021, 1, 1, 0, 0, 0, 123456, tzinfo=timezone(timedelta(hours=1))),
),
(
"2021-01-01T00:00:00+05",
datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=5))),
),
(
"2021-01-01 00:00:00-03:00",
datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=-3))),
@@ -55,3 +83,17 @@ from qdrant_client.local.datetime_utils import parse
)
def test_parse_dates(date_str: str, expected: datetime):
assert parse(date_str) == expected
@pytest.mark.parametrize( # type: ignore
"date_str",
[
# an hour on its own is not an accepted format in core, but the
# hour-only offset fallback used to complete it into one
"2021-01-01 00",
"not a date",
"",
],
)
def test_parse_unsupported_dates(date_str: str):
assert parse(date_str) is None