Skip to content

Commit 6f89fa8

Browse files
committed
fix: allow TypeID to work with other UUID versions.
1 parent 79e3bec commit 6f89fa8

5 files changed

Lines changed: 112 additions & 3 deletions

File tree

tests/explain/test_engine.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,11 @@ def test_to_dict_is_json_serializable():
177177

178178
payload = exp.to_dict()
179179
json.dumps(payload) # should not raise
180+
181+
182+
def test_explain_nil_uuid_not_sortable_no_created_at():
183+
exp = explain("x_00000000000000000000000000", enable_schema=False)
184+
assert exp.valid is True
185+
assert exp.parsed.uuid == "00000000-0000-0000-0000-000000000000"
186+
assert exp.parsed.created_at is None
187+
assert exp.parsed.sortable is False

tests/test_spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ def test_valid_spec(valid_spec: list) -> None:
1818

1919
typeid = TypeID.from_uuid(prefix=prefix, suffix=uuid)
2020
assert str(typeid) == spec["typeid"]
21+
assert typeid.uuid == uuid

tests/test_typeid.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime, timezone
2+
from uuid import UUID
13
import pytest
24
import uuid6
35

@@ -142,3 +144,61 @@ def test_uuid_property() -> None:
142144
assert isinstance(typeid.uuid, uuid6.UUID)
143145
assert typeid.uuid.version == uuid.version == 7
144146
assert typeid.uuid.time == uuid.time
147+
148+
149+
def test_created_at_none_for_nil_uuid_suffix():
150+
tid = TypeID(prefix="x", suffix="00000000000000000000000000")
151+
assert tid.created_at is None
152+
153+
154+
def test_created_at_none_for_non_v7_uuid_v4():
155+
# UUIDv4 (random) must not claim created_at
156+
u = UUID("550e8400-e29b-41d4-a716-446655440000") # version 4
157+
tid = TypeID.from_uuid(u, prefix="x")
158+
assert tid.created_at is None
159+
160+
161+
def test_created_at_is_utc_for_uuid7_generated_typeid():
162+
# Default TypeID generation should be UUIDv7; then created_at must be present and UTC
163+
tid = TypeID(prefix="x")
164+
dt = tid.created_at
165+
assert dt is not None
166+
_assert_utc_datetime(dt)
167+
168+
169+
def test_created_at_monotonic_increasing_for_multiple_new_ids():
170+
# UUIDv7 embeds time; created_at should be non-decreasing across consecutive generations.
171+
# Note: UUIDv7 can generate multiple IDs within the same millisecond, so equality is allowed.
172+
t1 = TypeID(prefix="x").created_at
173+
t2 = TypeID(prefix="x").created_at
174+
t3 = TypeID(prefix="x").created_at
175+
176+
assert t1 is not None and t2 is not None and t3 is not None
177+
assert t1 <= t2 <= t3
178+
179+
180+
def test_created_at_does_not_crash_if_uuid_object_is_unexpected(monkeypatch):
181+
# If TypeID.uuid returns something odd that breaks version/int access,
182+
# created_at should return None (safe behavior).
183+
class WeirdUUID:
184+
@property
185+
def version(self):
186+
raise RuntimeError("nope")
187+
188+
@property
189+
def int(self):
190+
raise RuntimeError("nope")
191+
192+
tid = TypeID(prefix="x", suffix="00000000000000000000000000")
193+
194+
# monkeypatch instance attribute/property access
195+
monkeypatch.setattr(type(tid), "uuid", property(lambda self: WeirdUUID()))
196+
197+
assert tid.created_at is None
198+
199+
200+
def _assert_utc_datetime(dt: datetime) -> None:
201+
assert isinstance(dt, datetime)
202+
assert dt.tzinfo is timezone.utc
203+
# must be timezone-aware and normalized to UTC
204+
assert dt.utcoffset() == timezone.utc.utcoffset(dt)

typeid/explain/engine.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,11 @@ def _parse_typeid(id_str: str) -> ParsedTypeID:
130130
# Derived facts from the validated TypeID
131131
uuid_obj = tid.uuid # library returns a UUID object (uuid6.UUID)
132132
uuid_str = str(uuid_obj)
133+
134+
ver = _uuid_version(uuid_obj)
133135

134-
created_at = _uuid7_created_at(uuid_obj)
135-
sortable = True # UUIDv7 is time-ordered by design
136+
created_at = _uuid7_created_at(uuid_obj) if ver == 7 else None
137+
sortable = True if ver == 7 else False
136138

137139
return ParsedTypeID(
138140
raw=id_str,
@@ -245,3 +247,11 @@ def _apply_derived_provenance(exp: Explanation) -> None:
245247
exp.provenance.setdefault("created_at", Provenance.DERIVED_FROM_ID)
246248
if exp.parsed.sortable is not None:
247249
exp.provenance.setdefault("sortable", Provenance.DERIVED_FROM_ID)
250+
251+
252+
def _uuid_version(u: Any) -> Optional[int]:
253+
try:
254+
# uuid.UUID and uuid6.UUID both usually expose .version
255+
return int(u.version)
256+
except Exception:
257+
return None

typeid/typeid.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timezone
12
import uuid
23
import warnings
34
from typing import Generic, Optional, TypeVar
@@ -144,6 +145,35 @@ def uuid(self) -> uuid6.UUID:
144145
- The UUID type here follows `uuid6.UUID` used by the project.
145146
"""
146147
return _convert_b32_to_uuid(self.suffix)
148+
149+
@property
150+
def created_at(self) -> Optional[datetime]:
151+
"""
152+
Creation time embedded in the underlying UUID, if available.
153+
154+
TypeID typically uses UUIDv7 for generated IDs. UUIDv7 encodes the Unix
155+
timestamp (milliseconds) in the most significant 48 bits of the 128-bit UUID.
156+
157+
Returns:
158+
A timezone-aware UTC datetime if the underlying UUID is version 7,
159+
otherwise None.
160+
"""
161+
u = self.uuid # must be LOSSLESS: constructed as UUID(bytes=decoded_bytes)
162+
163+
# Only UUIDv7 has a defined "created_at" in this sense.
164+
try:
165+
if getattr(u, "version", None) != 7:
166+
return None
167+
except Exception:
168+
return None
169+
170+
try:
171+
# UUID is 128 bits; top 48 bits are unix epoch time in milliseconds.
172+
# So: unix_ms = uuid_int >> (128 - 48) = uuid_int >> 80
173+
unix_ms = int(u.int) >> 80
174+
return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc)
175+
except Exception:
176+
return None
147177

148178
def __str__(self) -> str:
149179
"""
@@ -243,4 +273,4 @@ def _convert_uuid_to_b32(uuid_instance: uuid.UUID) -> str:
243273
def _convert_b32_to_uuid(b32: str) -> uuid6.UUID:
244274
uuid_bytes = bytes(base32.decode(b32))
245275
uuid_int = int.from_bytes(uuid_bytes, byteorder="big")
246-
return uuid6.UUID(int=uuid_int, version=7)
276+
return uuid6.UUID(int=uuid_int)

0 commit comments

Comments
 (0)