python-ulid 3.2.1__py3-none-any.whl → 4.0.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ulid
3
- Version: 3.2.1
3
+ Version: 4.0.1
4
4
  Summary: Universally unique lexicographically sortable identifier
5
5
  Project-URL: Homepage, https://github.com/mdomke/python-ulid
6
6
  Project-URL: Documentation, https://python-ulid.readthedocs.io
@@ -164,6 +164,64 @@ timestamp, then :math:`r_2 = r_1 + 1`.
164
164
 
165
165
  .. monotonic-end
166
166
 
167
+ .. generators-begin
168
+
169
+ Generators and policies
170
+ -----------------------
171
+
172
+ Every ``ULID`` is produced by a ``ULIDGenerator``, which samples a clock for the timestamp,
173
+ sources entropy for the randomness, and enforces a *monotonicity policy*. The bare ``ULID()``
174
+ constructor and the ``ULID.from_*`` factory methods delegate to a shared module-level
175
+ ``default_generator``.
176
+
177
+ For most use cases the default is all you need. To customize generation — a different clock, a
178
+ custom entropy source, or another monotonicity policy — create your own ``ULIDGenerator`` and
179
+ call ``generate()``
180
+
181
+ .. code-block:: pycon
182
+
183
+ >>> from ulid import ULIDGenerator
184
+ >>> generator = ULIDGenerator()
185
+ >>> generator.generate()
186
+ ULID(01HB0N8Q4RCE7YB1M2VZK9WX3T)
187
+
188
+ Choosing a monotonicity policy
189
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
190
+
191
+ A policy decides how the randomness is resolved when multiple ULIDs are generated within the same
192
+ millisecond. Three policies are available:
193
+
194
+ * ``StrictMonotonicPolicy`` *(default)* — increments the randomness by 1 on a same-millisecond
195
+ collision and raises ``ValueError`` if the randomness is exhausted. This is the behaviour
196
+ described under `Monotonic Support`_.
197
+ * ``LaxMonotonicPolicy`` — increments like the strict policy, but regenerates fresh randomness
198
+ instead of raising when the randomness is exhausted.
199
+ * ``PureRandomPolicy`` — ignores previous state and always draws fresh randomness, maximizing
200
+ entropy at the cost of same-millisecond sort order.
201
+
202
+ .. code-block:: pycon
203
+
204
+ >>> from ulid import ULIDGenerator, PureRandomPolicy
205
+ >>> generator = ULIDGenerator(policy=PureRandomPolicy())
206
+ >>> generator.generate()
207
+ ULID(01HB0N9F3TA5KDQ6ZE0WYV7MRC)
208
+
209
+ Overriding the default generator
210
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
211
+
212
+ To make ``ULID()`` and the ``ULID.from_*`` constructors use a custom generator globally, reassign
213
+ ``ulid.default_generator``
214
+
215
+ .. code-block:: pycon
216
+
217
+ >>> import ulid
218
+ >>> from ulid import ULID, ULIDGenerator, LaxMonotonicPolicy
219
+ >>> ulid.default_generator = ULIDGenerator(policy=LaxMonotonicPolicy())
220
+ >>> ULID() # now generated with the lax policy
221
+ ULID(01HB0NB7X2M4C8VKQ0ZF5WD9RA)
222
+
223
+ .. generators-end
224
+
167
225
  .. cli-begin
168
226
 
169
227
  Command line interface
@@ -0,0 +1,10 @@
1
+ ulid/__init__.py,sha256=RUAerfe39tcNib2oAREKB7U9DfF-rdXueNg3kF76zV8,21227
2
+ ulid/__main__.py,sha256=ZP8ZbhQ_o-O4Ckbba87bZ4NkwzrSFhAkrRLmUnzkW0Y,5586
3
+ ulid/base32.py,sha256=iUE5BGPW5Dul-Eu3ITvcymk8acNkIFAVTlrL12YIPKI,5961
4
+ ulid/constants.py,sha256=lbCLRNQXq8qnOdz_w-nJf_j5dC2X7Gjv-8GJUbs5jX8,503
5
+ ulid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ python_ulid-4.0.1.dist-info/METADATA,sha256=LuILMUf_zi1x8OonhM9KEuPdygFqPs9R4ubHAMxn6fQ,8758
7
+ python_ulid-4.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ python_ulid-4.0.1.dist-info/entry_points.txt,sha256=9214ghzfSgS8a-TjjlWDF7bI7KSaeewOCBKn5uz7bdU,50
9
+ python_ulid-4.0.1.dist-info/licenses/LICENSE,sha256=DiDPNhW5yUHIqaKowvX0c1c9xWiI0xW4ji8R9N2NJbw,1056
10
+ python_ulid-4.0.1.dist-info/RECORD,,
ulid/__init__.py CHANGED
@@ -1,17 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import abc
3
4
  import functools
4
5
  import os
5
6
  import time
6
7
  import uuid
8
+ from collections.abc import Callable
7
9
  from datetime import datetime
8
10
  from datetime import timezone
9
11
  from threading import Lock
10
12
  from typing import Any
11
13
  from typing import cast
12
- from typing import Generic
14
+ from typing import Protocol
13
15
  from typing import TYPE_CHECKING
14
- from typing import TypeVar
16
+
17
+
18
+ try:
19
+ from typing import override
20
+ except ImportError:
21
+ from typing_extensions import override # type: ignore
15
22
 
16
23
  from ulid import base32
17
24
  from ulid import constants
@@ -19,7 +26,6 @@ from ulid import constants
19
26
 
20
27
  if TYPE_CHECKING: # pragma: no cover
21
28
  import sys
22
- from collections.abc import Callable
23
29
 
24
30
  from pydantic import GetCoreSchemaHandler
25
31
  from pydantic import ValidatorFunctionWrapHandler
@@ -38,65 +44,202 @@ except ImportError: # pragma: no cover
38
44
 
39
45
  __version__ = version("python-ulid")
40
46
 
41
- T = TypeVar("T")
42
- R = TypeVar("R")
47
+
48
+ RandomnessSource = Callable[[int], bytes]
43
49
 
44
50
 
45
- class validate_type(Generic[T]): # noqa: N801
46
- def __init__(self, *types: type[T]) -> None:
47
- self.types = types
51
+ class MonotonicityPolicy(Protocol):
52
+ """Protocol defining the interface for monotonicity and randomness resolution policies."""
53
+
54
+ def resolve_randomness(
55
+ self,
56
+ timestamp: int,
57
+ randomness_source: RandomnessSource,
58
+ ) -> bytes:
59
+ """Resolve randomness for a given timestamp.
60
+
61
+ Args:
62
+ timestamp (int): The current timestamp in milliseconds.
63
+ randomness_source (RandomnessSource): A callable to get fresh random bytes.
48
64
 
49
- def __call__(self, func: Callable[..., R]) -> Callable[..., R]:
50
- @functools.wraps(func)
51
- def wrapped(cls: Any, value: T) -> R:
52
- if not isinstance(value, self.types):
53
- message = "Value has to be of type "
54
- message += " or ".join([t.__name__ for t in self.types])
55
- raise TypeError(message)
56
- return func(cls, value)
65
+ Returns:
66
+ bytes: The resolved randomness bytes (80 bits).
67
+ """
68
+ ...
57
69
 
58
- return wrapped
59
70
 
71
+ class BaseMonotonicPolicy(abc.ABC):
72
+ """Base class for stateful monotonic policies."""
60
73
 
61
- class ValueProvider:
62
74
  def __init__(self) -> None:
63
- self.lock = Lock()
64
75
  self.prev_timestamp = constants.MIN_TIMESTAMP
65
76
  self.prev_randomness = constants.MIN_RANDOMNESS
66
77
 
67
- def timestamp(self, value: float | None = None) -> int:
78
+ def resolve_randomness(
79
+ self,
80
+ timestamp: int,
81
+ randomness_source: RandomnessSource,
82
+ ) -> bytes:
83
+ if timestamp == self.prev_timestamp:
84
+ if self.prev_randomness == constants.MAX_RANDOMNESS:
85
+ randomness = self._on_overflow(timestamp, randomness_source)
86
+ else:
87
+ randomness = self._increment(self.prev_randomness)
88
+ else:
89
+ randomness = randomness_source(timestamp)
90
+
91
+ self.prev_randomness = randomness
92
+ self.prev_timestamp = timestamp
93
+ return randomness
94
+
95
+ @staticmethod
96
+ def _increment(value: bytes) -> bytes:
97
+ return (int.from_bytes(value, byteorder="big") + 1).to_bytes(len(value), byteorder="big")
98
+
99
+ @abc.abstractmethod
100
+ def _on_overflow(
101
+ self,
102
+ timestamp: int,
103
+ randomness_source: RandomnessSource,
104
+ ) -> bytes:
105
+ """Handle same-millisecond randomness exhaustion.
106
+
107
+ Args:
108
+ timestamp (int): The current timestamp in milliseconds.
109
+ randomness_source (RandomnessSource): A callable to get fresh random bytes.
110
+
111
+ Returns:
112
+ bytes: The resolved randomness bytes.
113
+ """
114
+ raise NotImplementedError
115
+
116
+
117
+ class StrictMonotonicPolicy(BaseMonotonicPolicy):
118
+ """Strict monotonicity policy.
119
+
120
+ Always increments the randomness by 1 if generated within the same millisecond.
121
+ Raises ValueError on millisecond randomness exhaustion.
122
+ """
123
+
124
+ @override
125
+ def _on_overflow(
126
+ self,
127
+ timestamp: int,
128
+ randomness_source: RandomnessSource,
129
+ ) -> bytes:
130
+ raise ValueError("Randomness within same millisecond exhausted")
131
+
132
+
133
+ class PureRandomPolicy:
134
+ """Pure random policy.
135
+
136
+ Always generates fresh randomness without enforcing any monotonicity constraints.
137
+ """
138
+
139
+ def resolve_randomness(
140
+ self,
141
+ timestamp: int,
142
+ randomness_source: RandomnessSource,
143
+ ) -> bytes:
144
+ return randomness_source(timestamp)
145
+
146
+
147
+ class LaxMonotonicPolicy(BaseMonotonicPolicy):
148
+ """Lax monotonicity policy.
149
+
150
+ Increments the randomness by 1 if generated within the same millisecond.
151
+ If the randomness overflows, it regenerates fresh randomness instead of raising
152
+ an error or sleeping.
153
+ """
154
+
155
+ @override
156
+ def _on_overflow(
157
+ self,
158
+ timestamp: int,
159
+ randomness_source: RandomnessSource,
160
+ ) -> bytes:
161
+ return randomness_source(timestamp)
162
+
163
+
164
+ class ULIDGenerator:
165
+ """Generator for creating universally unique lexicographically sortable identifiers (ULIDs).
166
+
167
+ Samples a clock for the timestamp, sources entropy for the randomness, and enforces a
168
+ :class:`MonotonicityPolicy` so that identifiers generated within the same millisecond are
169
+ monotonically increasing. Generation is guarded by a lock and is safe to share across
170
+ threads.
171
+
172
+ Args:
173
+ clock: A callable returning the current time in milliseconds. Defaults to the system
174
+ clock.
175
+ randomness: A callable that, given a timestamp, returns fresh random bytes for the
176
+ randomness component. Defaults to :func:`os.urandom`.
177
+ policy: The :class:`MonotonicityPolicy` used to resolve randomness on same-millisecond
178
+ collisions. Defaults to :class:`StrictMonotonicPolicy`.
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ clock: Callable[[], int] | None = None,
184
+ randomness: RandomnessSource | None = None,
185
+ policy: MonotonicityPolicy | None = None,
186
+ ) -> None:
187
+ self.lock = Lock()
188
+ self.clock = clock or self._default_clock
189
+ self.randomness_source = randomness or self._default_randomness
190
+ self.policy = policy or StrictMonotonicPolicy()
191
+
192
+ @staticmethod
193
+ def _default_clock() -> int:
194
+ return time.time_ns() // constants.NANOSECS_IN_MILLISECS
195
+
196
+ @staticmethod
197
+ def _default_randomness(_timestamp: int) -> bytes:
198
+ return os.urandom(constants.RANDOMNESS_LEN)
199
+
200
+ def _normalize_timestamp(self, value: float | datetime | None = None) -> int:
68
201
  if value is None:
69
- value = time.time_ns() // constants.NANOSECS_IN_MILLISECS
202
+ value = self.clock()
203
+ elif isinstance(value, datetime):
204
+ value = int(value.timestamp() * constants.MILLISECS_IN_SECS)
70
205
  elif isinstance(value, float):
71
206
  value = int(value * constants.MILLISECS_IN_SECS)
72
207
  if value > constants.MAX_TIMESTAMP:
73
208
  raise ValueError("Value exceeds maximum possible timestamp")
74
209
  return value
75
210
 
76
- def randomness(self, current_timestamp: int | None = None) -> bytes:
211
+ def generate(self, timestamp: float | datetime | None = None) -> ULID:
212
+ """Generate a new :class:`ULID` monotonically.
213
+
214
+ Args:
215
+ timestamp (int, float, datetime, None): Optional timestamp to set on the ULID.
216
+
217
+ Returns:
218
+ ULID: A generated ULID.
219
+ """
220
+ ts = self._normalize_timestamp(timestamp)
221
+
77
222
  with self.lock:
78
- if current_timestamp is None:
79
- current_timestamp = self.timestamp()
80
- if current_timestamp == self.prev_timestamp:
81
- if self.prev_randomness == constants.MAX_RANDOMNESS:
82
- raise ValueError("Randomness within same millisecond exhausted")
83
- randomness = self.increment_bytes(self.prev_randomness)
84
- else:
85
- randomness = os.urandom(constants.RANDOMNESS_LEN)
223
+ randomness = self.policy.resolve_randomness(
224
+ ts,
225
+ self.randomness_source,
226
+ )
227
+
228
+ ts_bytes = int.to_bytes(ts, constants.TIMESTAMP_LEN, "big")
229
+ return ULID.from_bytes(ts_bytes + randomness)
86
230
 
87
- self.prev_randomness = randomness
88
- self.prev_timestamp = current_timestamp
89
- return randomness
90
231
 
91
- def increment_bytes(self, value: bytes) -> bytes:
92
- length = len(value)
93
- return (int.from_bytes(value, byteorder="big") + 1).to_bytes(length, byteorder="big")
232
+ #: The module-level generator used by ``ULID()`` and the ``ULID.from_*`` constructors.
233
+ #: Reassign it to route the default constructors through a custom
234
+ #: :class:`ULIDGenerator` (e.g. a different clock, randomness source, or
235
+ #: :class:`MonotonicityPolicy`)::
236
+ #:
237
+ #: ulid.default_generator = ULIDGenerator(policy=LaxMonotonicPolicy())
238
+ default_generator = ULIDGenerator()
94
239
 
95
240
 
96
241
  @functools.total_ordering
97
242
  class ULID:
98
- provider = ValueProvider()
99
-
100
243
  """The :class:`ULID` object consists of a timestamp part of 48 bits and of 80 random bits.
101
244
 
102
245
  .. code-block:: text
@@ -121,13 +264,18 @@ class ULID:
121
264
  ValueError: If the provided value is not a valid encoded ULID.
122
265
  """
123
266
 
124
- def __init__(self, value: bytes | None = None) -> None:
267
+ def __init__(
268
+ self,
269
+ value: bytes | None = None,
270
+ ) -> None:
125
271
  if value is not None and len(value) != constants.BYTES_LEN:
126
272
  raise ValueError("ULID has to be exactly 16 bytes long.")
127
- self.bytes: bytes = value or ULID.from_timestamp(self.provider.timestamp()).bytes
273
+ if value is None:
274
+ self.bytes: bytes = default_generator.generate().bytes
275
+ else:
276
+ self.bytes = value
128
277
 
129
278
  @classmethod
130
- @validate_type(datetime)
131
279
  def from_datetime(cls, value: datetime) -> Self:
132
280
  """Create a new :class:`ULID`-object from a :class:`datetime`. The timestamp part of the
133
281
  `ULID` will be set to the corresponding timestamp of the datetime.
@@ -138,10 +286,11 @@ class ULID:
138
286
  >>> ULID.from_datetime(datetime.now())
139
287
  ULID(01E75QRYCAMM1MKQ9NYMYT6SAV)
140
288
  """
141
- return cls.from_timestamp(value.timestamp())
289
+ if not isinstance(value, datetime):
290
+ raise TypeError("Value has to be of type datetime")
291
+ return cls.from_bytes(default_generator.generate(value).bytes)
142
292
 
143
293
  @classmethod
144
- @validate_type(int, float)
145
294
  def from_timestamp(cls, value: float) -> Self:
146
295
  """Create a new :class:`ULID`-object from a timestamp. The timestamp can be either a
147
296
  `float` representing the time in seconds (as it would be returned by :func:`time.time()`)
@@ -153,13 +302,11 @@ class ULID:
153
302
  >>> ULID.from_timestamp(time.time())
154
303
  ULID(01E75QWN5HKQ0JAVX9FG1K4YP4)
155
304
  """
156
- timestamp_value = cls.provider.timestamp(value)
157
- timestamp = int.to_bytes(timestamp_value, constants.TIMESTAMP_LEN, "big")
158
- randomness = cls.provider.randomness(timestamp_value)
159
- return cls.from_bytes(timestamp + randomness)
305
+ if not isinstance(value, (int, float)):
306
+ raise TypeError("Value has to be of type int or float")
307
+ return cls.from_bytes(default_generator.generate(value).bytes)
160
308
 
161
309
  @classmethod
162
- @validate_type(uuid.UUID)
163
310
  def from_uuid(cls, value: uuid.UUID) -> Self:
164
311
  """Create a new :class:`ULID`-object from a :class:`uuid.UUID`. The timestamp part will be
165
312
  random in that case.
@@ -170,30 +317,36 @@ class ULID:
170
317
  >>> ULID.from_uuid(uuid4())
171
318
  ULID(27Q506DP7E9YNRXA0XVD8Z5YSG)
172
319
  """
320
+ if not isinstance(value, uuid.UUID):
321
+ raise TypeError("Value has to be of type UUID")
173
322
  return cls(value.bytes)
174
323
 
175
324
  @classmethod
176
- @validate_type(bytes)
177
325
  def from_bytes(cls, bytes_: bytes) -> Self:
178
326
  """Create a new :class:`ULID`-object from sequence of 16 bytes."""
327
+ if not isinstance(bytes_, bytes):
328
+ raise TypeError("Value has to be of type bytes")
179
329
  return cls(bytes_)
180
330
 
181
331
  @classmethod
182
- @validate_type(str)
183
332
  def from_hex(cls, value: str) -> Self:
184
333
  """Create a new :class:`ULID`-object from 32 character string of hex values."""
334
+ if not isinstance(value, str):
335
+ raise TypeError("Value has to be of type str")
185
336
  return cls.from_bytes(bytes.fromhex(value))
186
337
 
187
338
  @classmethod
188
- @validate_type(str)
189
339
  def from_str(cls, string: str) -> Self:
190
340
  """Create a new :class:`ULID`-object from a 26 char long string representation."""
341
+ if not isinstance(string, str):
342
+ raise TypeError("Value has to be of type str")
191
343
  return cls(base32.decode(string))
192
344
 
193
345
  @classmethod
194
- @validate_type(int)
195
346
  def from_int(cls, value: int) -> Self:
196
347
  """Create a new :class:`ULID`-object from an `int`."""
348
+ if not isinstance(value, int):
349
+ raise TypeError("Value has to be of type int")
197
350
  return cls(int.to_bytes(value, constants.BYTES_LEN, "big"))
198
351
 
199
352
  @classmethod
@@ -303,9 +456,9 @@ class ULID:
303
456
  Examples:
304
457
 
305
458
  >>> ulid = ULID()
306
- >>> uuid7 = ulid.to_uuidv7() # Perfect round-trip
307
- >>> assert ULID.from_uuidv7(uuid7) == ulid
308
- >>> uuid7_compliant = ulid.to_uuidv7(compliant=True) # RFC 4122 compliant
459
+ >>> uuid7 = ulid.to_uuid7() # Perfect round-trip
460
+ >>> assert ULID.from_uuid7(uuid7) == ulid
461
+ >>> uuid7_compliant = ulid.to_uuid7(compliant=True) # RFC 4122 compliant
309
462
  >>> uuid7_compliant.version
310
463
  7
311
464
  """
@@ -334,21 +487,22 @@ class ULID:
334
487
  return uuid.UUID(bytes=uuid_bytes)
335
488
 
336
489
  @classmethod
337
- @validate_type(uuid.UUID)
338
- def from_uuidv7(cls, value: uuid.UUID) -> Self:
490
+ def from_uuid7(cls, value: uuid.UUID) -> Self:
339
491
  """Create a new :class:`ULID` from a UUIDv7 (:class:`uuid.UUID` version 7).
340
492
 
341
493
  Extracts the timestamp from the UUIDv7's first 48 bits (milliseconds since epoch)
342
494
  and the remaining 80 bits as randomness. The timestamp is always transparently
343
- preserved, providing perfect round-trip conversion with :meth:`to_uuidv7`.
495
+ preserved, providing perfect round-trip conversion with :meth:`to_uuid7`.
344
496
 
345
497
  Examples:
346
498
 
347
499
  >>> uuid7 = uuid.UUID("01936c5e-f4c0-7000-8000-000000000000")
348
- >>> ulid = ULID.from_uuidv7(uuid7)
500
+ >>> ulid = ULID.from_uuid7(uuid7)
349
501
  >>> ulid.datetime
350
502
  datetime.datetime(2025, 11, 10, ...)
351
503
  """
504
+ if not isinstance(value, uuid.UUID):
505
+ raise TypeError("Value has to be of type UUID")
352
506
  uuid_int = int.from_bytes(value.bytes, byteorder="big")
353
507
 
354
508
  # Extract timestamp from UUIDv7 layout (always in first 48 bits)
@@ -1,10 +0,0 @@
1
- ulid/__init__.py,sha256=MokfVXGP3iguVkHuzaJvLeFytWAqe_jAqrQqzWYDbRs,16352
2
- ulid/__main__.py,sha256=ZP8ZbhQ_o-O4Ckbba87bZ4NkwzrSFhAkrRLmUnzkW0Y,5586
3
- ulid/base32.py,sha256=iUE5BGPW5Dul-Eu3ITvcymk8acNkIFAVTlrL12YIPKI,5961
4
- ulid/constants.py,sha256=lbCLRNQXq8qnOdz_w-nJf_j5dC2X7Gjv-8GJUbs5jX8,503
5
- ulid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- python_ulid-3.2.1.dist-info/METADATA,sha256=_Ug09XKRumD9YAhF2abhShiUXJLRjfkh8HebBZPQBb8,6591
7
- python_ulid-3.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
- python_ulid-3.2.1.dist-info/entry_points.txt,sha256=9214ghzfSgS8a-TjjlWDF7bI7KSaeewOCBKn5uz7bdU,50
9
- python_ulid-3.2.1.dist-info/licenses/LICENSE,sha256=DiDPNhW5yUHIqaKowvX0c1c9xWiI0xW4ji8R9N2NJbw,1056
10
- python_ulid-3.2.1.dist-info/RECORD,,