diff --git a/qdrant_client/local/order_by.py b/qdrant_client/local/order_by.py index c695bae85..5b7272cfb 100644 --- a/qdrant_client/local/order_by.py +++ b/qdrant_client/local/order_by.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from qdrant_client.http.models import OrderValue from qdrant_client.local.datetime_utils import parse @@ -7,6 +7,12 @@ def datetime_to_microseconds(dt: datetime) -> int: + if dt.tzinfo is None: + # Assume UTC if no timezone is provided, matching `datetime_utils.parse` + # and qdrant core. `datetime.timestamp()` would otherwise read a naive + # datetime as local time, making order values depend on where the + # client runs. + dt = dt.replace(tzinfo=timezone.utc) return int(dt.timestamp() * MICROS_PER_SECOND) diff --git a/tests/test_order_by.py b/tests/test_order_by.py new file mode 100644 index 000000000..6bb7f7682 --- /dev/null +++ b/tests/test_order_by.py @@ -0,0 +1,58 @@ +import os +import time +from datetime import datetime, timedelta, timezone + +import pytest + +from qdrant_client.local.order_by import to_order_value + +# 2024-06-15 12:30:45 UTC +WALL_CLOCK = (2024, 6, 15, 12, 30, 45) +EXPECTED_MICROS = 1718454645000000 + + +def test_naive_datetime_object_is_interpreted_as_utc() -> None: + """A datetime with no tzinfo means UTC, matching how qdrant core reads a + datetime string with no offset.""" + assert to_order_value(datetime(*WALL_CLOCK)) == EXPECTED_MICROS + + +def test_naive_object_string_and_aware_utc_agree() -> None: + """The same wall-clock time must order identically however it is spelled.""" + naive_object = to_order_value(datetime(*WALL_CLOCK)) + naive_string = to_order_value("2024-06-15 12:30:45") + aware_utc = to_order_value(datetime(*WALL_CLOCK, tzinfo=timezone.utc)) + + assert naive_object == naive_string == aware_utc + + +def test_aware_non_utc_datetime_is_converted_not_stripped() -> None: + """An explicit offset is still honoured -- naive-means-UTC must not turn + into treating every datetime as UTC.""" + aware = datetime(*WALL_CLOCK, tzinfo=timezone(timedelta(hours=5, minutes=30))) + + assert to_order_value(aware) == EXPECTED_MICROS - int(5.5 * 3600 * 1_000_000) + + +@pytest.mark.skipif( + not hasattr(time, "tzset"), + reason="process timezone is not settable on this platform", +) +def test_order_value_does_not_depend_on_the_client_timezone() -> None: + """Without this, the naive-object path went through datetime.timestamp(), + which reads the machine's local time, so the same call produced different + order values on differently-configured clients.""" + original = os.environ.get("TZ") + try: + values = [] + for zone in ("UTC", "Asia/Kolkata", "America/Los_Angeles"): + os.environ["TZ"] = zone + time.tzset() + values.append(to_order_value(datetime(*WALL_CLOCK))) + assert values == [EXPECTED_MICROS] * 3 + finally: + if original is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original + time.tzset()