mirror of
https://github.com/qdrant/qdrant-client.git
synced 2026-09-29 09:28:05 -05:00
* 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>
97 lines
3.5 KiB
Python
97 lines
3.5 KiB
Python
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",
|
|
"%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/81d27d9baf13ea43b8c9398b914d36ee160cf60e/lib/segment/src/types.rs#L114-L149)
|
|
|
|
Returns:
|
|
Optional[datetime]: the datetime if the string is valid, otherwise None
|
|
"""
|
|
|
|
def parse_available_formats(datetime_str: str) -> datetime | None:
|
|
for fmt in available_formats:
|
|
try:
|
|
dt = datetime.strptime(datetime_str, fmt)
|
|
if dt.tzinfo is None:
|
|
# Assume UTC if no timezone is provided
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
date_str = normalize(date_str)
|
|
|
|
parsed_dt = parse_available_formats(date_str)
|
|
if parsed_dt is not None:
|
|
return parsed_dt
|
|
|
|
# Python can't parse timezones containing only hours (+HH), but it can parse timezones with hours and minutes
|
|
# So we add :00 to the assumed timezone and try parsing it again
|
|
# 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
|