diff --git a/qdrant_client/local/order_by.py b/qdrant_client/local/order_by.py index c695bae85..199f3fa8c 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 @@ -19,6 +19,16 @@ def to_order_value(value: str | datetime | OrderValue | None) -> OrderValue | No return value if isinstance(value, datetime): + # A datetime counts as aware only when tzinfo is set *and* utcoffset() returns + # an offset, so both are checked here: a tzinfo whose utcoffset() is None is + # naive by Python's own definition and would otherwise reach timestamp() and + # raise. + if value.tzinfo is None or value.utcoffset() is None: + # A naive datetime means UTC — the same assumption `parse()` makes for a + # datetime string with no offset, and the one qdrant core makes. Without + # this, `timestamp()` reads it as local time, so the same wall clock + # sorts differently depending on the client machine's timezone. + value = value.replace(tzinfo=timezone.utc) return datetime_to_microseconds(value) if isinstance(value, str): diff --git a/tests/test_order_by_tz.py b/tests/test_order_by_tz.py new file mode 100644 index 000000000..c82db217f --- /dev/null +++ b/tests/test_order_by_tz.py @@ -0,0 +1,76 @@ +from datetime import datetime, timedelta, timezone, tzinfo + +from qdrant_client.local.order_by import to_order_value + + +def test_naive_datetime_is_normalized_to_utc(monkeypatch) -> None: + """Assert the normalization itself, not the resulting number. + + On a UTC host `timestamp()` happens to agree with the UTC interpretation, so a + value-only assertion passes even without the fix. Spying on what reaches + `datetime_to_microseconds` makes this fail on any host timezone. + """ + import qdrant_client.local.order_by as order_by_module + + seen: dict[str, object] = {} + real = order_by_module.datetime_to_microseconds + + def spy(dt: datetime) -> int: + seen["tzinfo"] = dt.tzinfo + seen["utcoffset"] = dt.utcoffset() + return real(dt) + + monkeypatch.setattr(order_by_module, "datetime_to_microseconds", spy) + order_by_module.to_order_value(datetime(2024, 6, 15, 12, 30, 45)) + + assert seen["tzinfo"] is timezone.utc + assert seen["utcoffset"] == timedelta(0) + + +def test_naive_datetime_is_utc_like_a_naive_string() -> None: + """A naive datetime means UTC, matching how a naive datetime *string* is parsed. + + Reading it as local time made the order value depend on the client machine's + timezone, so the same wall clock sorted differently on different machines. + """ + from_string = to_order_value("2024-06-15 12:30:45") + from_naive = to_order_value(datetime(2024, 6, 15, 12, 30, 45)) + from_aware = to_order_value(datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc)) + + assert from_string == from_naive == from_aware + + +def test_aware_datetime_offset_is_respected() -> None: + ist = timezone(timedelta(hours=5, minutes=30)) + assert to_order_value(datetime(2024, 6, 15, 18, 0, 45, tzinfo=ist)) == to_order_value( + datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc) + ) + + +def test_non_datetime_values_are_unchanged() -> None: + assert to_order_value(None) is None + assert to_order_value(42) == 42 + assert to_order_value(1.5) == 1.5 + assert to_order_value("not a date") is None + + +def test_tzinfo_with_none_utcoffset_is_treated_as_naive() -> None: + """A tzinfo whose utcoffset() returns None is naive by Python's definition. + + Checking only `tzinfo is None` let such a value through to timestamp(), which + raised TypeError instead of ordering it. + """ + + class NoOffset(tzinfo): + def utcoffset(self, dt): + return None + + def dst(self, dt): + return None + + value = datetime(2024, 6, 15, 12, 30, 45, tzinfo=NoOffset()) + assert value.tzinfo is not None and value.utcoffset() is None # naive per datetime docs + + assert to_order_value(value) == to_order_value( + datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc) + )