Skip to content

Commit 584fe99

Browse files
authored
Merge pull request #55 from akhundMurad/fix/bug/15
fix: allow TypeID to work with other UUID versions.
2 parents d62798c + 53afb44 commit 584fe99

File tree

6 files changed

+97
-135
lines changed

6 files changed

+97
-135
lines changed

docs/quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ tid.uuid
126126

127127
> **_NOTE:_**
128128
> The exact Python type returned by `tid.uuid` depends on the available backend.
129-
> For time-related information, prefer `typeid explain` or derived properties (`.creation_time` and `.timestamp_ms`)
129+
> For time-related information, prefer `typeid explain` or derived properties (`.created_at`)
130130
> over backend-specific UUID attributes.
131131
132132
And you can always reconstruct a TypeID from a UUID:

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: 53 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime, timezone
2-
from typing import Callable
2+
from uuid import UUID
33
import pytest
44
import uuid6
55

@@ -146,96 +146,59 @@ def test_uuid_property() -> None:
146146
assert typeid.uuid.time == uuid.time
147147

148148

149-
def _extract_ts_ms_from_uuid_bytes(uuid_bytes: bytes) -> int:
150-
"""UUIDv7: first 48 bits (6 bytes) are Unix timestamp in milliseconds."""
151-
assert len(uuid_bytes) == 16
152-
return int.from_bytes(uuid_bytes[0:6], byteorder="big")
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
153198

154199

155200
def _assert_utc_datetime(dt: datetime) -> None:
156201
assert isinstance(dt, datetime)
157-
assert dt.tzinfo is not None
158-
assert dt.tzinfo.utcoffset(dt) == timezone.utc.utcoffset(dt)
159-
160-
161-
@pytest.mark.parametrize(
162-
"uuid7_factory",
163-
[
164-
pytest.param(
165-
lambda: __import__("uuid_utils").uuid7(),
166-
id="uuid-utils",
167-
marks=pytest.mark.skipif(
168-
pytest.importorskip("uuid_utils", reason="uuid-utils not installed") is None,
169-
reason="uuid-utils not installed",
170-
),
171-
),
172-
pytest.param(
173-
lambda: __import__("uuid6").uuid7(),
174-
id="uuid6",
175-
marks=pytest.mark.skipif(
176-
pytest.importorskip("uuid6", reason="uuid6 not installed") is None,
177-
reason="uuid6 not installed",
178-
),
179-
),
180-
],
181-
)
182-
def test_timestamp_ms_matches_uuid_bytes_from_uuid(uuid7_factory: Callable[[], object]) -> None:
183-
u = uuid7_factory()
184-
tid = TypeID.from_uuid(prefix="user", suffix=u)
185-
186-
expected = _extract_ts_ms_from_uuid_bytes(u.bytes)
187-
assert tid.timestamp_ms == expected
188-
189-
190-
@pytest.mark.parametrize(
191-
"uuid7_factory",
192-
[
193-
pytest.param(
194-
lambda: __import__("uuid_utils").uuid7(),
195-
id="uuid-utils",
196-
marks=pytest.mark.skipif(
197-
pytest.importorskip("uuid_utils", reason="uuid-utils not installed") is None,
198-
reason="uuid-utils not installed",
199-
),
200-
),
201-
pytest.param(
202-
lambda: __import__("uuid6").uuid7(),
203-
id="uuid6",
204-
marks=pytest.mark.skipif(
205-
pytest.importorskip("uuid6", reason="uuid6 not installed") is None,
206-
reason="uuid6 not installed",
207-
),
208-
),
209-
],
210-
)
211-
def test_creation_time_matches_timestamp_ms_from_uuid(uuid7_factory: Callable[[], object]) -> None:
212-
u = uuid7_factory()
213-
tid = TypeID.from_uuid(prefix="user", suffix=u)
214-
215-
_assert_utc_datetime(tid.creation_time)
216-
217-
expected_dt = datetime.fromtimestamp(tid.timestamp_ms / 1000, tz=timezone.utc)
218-
assert tid.creation_time == expected_dt
219-
220-
221-
def test_timestamp_ms_roundtrip_from_string() -> None:
222-
# Generate a TypeID (whatever backend is configured) and roundtrip via string parsing.
223-
tid1 = TypeID(prefix="user")
224-
s = str(tid1)
225-
226-
tid2 = TypeID.from_string(s)
227-
assert tid2.timestamp_ms == tid1.timestamp_ms
228-
assert tid2.creation_time == tid1.creation_time
229-
230-
231-
def test_creation_time_is_monotonic_non_decreasing_for_multiple_new() -> None:
232-
# UUIDv7 timestamps are millisecond resolution; two IDs in the same ms may be equal.
233-
t1 = TypeID(prefix="user")
234-
t2 = TypeID(prefix="user")
235-
236-
assert t2.timestamp_ms >= t1.timestamp_ms
237-
238-
239-
def test_creation_time_timezone_is_utc() -> None:
240-
tid = TypeID(prefix="user")
241-
_assert_utc_datetime(tid.creation_time)
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
@@ -131,8 +131,10 @@ def _parse_typeid(id_str: str) -> ParsedTypeID:
131131
uuid_obj = tid.uuid # library returns a UUID object (uuid6.UUID)
132132
uuid_str = str(uuid_obj)
133133

134-
created_at = _uuid7_created_at(uuid_obj)
135-
sortable = True # UUIDv7 is time-ordered by design
134+
ver = _uuid_version(uuid_obj)
135+
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: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,6 @@
1414
PrefixT = TypeVar("PrefixT", bound=str)
1515

1616

17-
def _extract_v7_timestamp_ms(uuid_bytes: bytes) -> int:
18-
"""
19-
Extract Unix timestamp (ms) from UUIDv7 bytes.
20-
UUIDv7: first 48 bits = Unix timestamp in ms.
21-
"""
22-
return int.from_bytes(uuid_bytes[0:6], byteorder="big")
23-
24-
2517
def _uuid_from_bytes_v7(uuid_bytes: bytes) -> std_uuid.UUID:
2618
"""
2719
Construct a UUID object from bytes.
@@ -31,7 +23,6 @@ def _uuid_from_bytes_v7(uuid_bytes: bytes) -> std_uuid.UUID:
3123
import uuid6 # type: ignore
3224

3325
uuid_int = int.from_bytes(uuid_bytes, "big")
34-
# uuid6.UUID(int=..., version=7) would be ideal; uuid6 also infers in many cases.
3526
return uuid6.UUID(int=uuid_int)
3627
except Exception:
3728
return std_uuid.UUID(bytes=uuid_bytes)
@@ -214,44 +205,33 @@ def uuid_bytes(self) -> bytes:
214205
return self._uuid_bytes
215206

216207
@property
217-
def timestamp_ms(self) -> int:
208+
def created_at(self) -> Optional[datetime]:
218209
"""
219-
Creation timestamp encoded in the TypeID (milliseconds since Unix epoch).
220-
221-
TypeID identifiers are based on UUIDv7, which encodes the creation time
222-
in the first 48 bits of the UUID as a Unix timestamp in milliseconds.
223-
224-
This value is extracted directly from the identifier and does **not**
225-
depend on any UUID backend or runtime-specific behavior.
226-
227-
Returns:
228-
The creation time as an integer number of milliseconds since
229-
``1970-01-01T00:00:00Z``.
230-
"""
231-
if self._uuid_bytes is None:
232-
self._uuid_bytes = base32.decode(self._suffix)
233-
return _extract_v7_timestamp_ms(self._uuid_bytes)
234-
235-
@property
236-
def creation_time(self) -> datetime:
237-
"""
238-
Creation time of the TypeID as a timezone-aware UTC datetime.
239-
240-
This is a convenience wrapper around :pyattr:`timestamp_ms` that converts
241-
the embedded UUIDv7 timestamp into a ``datetime`` object in UTC.
210+
Creation time embedded in the underlying UUID, if available.
242211
243-
The returned value is:
244-
- timezone-aware
245-
- stable across Python versions
246-
- independent of UUID backend semantics
212+
TypeID typically uses UUIDv7 for generated IDs. UUIDv7 encodes the Unix
213+
timestamp (milliseconds) in the most significant 48 bits of the 128-bit UUID.
247214
248215
Returns:
249-
A ``datetime`` instance representing the creation time in UTC.
216+
A timezone-aware UTC datetime if the underlying UUID is version 7,
217+
otherwise None.
250218
"""
251-
return datetime.fromtimestamp(
252-
self.timestamp_ms / 1000,
253-
tz=timezone.utc,
254-
)
219+
u = self.uuid
220+
221+
# Only UUIDv7 has a defined "created_at" in this sense.
222+
try:
223+
if getattr(u, "version", None) != 7:
224+
return None
225+
except Exception:
226+
return None
227+
228+
try:
229+
# UUID is 128 bits; top 48 bits are unix epoch time in milliseconds.
230+
# So: unix_ms = uuid_int >> (128 - 48) = uuid_int >> 80
231+
unix_ms = int(u.int) >> 80
232+
return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc)
233+
except Exception:
234+
return None
255235

256236
def __str__(self) -> str:
257237
"""

0 commit comments

Comments
 (0)