Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ tid.uuid

> **_NOTE:_**
> The exact Python type returned by `tid.uuid` depends on the available backend.
> For time-related information, prefer `typeid explain` or derived properties (`.creation_time` and `.timestamp_ms`)
> For time-related information, prefer `typeid explain` or derived properties (`.created_at`)
> over backend-specific UUID attributes.

And you can always reconstruct a TypeID from a UUID:
Expand Down
8 changes: 8 additions & 0 deletions tests/explain/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,11 @@ def test_to_dict_is_json_serializable():

payload = exp.to_dict()
json.dumps(payload) # should not raise


def test_explain_nil_uuid_not_sortable_no_created_at():
exp = explain("x_00000000000000000000000000", enable_schema=False)
assert exp.valid is True
assert exp.parsed.uuid == "00000000-0000-0000-0000-000000000000"
assert exp.parsed.created_at is None
assert exp.parsed.sortable is False
1 change: 1 addition & 0 deletions tests/test_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ def test_valid_spec(valid_spec: list) -> None:

typeid = TypeID.from_uuid(prefix=prefix, suffix=uuid)
assert str(typeid) == spec["typeid"]
assert typeid.uuid == uuid
143 changes: 53 additions & 90 deletions tests/test_typeid.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from typing import Callable
from uuid import UUID
import pytest
import uuid6

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


def _extract_ts_ms_from_uuid_bytes(uuid_bytes: bytes) -> int:
"""UUIDv7: first 48 bits (6 bytes) are Unix timestamp in milliseconds."""
assert len(uuid_bytes) == 16
return int.from_bytes(uuid_bytes[0:6], byteorder="big")
def test_created_at_none_for_nil_uuid_suffix():
tid = TypeID(prefix="x", suffix="00000000000000000000000000")
assert tid.created_at is None


def test_created_at_none_for_non_v7_uuid_v4():
# UUIDv4 (random) must not claim created_at
u = UUID("550e8400-e29b-41d4-a716-446655440000") # version 4
tid = TypeID.from_uuid(u, prefix="x")
assert tid.created_at is None


def test_created_at_is_utc_for_uuid7_generated_typeid():
# Default TypeID generation should be UUIDv7; then created_at must be present and UTC
tid = TypeID(prefix="x")
dt = tid.created_at
assert dt is not None
_assert_utc_datetime(dt)


def test_created_at_monotonic_increasing_for_multiple_new_ids():
# UUIDv7 embeds time; created_at should be non-decreasing across consecutive generations.
# Note: UUIDv7 can generate multiple IDs within the same millisecond, so equality is allowed.
t1 = TypeID(prefix="x").created_at
t2 = TypeID(prefix="x").created_at
t3 = TypeID(prefix="x").created_at

assert t1 is not None and t2 is not None and t3 is not None
assert t1 <= t2 <= t3


def test_created_at_does_not_crash_if_uuid_object_is_unexpected(monkeypatch):
# If TypeID.uuid returns something odd that breaks version/int access,
# created_at should return None (safe behavior).
class WeirdUUID:
@property
def version(self):
raise RuntimeError("nope")

@property
def int(self):
raise RuntimeError("nope")

tid = TypeID(prefix="x", suffix="00000000000000000000000000")

# monkeypatch instance attribute/property access
monkeypatch.setattr(type(tid), "uuid", property(lambda self: WeirdUUID()))

assert tid.created_at is None


def _assert_utc_datetime(dt: datetime) -> None:
assert isinstance(dt, datetime)
assert dt.tzinfo is not None
assert dt.tzinfo.utcoffset(dt) == timezone.utc.utcoffset(dt)


@pytest.mark.parametrize(
"uuid7_factory",
[
pytest.param(
lambda: __import__("uuid_utils").uuid7(),
id="uuid-utils",
marks=pytest.mark.skipif(
pytest.importorskip("uuid_utils", reason="uuid-utils not installed") is None,
reason="uuid-utils not installed",
),
),
pytest.param(
lambda: __import__("uuid6").uuid7(),
id="uuid6",
marks=pytest.mark.skipif(
pytest.importorskip("uuid6", reason="uuid6 not installed") is None,
reason="uuid6 not installed",
),
),
],
)
def test_timestamp_ms_matches_uuid_bytes_from_uuid(uuid7_factory: Callable[[], object]) -> None:
u = uuid7_factory()
tid = TypeID.from_uuid(prefix="user", suffix=u)

expected = _extract_ts_ms_from_uuid_bytes(u.bytes)
assert tid.timestamp_ms == expected


@pytest.mark.parametrize(
"uuid7_factory",
[
pytest.param(
lambda: __import__("uuid_utils").uuid7(),
id="uuid-utils",
marks=pytest.mark.skipif(
pytest.importorskip("uuid_utils", reason="uuid-utils not installed") is None,
reason="uuid-utils not installed",
),
),
pytest.param(
lambda: __import__("uuid6").uuid7(),
id="uuid6",
marks=pytest.mark.skipif(
pytest.importorskip("uuid6", reason="uuid6 not installed") is None,
reason="uuid6 not installed",
),
),
],
)
def test_creation_time_matches_timestamp_ms_from_uuid(uuid7_factory: Callable[[], object]) -> None:
u = uuid7_factory()
tid = TypeID.from_uuid(prefix="user", suffix=u)

_assert_utc_datetime(tid.creation_time)

expected_dt = datetime.fromtimestamp(tid.timestamp_ms / 1000, tz=timezone.utc)
assert tid.creation_time == expected_dt


def test_timestamp_ms_roundtrip_from_string() -> None:
# Generate a TypeID (whatever backend is configured) and roundtrip via string parsing.
tid1 = TypeID(prefix="user")
s = str(tid1)

tid2 = TypeID.from_string(s)
assert tid2.timestamp_ms == tid1.timestamp_ms
assert tid2.creation_time == tid1.creation_time


def test_creation_time_is_monotonic_non_decreasing_for_multiple_new() -> None:
# UUIDv7 timestamps are millisecond resolution; two IDs in the same ms may be equal.
t1 = TypeID(prefix="user")
t2 = TypeID(prefix="user")

assert t2.timestamp_ms >= t1.timestamp_ms


def test_creation_time_timezone_is_utc() -> None:
tid = TypeID(prefix="user")
_assert_utc_datetime(tid.creation_time)
assert dt.tzinfo is timezone.utc
# must be timezone-aware and normalized to UTC
assert dt.utcoffset() == timezone.utc.utcoffset(dt)
14 changes: 12 additions & 2 deletions typeid/explain/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,10 @@ def _parse_typeid(id_str: str) -> ParsedTypeID:
uuid_obj = tid.uuid # library returns a UUID object (uuid6.UUID)
uuid_str = str(uuid_obj)

created_at = _uuid7_created_at(uuid_obj)
sortable = True # UUIDv7 is time-ordered by design
ver = _uuid_version(uuid_obj)

created_at = _uuid7_created_at(uuid_obj) if ver == 7 else None
sortable = True if ver == 7 else False

return ParsedTypeID(
raw=id_str,
Expand Down Expand Up @@ -245,3 +247,11 @@ def _apply_derived_provenance(exp: Explanation) -> None:
exp.provenance.setdefault("created_at", Provenance.DERIVED_FROM_ID)
if exp.parsed.sortable is not None:
exp.provenance.setdefault("sortable", Provenance.DERIVED_FROM_ID)


def _uuid_version(u: Any) -> Optional[int]:
try:
# uuid.UUID and uuid6.UUID both usually expose .version
return int(u.version)
except Exception:
return None
64 changes: 22 additions & 42 deletions typeid/typeid.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,6 @@
PrefixT = TypeVar("PrefixT", bound=str)


def _extract_v7_timestamp_ms(uuid_bytes: bytes) -> int:
"""
Extract Unix timestamp (ms) from UUIDv7 bytes.
UUIDv7: first 48 bits = Unix timestamp in ms.
"""
return int.from_bytes(uuid_bytes[0:6], byteorder="big")


def _uuid_from_bytes_v7(uuid_bytes: bytes) -> std_uuid.UUID:
"""
Construct a UUID object from bytes.
Expand All @@ -31,7 +23,6 @@ def _uuid_from_bytes_v7(uuid_bytes: bytes) -> std_uuid.UUID:
import uuid6 # type: ignore

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

@property
def timestamp_ms(self) -> int:
def created_at(self) -> Optional[datetime]:
"""
Creation timestamp encoded in the TypeID (milliseconds since Unix epoch).

TypeID identifiers are based on UUIDv7, which encodes the creation time
in the first 48 bits of the UUID as a Unix timestamp in milliseconds.

This value is extracted directly from the identifier and does **not**
depend on any UUID backend or runtime-specific behavior.

Returns:
The creation time as an integer number of milliseconds since
``1970-01-01T00:00:00Z``.
"""
if self._uuid_bytes is None:
self._uuid_bytes = base32.decode(self._suffix)
return _extract_v7_timestamp_ms(self._uuid_bytes)

@property
def creation_time(self) -> datetime:
"""
Creation time of the TypeID as a timezone-aware UTC datetime.

This is a convenience wrapper around :pyattr:`timestamp_ms` that converts
the embedded UUIDv7 timestamp into a ``datetime`` object in UTC.
Creation time embedded in the underlying UUID, if available.

The returned value is:
- timezone-aware
- stable across Python versions
- independent of UUID backend semantics
TypeID typically uses UUIDv7 for generated IDs. UUIDv7 encodes the Unix
timestamp (milliseconds) in the most significant 48 bits of the 128-bit UUID.

Returns:
A ``datetime`` instance representing the creation time in UTC.
A timezone-aware UTC datetime if the underlying UUID is version 7,
otherwise None.
"""
return datetime.fromtimestamp(
self.timestamp_ms / 1000,
tz=timezone.utc,
)
u = self.uuid

# Only UUIDv7 has a defined "created_at" in this sense.
try:
if getattr(u, "version", None) != 7:
return None
except Exception:
return None

try:
# UUID is 128 bits; top 48 bits are unix epoch time in milliseconds.
# So: unix_ms = uuid_int >> (128 - 48) = uuid_int >> 80
unix_ms = int(u.int) >> 80
return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc)
except Exception:
return None

def __str__(self) -> str:
"""
Expand Down