diff --git a/docs/quickstart.md b/docs/quickstart.md index 59acc48..6dfbd7e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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: diff --git a/tests/explain/test_engine.py b/tests/explain/test_engine.py index a79cdac..0534cad 100644 --- a/tests/explain/test_engine.py +++ b/tests/explain/test_engine.py @@ -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 diff --git a/tests/test_spec.py b/tests/test_spec.py index 236b796..f009344 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -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 diff --git a/tests/test_typeid.py b/tests/test_typeid.py index fd072fb..25cf011 100644 --- a/tests/test_typeid.py +++ b/tests/test_typeid.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Callable +from uuid import UUID import pytest import uuid6 @@ -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) diff --git a/typeid/explain/engine.py b/typeid/explain/engine.py index 6b6ac0a..b3dd6e6 100644 --- a/typeid/explain/engine.py +++ b/typeid/explain/engine.py @@ -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, @@ -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 diff --git a/typeid/typeid.py b/typeid/typeid.py index 5cfe447..1b130a5 100644 --- a/typeid/typeid.py +++ b/typeid/typeid.py @@ -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. @@ -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) @@ -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: """