From 1213468c472aa7e899a1995bae3eb974135568a7 Mon Sep 17 00:00:00 2001 From: shashvat-singham Date: Mon, 17 Aug 2026 11:53:23 +0530 Subject: [PATCH 1/3] fix: treat a naive datetime as UTC in order_by Fixes #1342 A naive datetime string is parsed as UTC (datetime_utils.parse says so explicitly, matching qdrant core), but a naive datetime *object* went straight to timestamp(), which reads it as local time. The same wall clock therefore produced two different order values: to_order_value("2024-06-15 12:30:45") 1718454645000000 to_order_value(datetime(2024, 6, 15, 12, 30, 45)) 1718434845000000 The gap is the client machine's UTC offset, so local-mode ordering depended on where the client ran. Attach UTC to a naive datetime in to_order_value, so both spellings and the aware-UTC form agree. Aware datetimes keep their own offset. Done in to_order_value rather than datetime_to_microseconds to stay off #1310, which is rewriting that function for an unrelated precision fix. --- qdrant_client/local/order_by.py | 8 +++++++- tests/test_order_by_tz.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/test_order_by_tz.py diff --git a/qdrant_client/local/order_by.py b/qdrant_client/local/order_by.py index c695bae85..91982d54a 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,12 @@ def to_order_value(value: str | datetime | OrderValue | None) -> OrderValue | No return value if isinstance(value, datetime): + if value.tzinfo 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..42bdae9df --- /dev/null +++ b/tests/test_order_by_tz.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +from qdrant_client.local.order_by import to_order_value + + +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 From 44787758cac9648deb06f9f8c7a8d053d92fe90d Mon Sep 17 00:00:00 2001 From: shashvat-singham Date: Mon, 17 Aug 2026 13:37:18 +0530 Subject: [PATCH 2/3] Treat a tzinfo with no utcoffset as naive A datetime is aware only when tzinfo is set and utcoffset() returns an offset. Checking tzinfo alone let a tzinfo whose utcoffset() returns None reach timestamp(), which raised TypeError rather than ordering the value. Check both, per the CodeRabbit review. --- qdrant_client/local/order_by.py | 6 +++++- tests/test_order_by_tz.py | 24 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/qdrant_client/local/order_by.py b/qdrant_client/local/order_by.py index 91982d54a..199f3fa8c 100644 --- a/qdrant_client/local/order_by.py +++ b/qdrant_client/local/order_by.py @@ -19,7 +19,11 @@ def to_order_value(value: str | datetime | OrderValue | None) -> OrderValue | No return value if isinstance(value, datetime): - if value.tzinfo is None: + # 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 diff --git a/tests/test_order_by_tz.py b/tests/test_order_by_tz.py index 42bdae9df..300a0157e 100644 --- a/tests/test_order_by_tz.py +++ b/tests/test_order_by_tz.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from qdrant_client.local.order_by import to_order_value @@ -28,3 +28,25 @@ def test_non_datetime_values_are_unchanged() -> 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) + ) From 99020d18c415c16276ee6c5442d142c6ec11fcc1 Mon Sep 17 00:00:00 2001 From: shashvat-singham Date: Mon, 17 Aug 2026 15:25:49 +0530 Subject: [PATCH 3/3] Make the naive-datetime regression independent of the host timezone On a UTC host, timestamp() on a naive datetime agrees with the UTC interpretation, so the value-only assertion passed even without the normalization -- the regression was only failing because the author's machine is UTC+5:30. Spy on what reaches datetime_to_microseconds and assert the tzinfo instead, which fails on any host timezone. Raised by the CodeRabbit review. --- tests/test_order_by_tz.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_order_by_tz.py b/tests/test_order_by_tz.py index 300a0157e..c82db217f 100644 --- a/tests/test_order_by_tz.py +++ b/tests/test_order_by_tz.py @@ -3,6 +3,30 @@ 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.