tokenmeter-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tokenmeter/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """TokenMeter Python SDK.
2
+
3
+ import tokenmeter
4
+ tokenmeter.init(ingest_key="tl_live_...")
5
+ client = tokenmeter.wrap(OpenAI())
6
+
7
+ with tokenmeter.action("generate-report", customer_id="acme", feature="reports"):
8
+ client.chat.completions.create(...)
9
+ """
10
+ from ._client import init
11
+ from ._context import TraceContext, action, attach, current
12
+ from ._diagnose import diagnose
13
+ from ._preflight import GovernanceDenied, GovernanceUnavailable, PreflightDecision, governed, preflight
14
+ from ._wrap import wrap, wrap_openai_compatible
15
+ from ._read_api import ReadApiClient, ReadApiError, TokenMeterReadClient
16
+ from ._topic import keyword_topic_classifier, latest_user_text
17
+
18
+ __all__ = [
19
+ "init",
20
+ "wrap",
21
+ "wrap_openai_compatible",
22
+ "action",
23
+ "current",
24
+ "attach",
25
+ "TraceContext",
26
+ "diagnose",
27
+ "flush",
28
+ "preflight",
29
+ "governed",
30
+ "PreflightDecision",
31
+ "GovernanceDenied",
32
+ "GovernanceUnavailable",
33
+ "ReadApiClient",
34
+ "TokenMeterReadClient",
35
+ "ReadApiError",
36
+ "keyword_topic_classifier",
37
+ "latest_user_text",
38
+ ]
39
+
40
+ __version__ = "0.2.0"
41
+
42
+
43
+ def flush(deadline_s: float = 2.0) -> None:
44
+ """Best-effort synchronous flush. Never raises."""
45
+ from ._client import get_state
46
+
47
+ state = get_state()
48
+ if state is not None:
49
+ state.flusher.flush_best_effort(deadline_s=deadline_s)
tokenmeter/_buffer.py ADDED
@@ -0,0 +1,201 @@
1
+ """Ring buffer + background flush thread (PRD §7.1).
2
+
3
+ Flush every 2s or 500 events, whichever first. Buffer full -> drop OLDEST,
4
+ count the drop, ship the count in the next batch (never silent).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import atexit
9
+ import collections
10
+ import logging
11
+ import threading
12
+ import time
13
+ from typing import Callable, Deque, List, Optional
14
+
15
+ logger = logging.getLogger("tokenmeter")
16
+
17
+ DEFAULT_MAXSIZE = 10_000
18
+ DEFAULT_FLUSH_INTERVAL_S = 2.0
19
+ DEFAULT_FLUSH_BATCH_SIZE = 500
20
+ # DECISIONS.md §8: sustained rate limiting must be LOUD, not a silent spin.
21
+ # 5 consecutive 429s at the default 2s flush interval is ~10s of sustained
22
+ # rejection -- past one unlucky fixed-window boundary straddle, short of
23
+ # letting the ~10k-event ring buffer fill silently.
24
+ CONSECUTIVE_429_WARN_THRESHOLD = 5
25
+
26
+
27
+ class RingBuffer:
28
+ """Thread-safe, fixed-capacity, drop-oldest-on-overflow queue.
29
+
30
+ `collections.deque(maxlen=N)` already evicts from the opposite end on
31
+ overflow (stdlib does it, rung 3) -- this class only adds the lock and the
32
+ dropped-event counter the eviction doesn't tell us about on its own.
33
+ """
34
+
35
+ def __init__(self, maxsize: int = DEFAULT_MAXSIZE) -> None:
36
+ self._dq: Deque[dict] = collections.deque(maxlen=maxsize)
37
+ self._lock = threading.Lock()
38
+ self._dropped = 0
39
+
40
+ def append(self, event: dict) -> None:
41
+ with self._lock:
42
+ if len(self._dq) >= (self._dq.maxlen or 0):
43
+ self._dropped += 1 # about to evict the oldest via maxlen
44
+ self._dq.append(event)
45
+
46
+ def __len__(self) -> int:
47
+ with self._lock:
48
+ return len(self._dq)
49
+
50
+ @property
51
+ def capacity(self) -> int:
52
+ return self._dq.maxlen or 0
53
+
54
+ def drain(self, max_items: Optional[int] = None) -> List[dict]:
55
+ """Pop up to max_items (or all) events. Returns [] if empty."""
56
+ with self._lock:
57
+ n = len(self._dq) if max_items is None else min(max_items, len(self._dq))
58
+ return [self._dq.popleft() for _ in range(n)]
59
+
60
+ def take_dropped(self) -> int:
61
+ """Reset and return the dropped-event count since the last call."""
62
+ with self._lock:
63
+ d, self._dropped = self._dropped, 0
64
+ return d
65
+
66
+ @property
67
+ def dropped(self) -> int:
68
+ """Peek the pending drop count without resetting it."""
69
+ with self._lock:
70
+ return self._dropped
71
+
72
+ def release_dropped(self, n: int) -> None:
73
+ """Subtract n from the pending drop count once it has actually been
74
+ ACKNOWLEDGED by the server, rather than zeroing it the moment it's
75
+ read (take_dropped()'s behavior, which is what Flusher used to call
76
+ unconditionally BEFORE knowing whether the send would even succeed --
77
+ a failed/retried send silently threw the count away, exactly wrong
78
+ during a sustained-429 run where every attempt discarded it again).
79
+ """
80
+ with self._lock:
81
+ self._dropped = max(0, self._dropped - n)
82
+
83
+ def requeue(self, events: List[dict]) -> None:
84
+ """Put a drained-but-unsent batch back, oldest-first, for a failed
85
+ send -- mirrors the TypeScript SDK's unshiftMany(). Without this,
86
+ drain() had already removed the events from the deque, and a raised
87
+ send() (network error, non-2xx) meant _flush_once()'s outer
88
+ try/except simply swallowed them: a real data-loss bug, not just a
89
+ counter bug. If the buffer filled back up while this batch was in
90
+ flight, the newest of what's being returned gives way (drop-oldest
91
+ semantics still hold; what's already in the buffer is newer)."""
92
+ with self._lock:
93
+ maxlen = self._dq.maxlen or 0
94
+ for ev in reversed(events):
95
+ if len(self._dq) >= maxlen:
96
+ self._dropped += 1
97
+ continue
98
+ self._dq.appendleft(ev)
99
+
100
+
101
+ class Flusher:
102
+ """Background thread: flush when len(buffer) >= batch_size OR every interval_s."""
103
+
104
+ def __init__(
105
+ self,
106
+ buffer: RingBuffer,
107
+ send: Callable[[List[dict], int], None],
108
+ *,
109
+ interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
110
+ batch_size: int = DEFAULT_FLUSH_BATCH_SIZE,
111
+ ) -> None:
112
+ self._buffer = buffer
113
+ self._send = send
114
+ self._interval_s = interval_s
115
+ self._batch_size = batch_size
116
+ self._wake = threading.Event()
117
+ self._stop = threading.Event()
118
+ self._thread = threading.Thread(target=self._run, name="tokenmeter-flusher", daemon=True)
119
+ self._atexit_registered = False
120
+ self._consecutive_429s = 0
121
+ self._warned_sustained_429 = False
122
+
123
+ def start(self) -> None:
124
+ self._thread.start()
125
+ if not self._atexit_registered:
126
+ atexit.register(self.flush_best_effort, deadline_s=2.0)
127
+ self._atexit_registered = True
128
+
129
+ def notify(self) -> None:
130
+ """Called on every append; wakes the flusher early once batch_size is hit."""
131
+ if len(self._buffer) >= self._batch_size:
132
+ self._wake.set()
133
+
134
+ def _run(self) -> None:
135
+ while not self._stop.is_set():
136
+ self._wake.wait(timeout=self._interval_s)
137
+ self._wake.clear()
138
+ self._flush_once()
139
+
140
+ def _flush_once(self) -> None:
141
+ events = self._buffer.drain()
142
+ if not events:
143
+ return
144
+ # Peek, don't consume: the count is only "communicated" once the
145
+ # server actually acknowledges it. take_dropped() used to be called
146
+ # unconditionally here, BEFORE knowing whether send() would even
147
+ # succeed -- a failed send meant the drop count it just zeroed was
148
+ # gone, silently under-reporting exactly the signal DECISIONS.md §8
149
+ # relies on to make loss visible (worst during a sustained-429 run,
150
+ # where every retry attempt reset it again).
151
+ dropped = self._buffer.dropped
152
+ try:
153
+ self._send(events, dropped)
154
+ except Exception as exc: # SDK NEVER throws into host code -- swallow, log, move on.
155
+ # drain() already removed these from the buffer; without putting
156
+ # them back, a failed send silently discarded the whole batch --
157
+ # not just under-counted drops, an actual data-loss bug.
158
+ self._buffer.requeue(events)
159
+ self._note_failure(exc)
160
+ logger.debug("tokenmeter: flush failed", exc_info=True)
161
+ return
162
+ self._buffer.release_dropped(dropped)
163
+ self._consecutive_429s = 0
164
+ self._warned_sustained_429 = False
165
+
166
+ def _note_failure(self, exc: Exception) -> None:
167
+ """DECISIONS.md §8: sustained 429s must be loud, not a silent retry
168
+ spin. Mirrors diagnose()'s "print + log, never raise" approach --
169
+ one warning per incident (not one per attempt), reset once traffic
170
+ stops being rate-limited."""
171
+ code = getattr(exc, "code", None)
172
+ if code == 429:
173
+ self._consecutive_429s += 1
174
+ else:
175
+ self._consecutive_429s = 0
176
+ self._warned_sustained_429 = False
177
+ return
178
+ if self._consecutive_429s >= CONSECUTIVE_429_WARN_THRESHOLD and not self._warned_sustained_429:
179
+ self._warned_sustained_429 = True
180
+ msg = (
181
+ f"tokenmeter: {self._consecutive_429s} consecutive batches rate-limited (HTTP 429). "
182
+ "Events are being retried, not dropped by the server, but this SDK's local buffer "
183
+ f"(maxsize={self._buffer.capacity}) can still overflow under sustained pressure -- "
184
+ "watch the dropped-event count. If this persists, the ingest key's plan quota is "
185
+ "genuinely too low for this traffic. Run tokenmeter.diagnose() for more detail."
186
+ )
187
+ logger.warning(msg)
188
+ print("[tokenmeter] WARNING: " + msg)
189
+
190
+ def flush_best_effort(self, deadline_s: float = 2.0) -> None:
191
+ """Synchronous, best-effort, capped flush -- used by atexit and public flush()."""
192
+ deadline = time.monotonic() + deadline_s
193
+ try:
194
+ while len(self._buffer) > 0 and time.monotonic() < deadline:
195
+ self._flush_once()
196
+ except Exception:
197
+ logger.debug("tokenmeter: atexit flush failed", exc_info=True)
198
+
199
+ def stop(self) -> None:
200
+ self._stop.set()
201
+ self._wake.set()
tokenmeter/_client.py ADDED
@@ -0,0 +1,78 @@
1
+ """Global SDK state, set up once by tokenmeter.init()."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Optional
6
+
7
+ from ._buffer import DEFAULT_FLUSH_BATCH_SIZE, DEFAULT_FLUSH_INTERVAL_S, DEFAULT_MAXSIZE, Flusher, RingBuffer
8
+ from ._transport import Transport
9
+
10
+ logger = logging.getLogger("tokenmeter")
11
+
12
+ DEFAULT_ENDPOINT = "https://ingest.timewastevc.com"
13
+
14
+
15
+ class _State:
16
+ def __init__(
17
+ self,
18
+ ingest_key: str,
19
+ endpoint: str = DEFAULT_ENDPOINT,
20
+ *,
21
+ buffer_size: int = DEFAULT_MAXSIZE,
22
+ flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
23
+ flush_batch_size: int = DEFAULT_FLUSH_BATCH_SIZE,
24
+ optimization: Optional[dict] = None,
25
+ ) -> None:
26
+ self.optimization = dict(optimization) if isinstance(optimization, dict) else {}
27
+ self.transport = Transport(ingest_key, endpoint)
28
+ self.buffer = RingBuffer(buffer_size)
29
+ self.flusher = Flusher(
30
+ self.buffer,
31
+ self.transport.send,
32
+ interval_s=flush_interval_s,
33
+ batch_size=flush_batch_size,
34
+ )
35
+ self.flusher.start()
36
+
37
+ def emit(self, event: dict) -> None:
38
+ # SDK NEVER throws into host code and NEVER adds latency to the caller --
39
+ # this is a memory append, not I/O, so it's already sub-microsecond; the
40
+ # try/except is only for the pathological case of an unpicklable/huge event.
41
+ try:
42
+ self.buffer.append(event)
43
+ self.flusher.notify()
44
+ except Exception:
45
+ logger.debug("tokenmeter: emit failed", exc_info=True)
46
+
47
+
48
+ _state: Optional[_State] = None
49
+
50
+
51
+ def init(
52
+ ingest_key: str,
53
+ endpoint: str = DEFAULT_ENDPOINT,
54
+ *,
55
+ router_url: Optional[str] = None,
56
+ governance_mode: str = "enforce",
57
+ governance_fail_mode: str = "closed",
58
+ optimization: Optional[dict] = None,
59
+ **kwargs,
60
+ ) -> None:
61
+ """router_url enables tokenmeter.preflight()/governed() (governance before direct provider calls)."""
62
+ global _state
63
+ from . import _preflight
64
+
65
+ _preflight.configure(ingest_key, router_url, mode=governance_mode, fail_mode=governance_fail_mode)
66
+ _state = _State(ingest_key, endpoint, optimization=optimization, **kwargs)
67
+
68
+
69
+ def get_state() -> Optional[_State]:
70
+ return _state
71
+
72
+
73
+ def _reset_for_tests() -> None:
74
+ """Test-only: drop global state so each test starts clean."""
75
+ global _state
76
+ if _state is not None:
77
+ _state.flusher.stop()
78
+ _state = None
tokenmeter/_context.py ADDED
@@ -0,0 +1,131 @@
1
+ """Trace/action context propagation (PRD §11).
2
+
3
+ contextvars.ContextVar survives asyncio task spawns (gather, create_task) and
4
+ thread-pool submission natively as long as the submitted callable captures the
5
+ context (contextvars.copy_context() / ThreadPoolExecutor + contextvars.copy_context
6
+ via `contextvars.copy_context().run(...)`, or Python's own default propagation into
7
+ `concurrent.futures.ThreadPoolExecutor.submit` -- which does NOT auto-propagate, so
8
+ callers crossing a real OS thread boundary must use `attach(current())` explicitly,
9
+ same escape hatch as a queue/serialize hop. This module provides that hatch.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import contextvars
14
+ import dataclasses
15
+ from typing import Optional
16
+
17
+ from ._ids import uuid7_str
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True)
21
+ class TraceContext:
22
+ trace_id: str
23
+ span_id: str
24
+ parent_span_id: Optional[str] = None
25
+ action: Optional[str] = None
26
+ customer_ref: Optional[str] = None
27
+ feature: Optional[str] = None
28
+ team: Optional[str] = None
29
+ environment: Optional[str] = None
30
+ project: Optional[str] = None
31
+ member_ref: Optional[str] = None
32
+ optimization: Optional[dict] = None
33
+
34
+ def to_dict(self) -> dict:
35
+ d = dataclasses.asdict(self)
36
+ # A function (e.g. optimization["topic_classifier"]) cannot cross a queue/serialize hop; the receiving side
37
+ # falls back to the classifier configured in its own tokenmeter.init().
38
+ if isinstance(d.get("optimization"), dict):
39
+ d["optimization"] = {k: v for k, v in d["optimization"].items() if not callable(v)}
40
+ return d
41
+
42
+ @classmethod
43
+ def from_dict(cls, d: dict) -> "TraceContext":
44
+ return cls(**d)
45
+
46
+
47
+ _current: "contextvars.ContextVar[Optional[TraceContext]]" = contextvars.ContextVar(
48
+ "tokenmeter_context", default=None
49
+ )
50
+
51
+
52
+ def current() -> Optional[TraceContext]:
53
+ """The active TraceContext, or None if no action() scope is open.
54
+
55
+ Serializable (dataclass of str fields) so it can cross a queue/serialize hop:
56
+ `payload = tokenmeter.current().to_dict()` ... `tokenmeter.attach(TraceContext.from_dict(payload))`.
57
+ """
58
+ return _current.get()
59
+
60
+
61
+ def attach(ctx: Optional[TraceContext]) -> contextvars.Token:
62
+ """Escape hatch for thread pools and queue/serialize hops (PRD §11).
63
+
64
+ Returns the reset Token so the caller can restore the prior value if it owns
65
+ the context's lifetime (rarely needed -- a fresh thread/task has nothing to
66
+ restore to).
67
+ """
68
+ return _current.set(ctx)
69
+
70
+
71
+ def _reset(token: contextvars.Token) -> None:
72
+ _current.reset(token)
73
+
74
+
75
+ class action:
76
+ """Context manager. Nested calls form a span chain (parent_span_id chain)."""
77
+
78
+ def __init__(
79
+ self,
80
+ name: str,
81
+ *,
82
+ customer_id: Optional[str] = None,
83
+ customer_ref: Optional[str] = None,
84
+ feature: Optional[str] = None,
85
+ team: Optional[str] = None,
86
+ environment: Optional[str] = None,
87
+ project: Optional[str] = None,
88
+ member_ref: Optional[str] = None,
89
+ optimization: Optional[dict] = None,
90
+ ) -> None:
91
+ self.name = name
92
+ # accept either kwarg spelling used across the PRD text/examples
93
+ self.customer_ref = customer_ref or customer_id
94
+ self.feature = feature
95
+ self.team = team
96
+ self.environment = environment
97
+ self.project = project
98
+ self.member_ref = member_ref
99
+ self.optimization = dict(optimization) if isinstance(optimization, dict) else {}
100
+ self._token: Optional[contextvars.Token] = None
101
+
102
+ def __enter__(self) -> TraceContext:
103
+ parent = current()
104
+ ctx = TraceContext(
105
+ trace_id=parent.trace_id if parent else uuid7_str(),
106
+ span_id=uuid7_str(),
107
+ parent_span_id=parent.span_id if parent else None,
108
+ action=self.name,
109
+ customer_ref=self.customer_ref if self.customer_ref is not None else (parent.customer_ref if parent else None),
110
+ feature=self.feature if self.feature is not None else (parent.feature if parent else None),
111
+ team=self.team if self.team is not None else (parent.team if parent else None),
112
+ environment=self.environment if self.environment is not None else (parent.environment if parent else None),
113
+ project=self.project if self.project is not None else (parent.project if parent else None),
114
+ member_ref=self.member_ref if self.member_ref is not None else (parent.member_ref if parent else None),
115
+ optimization={**(parent.optimization or {} if parent else {}), **self.optimization},
116
+ )
117
+ self._token = attach(ctx)
118
+ return ctx
119
+
120
+ def __exit__(self, exc_type, exc, tb) -> None:
121
+ if self._token is not None:
122
+ _reset(self._token)
123
+ return None
124
+
125
+ # async support: `async with tokenmeter.action(...):` works identically since
126
+ # entering/exiting is synchronous context-var manipulation, not I/O.
127
+ async def __aenter__(self) -> TraceContext:
128
+ return self.__enter__()
129
+
130
+ async def __aexit__(self, exc_type, exc, tb) -> None:
131
+ self.__exit__(exc_type, exc, tb)
@@ -0,0 +1,57 @@
1
+ """tokenmeter.diagnose() -- finding M4: a typo'd ingest key must never be silent.
2
+
3
+ Everywhere else the SDK swallows errors (that's the whole point of "never throws
4
+ into host code"). diagnose() is the one deliberate exception: it is called on
5
+ purpose, synchronously, specifically to make failures LOUD -- it prints a clear
6
+ verdict and returns a structured result, and it is the documented answer to
7
+ "why do I see zero data in my dashboard."
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import urllib.error
12
+
13
+ from ._client import DEFAULT_ENDPOINT, get_state
14
+ from ._transport import Transport
15
+
16
+ _STATUS_HINTS = {
17
+ 401: "invalid_key: the ingest key is wrong, revoked, or malformed. Check tokenmeter.init(ingest_key=...).",
18
+ 403: "key_revoked: this key (or its org) was revoked/purged. Issue a new key from the dashboard.",
19
+ 413: "batch_too_large: unrelated to diagnose() itself -- your real traffic is sending oversized batches.",
20
+ 429: "rate_limited: traffic looks abuse-shaped (well over the plan quota), not ordinary overage -- ordinary overage is accepted (DECISIONS.md §8). A single 429 is retried and not data loss; SUSTAINED 429s can be, since this SDK's local buffer is bounded -- watch for the sustained-429 warning this SDK logs.",
21
+ 503: "unavailable: TokenMeter ingest is temporarily down. Events buffer locally and retry.",
22
+ }
23
+
24
+
25
+ def diagnose(ingest_key: str = None, endpoint: str = None) -> dict:
26
+ """Synchronously pings the Ingest API and returns
27
+ {"ok": bool, "status": int|None, "message": str}. Never raises. Prints the
28
+ verdict too, because the whole point is to be found by a human staring at a
29
+ silent dashboard, not by someone who already knew to check a return value.
30
+ """
31
+ state = get_state()
32
+ key = ingest_key or (state.transport.ingest_key if state else None)
33
+ url = endpoint or (state.transport.endpoint.rsplit("/v1/", 1)[0] if state else DEFAULT_ENDPOINT)
34
+
35
+ if not key:
36
+ result = {"ok": False, "status": None, "message": "no ingest key: call tokenmeter.init(ingest_key=...) first, or pass one to diagnose()."}
37
+ _report(result)
38
+ return result
39
+
40
+ try:
41
+ pong = Transport(key, url).ping()
42
+ result = {"ok": True, "status": pong["status"], "message": f"tokenmeter: ingest key valid, endpoint reachable ({url})."}
43
+ except urllib.error.HTTPError as e:
44
+ hint = _STATUS_HINTS.get(e.code, f"unexpected HTTP {e.code}")
45
+ result = {"ok": False, "status": e.code, "message": f"tokenmeter: {hint}"}
46
+ except urllib.error.URLError as e:
47
+ result = {"ok": False, "status": None, "message": f"tokenmeter: could not reach {url}: {e.reason}"}
48
+ except Exception as e: # still must never raise into host code
49
+ result = {"ok": False, "status": None, "message": f"tokenmeter: diagnose() failed unexpectedly: {e!r}"}
50
+
51
+ _report(result)
52
+ return result
53
+
54
+
55
+ def _report(result: dict) -> None:
56
+ prefix = "[tokenmeter diagnose] OK: " if result["ok"] else "[tokenmeter diagnose] FAILED: "
57
+ print(prefix + result["message"])
tokenmeter/_ids.py ADDED
@@ -0,0 +1,45 @@
1
+ """UUIDv7 (RFC 9562) event ids: time-ordered, client-generated, dedup key upstream."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+ import uuid
7
+
8
+
9
+ def uuid7() -> uuid.UUID:
10
+ """48-bit unix ms timestamp + version/variant bits + 74 random bits."""
11
+ unix_ts_ms = int(time.time() * 1000) & 0xFFFFFFFFFFFF # 48 bits
12
+ rand = os.urandom(10) # 12 bits rand_a + 62 bits rand_b = 74 bits, rounded up to 10 bytes
13
+
14
+ rand_a = int.from_bytes(rand[0:2], "big") & 0x0FFF # 12 bits
15
+ rand_b = int.from_bytes(rand[2:10], "big") & 0x3FFFFFFFFFFFFFFF # 62 bits
16
+
17
+ b = bytearray(16)
18
+ b[0:6] = unix_ts_ms.to_bytes(6, "big")
19
+ b[6] = 0x70 | (rand_a >> 8) # version 7 in top nibble
20
+ b[7] = rand_a & 0xFF
21
+ b[8] = 0x80 | (rand_b >> 56) & 0x3F # variant bits 10
22
+ b[9:16] = ((rand_b >> 0) & 0xFFFFFFFFFFFFFF).to_bytes(7, "big")
23
+ return uuid.UUID(bytes=bytes(b))
24
+
25
+
26
+ def uuid7_str() -> str:
27
+ return str(uuid7())
28
+
29
+
30
+ def demo() -> None:
31
+ ids = [uuid7() for _ in range(1000)]
32
+ strs = [str(i) for i in ids]
33
+ assert len(set(strs)) == 1000, "collision in 1000 draws"
34
+ for i in ids:
35
+ assert i.version == 7
36
+ # Timestamp prefix (first 12 hex chars = 48-bit ms) must be non-decreasing across
37
+ # calls in issuance order -- the property that makes these dedup/sort-friendly.
38
+ # (Random tail bits are NOT required to sort within the same millisecond.)
39
+ ts_prefixes = [s.replace("-", "")[:12] for s in strs]
40
+ assert ts_prefixes == sorted(ts_prefixes), "uuid7 timestamp prefix must be time-ordered"
41
+ print("uuid7 demo: ok")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ demo()