From c03471060f62063ab1e634002d5eb81638828470 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 16 Jun 2026 01:53:05 +0200 Subject: [PATCH 1/3] Return NotImplemented from comparisons with non-Ksuid objects Ksuid.__eq__ asserted isinstance(other, self.__class__), so comparing a Ksuid with any other type raised AssertionError instead of returning False. This broke ordinary operations such as 'ksuid == None', 'ksuid in some_list', and comparing a Ksuid with a KsuidMs, and the assert is stripped under 'python -O' (turning the error into an AttributeError). __lt__ had the same problem. Follow the data model and return NotImplemented when the other operand is not a Ksuid, so equality falls back to False and ordering raises a proper TypeError. --- ksuid/ksuid.py | 7 +++++-- tests/test_ksuid.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/ksuid/ksuid.py b/ksuid/ksuid.py index dcf4b37..7dfaca8 100644 --- a/ksuid/ksuid.py +++ b/ksuid/ksuid.py @@ -81,10 +81,13 @@ def __bytes__(self) -> bytes: return self._uid def __eq__(self, other: object) -> bool: - assert isinstance(other, self.__class__) + if not isinstance(other, Ksuid): + return NotImplemented return self._uid == other._uid - def __lt__(self: SelfT, other: SelfT) -> bool: + def __lt__(self, other: object) -> bool: + if not isinstance(other, Ksuid): + return NotImplemented return self._uid < other._uid def __hash__(self) -> int: diff --git a/tests/test_ksuid.py b/tests/test_ksuid.py index 08678b3..32c5b85 100644 --- a/tests/test_ksuid.py +++ b/tests/test_ksuid.py @@ -73,6 +73,33 @@ def test_to_from_base62(): assert ksuid == ksuid_from_base62 +def test_eq_with_non_ksuid_does_not_raise(): + ksuid = Ksuid.from_bytes(bytes(Ksuid.BYTES_LENGTH)) + + # Comparing against a foreign type must not raise; equality is False + assert (ksuid == None) is False + assert (ksuid != None) is True + assert (ksuid == "not a ksuid") is False + assert (ksuid == 42) is False + assert None not in [ksuid] + + +def test_eq_by_raw_bytes(): + raw = bytes(range(Ksuid.BYTES_LENGTH)) + assert Ksuid.from_bytes(raw) == Ksuid.from_bytes(raw) + assert Ksuid.from_bytes(raw) != Ksuid.from_bytes(bytes(Ksuid.BYTES_LENGTH)) + # equal values must hash equally + assert hash(Ksuid.from_bytes(raw)) == hash(Ksuid.from_bytes(raw)) + + +def test_ordering_with_non_ksuid_raises_type_error(): + ksuid = Ksuid.from_bytes(bytes(Ksuid.BYTES_LENGTH)) + with pytest.raises(TypeError): + _ = ksuid < None + with pytest.raises(TypeError): + _ = ksuid < "not a ksuid" + + def test_to_from_bytes(): # Arrange ksuid = Ksuid() From f0a1ff32bc107e1aecde70ead4866688f3b32ee3 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Wed, 17 Jun 2026 09:43:38 +0200 Subject: [PATCH 2/3] Make Ksuid and KsuidMs compare unequal across types A Ksuid and a KsuidMs are both 20 bytes and can share a byte representation, but they decode their timestamps differently. Guard __eq__ on TIMESTAMP_LENGTH_IN_BYTES so the two subclasses never compare equal just because their raw bytes coincide, and add a test that constructs both from the same bytes and asserts inequality. --- ksuid/ksuid.py | 5 ++++- tests/test_ksuid.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ksuid/ksuid.py b/ksuid/ksuid.py index 7dfaca8..7964254 100644 --- a/ksuid/ksuid.py +++ b/ksuid/ksuid.py @@ -81,7 +81,10 @@ def __bytes__(self) -> bytes: return self._uid def __eq__(self, other: object) -> bool: - if not isinstance(other, Ksuid): + if ( + not isinstance(other, Ksuid) + or self.TIMESTAMP_LENGTH_IN_BYTES != other.TIMESTAMP_LENGTH_IN_BYTES + ): return NotImplemented return self._uid == other._uid diff --git a/tests/test_ksuid.py b/tests/test_ksuid.py index 32c5b85..827a99c 100644 --- a/tests/test_ksuid.py +++ b/tests/test_ksuid.py @@ -92,6 +92,24 @@ def test_eq_by_raw_bytes(): assert hash(Ksuid.from_bytes(raw)) == hash(Ksuid.from_bytes(raw)) +def test_eq_across_ksuid_subclasses_is_false(): + # Ksuid and KsuidMs are both 20 bytes, so the same raw bytes can be parsed + # as either, but they split those bytes into different timestamp/payload + # layouts (4- vs 5-byte timestamp). A Ksuid must never compare equal to a + # KsuidMs that merely shares its byte representation. + assert Ksuid.BYTES_LENGTH == KsuidMs.BYTES_LENGTH + raw = bytes(range(Ksuid.BYTES_LENGTH)) + ksuid = Ksuid.from_bytes(raw) + ksuid_ms = KsuidMs.from_bytes(raw) + assert bytes(ksuid) == bytes(ksuid_ms) # identical raw bytes... + assert ksuid != ksuid_ms # ...but not equal + assert ksuid_ms != ksuid + assert not (ksuid == ksuid_ms) + # same-class equality is unaffected + assert ksuid == Ksuid.from_bytes(raw) + assert ksuid_ms == KsuidMs.from_bytes(raw) + + def test_ordering_with_non_ksuid_raises_type_error(): ksuid = Ksuid.from_bytes(bytes(Ksuid.BYTES_LENGTH)) with pytest.raises(TypeError): From 7f8f53e99032d7217910e9542d1a0b4538d27f03 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 17 Jun 2026 22:38:47 +0200 Subject: [PATCH 3/3] style: ruff format and fix import/lint issues --- ksuid/ksuid.py | 5 +---- tests/test_ksuid.py | 13 ++++++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ksuid/ksuid.py b/ksuid/ksuid.py index 7964254..c1b96b9 100644 --- a/ksuid/ksuid.py +++ b/ksuid/ksuid.py @@ -81,10 +81,7 @@ def __bytes__(self) -> bytes: return self._uid def __eq__(self, other: object) -> bool: - if ( - not isinstance(other, Ksuid) - or self.TIMESTAMP_LENGTH_IN_BYTES != other.TIMESTAMP_LENGTH_IN_BYTES - ): + if not isinstance(other, Ksuid) or self.TIMESTAMP_LENGTH_IN_BYTES != other.TIMESTAMP_LENGTH_IN_BYTES: return NotImplemented return self._uid == other._uid diff --git a/tests/test_ksuid.py b/tests/test_ksuid.py index 827a99c..b1488e3 100644 --- a/tests/test_ksuid.py +++ b/tests/test_ksuid.py @@ -1,7 +1,6 @@ import json -import os import math -import typing as t +import os from datetime import datetime, timedelta, timezone import pytest @@ -77,8 +76,8 @@ def test_eq_with_non_ksuid_does_not_raise(): ksuid = Ksuid.from_bytes(bytes(Ksuid.BYTES_LENGTH)) # Comparing against a foreign type must not raise; equality is False - assert (ksuid == None) is False - assert (ksuid != None) is True + assert (ksuid == None) is False # noqa: E711 + assert (ksuid != None) is True # noqa: E711 assert (ksuid == "not a ksuid") is False assert (ksuid == 42) is False assert None not in [ksuid] @@ -167,7 +166,7 @@ def test_payload_uniqueness(): # Arrange now = datetime.now() timestamp = now.replace(microsecond=0).timestamp() - ksuids_set: t.Set[Ksuid] = set() + ksuids_set: set[Ksuid] = set() for i in range(TEST_ITEMS_COUNT): ksuids_set.add(Ksuid(datetime=now)) @@ -180,7 +179,7 @@ def test_payload_uniqueness(): def test_timestamp_uniqueness(): # Arrange time = datetime.now() - ksuids_set: t.Set[Ksuid] = set() + ksuids_set: set[Ksuid] = set() for i in range(TEST_ITEMS_COUNT): ksuids_set.add(Ksuid(datetime=time, payload=EMPTY_KSUID_PAYLOAD)) time += timedelta(seconds=1) @@ -228,7 +227,7 @@ def test_ms_mode_datetime(): def pytest_generate_tests(metafunc): if "test_data" in metafunc.fixturenames: data = [] - with open(TF_PATH, "r") as test_kuids: + with open(TF_PATH) as test_kuids: for ksuid_json in test_kuids: data.append(json.loads(ksuid_json)) metafunc.parametrize("test_data", data)