mirror of
https://github.com/qdrant/qdrant-client.git
synced 2026-09-21 13:37:55 -05:00
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:
committed by
George Panchuk
co-authored by
George Panchuk
parent
52153fc338
commit
3e682a0896
@@ -2,6 +2,11 @@ from datetime import datetime, timezone
|
||||
|
||||
# These are the formats accepted by qdrant core
|
||||
available_formats = [
|
||||
"%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",
|
||||
# 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",
|
||||
@@ -10,16 +15,47 @@ available_formats = [
|
||||
"%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"
|
||||
#
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user