Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion qdrant_client/local/order_by.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down
76 changes: 76 additions & 0 deletions tests/test_order_by_tz.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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)
)