python-ulid 3.0.0__py3-none-any.whl → 3.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: python-ulid
3
- Version: 3.0.0
3
+ Version: 3.2.0
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
@@ -18,12 +18,14 @@ Classifier: License :: OSI Approved :: MIT License
18
18
  Classifier: Operating System :: MacOS :: MacOS X
19
19
  Classifier: Operating System :: POSIX :: Linux
20
20
  Classifier: Programming Language :: Python
21
- Classifier: Programming Language :: Python :: 3.9
22
21
  Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
25
26
  Classifier: Topic :: Software Development :: Libraries
26
- Requires-Python: >=3.9
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: typing-extensions; python_version < '3.11'
27
29
  Provides-Extra: pydantic
28
30
  Requires-Dist: pydantic>=2.0; extra == 'pydantic'
29
31
  Description-Content-Type: text/x-rst
@@ -39,6 +41,7 @@ A ``ULID`` is a *universally unique lexicographically sortable identifier*. It i
39
41
  * Uses Crockford's base32 for better efficiency and readability (5 bits per character)
40
42
  * Case insensitive
41
43
  * No special characters (URL safe)
44
+ * Monotonic sort order (correctly detects and handles the same millisecond)
42
45
 
43
46
  In general the structure of a ULID is as follows:
44
47
 
@@ -147,6 +150,20 @@ The ``ULID`` class can be directly used for the popular data validation library
147
150
 
148
151
  .. pydantic-end
149
152
 
153
+ .. monotonic-begin
154
+
155
+ Monotonic Support
156
+ -----------------
157
+
158
+ This library by default supports the implementation for monotonic sort order suggested by the
159
+ official ULID specification.
160
+
161
+ This means that ULID values generated in the same millisecond will have linear increasing randomness
162
+ values. If :math:`r_1` and :math:`r_2` are the randomness values of two ULIDs with the same
163
+ timestamp, then :math:`r_2 = r_1 + 1`.
164
+
165
+ .. monotonic-end
166
+
150
167
  .. cli-begin
151
168
 
152
169
  Command line interface
@@ -204,3 +221,9 @@ Other implementations
204
221
  * `ulid/javascript <https://github.com/ulid/javascript>`_
205
222
  * `RobThree/NUlid <https://github.com/RobThree/NUlid>`_
206
223
  * `imdario/go-ulid <https://github.com/imdario/go-ulid>`_
224
+
225
+ Contributions
226
+ -------------
227
+
228
+ Contributions are welcome! Feel free to create pull-requests for issues or feature requests.
229
+ It might be worth creating an issue upfront to discuss the matter.
@@ -0,0 +1,10 @@
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.0.dist-info/METADATA,sha256=4wbtd2VPXZ2fiKQx7lZexKFypftpTlj3qfoWbtN5bUw,6591
7
+ python_ulid-3.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ python_ulid-3.2.0.dist-info/entry_points.txt,sha256=9214ghzfSgS8a-TjjlWDF7bI7KSaeewOCBKn5uz7bdU,50
9
+ python_ulid-3.2.0.dist-info/licenses/LICENSE,sha256=DiDPNhW5yUHIqaKowvX0c1c9xWiI0xW4ji8R9N2NJbw,1056
10
+ python_ulid-3.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.25.0
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
ulid/__init__.py CHANGED
@@ -6,6 +6,7 @@ import time
6
6
  import uuid
7
7
  from datetime import datetime
8
8
  from datetime import timezone
9
+ from threading import Lock
9
10
  from typing import Any
10
11
  from typing import cast
11
12
  from typing import Generic
@@ -17,12 +18,18 @@ from ulid import constants
17
18
 
18
19
 
19
20
  if TYPE_CHECKING: # pragma: no cover
21
+ import sys
20
22
  from collections.abc import Callable
21
23
 
22
24
  from pydantic import GetCoreSchemaHandler
23
25
  from pydantic import ValidatorFunctionWrapHandler
24
26
  from pydantic_core import CoreSchema
25
27
 
28
+ if sys.version_info >= (3, 11):
29
+ from typing import Self
30
+ else:
31
+ from typing_extensions import Self
32
+
26
33
  try:
27
34
  from importlib.metadata import version
28
35
  except ImportError: # pragma: no cover
@@ -31,12 +38,12 @@ except ImportError: # pragma: no cover
31
38
 
32
39
  __version__ = version("python-ulid")
33
40
 
34
- T = TypeVar("T", bound=type)
41
+ T = TypeVar("T")
35
42
  R = TypeVar("R")
36
43
 
37
44
 
38
45
  class validate_type(Generic[T]): # noqa: N801
39
- def __init__(self, *types: T) -> None:
46
+ def __init__(self, *types: type[T]) -> None:
40
47
  self.types = types
41
48
 
42
49
  def __call__(self, func: Callable[..., R]) -> Callable[..., R]:
@@ -51,11 +58,45 @@ class validate_type(Generic[T]): # noqa: N801
51
58
  return wrapped
52
59
 
53
60
 
54
- U = TypeVar("U", bound="ULID")
61
+ class ValueProvider:
62
+ def __init__(self) -> None:
63
+ self.lock = Lock()
64
+ self.prev_timestamp = constants.MIN_TIMESTAMP
65
+ self.prev_randomness = constants.MIN_RANDOMNESS
66
+
67
+ def timestamp(self, value: float | None = None) -> int:
68
+ if value is None:
69
+ value = time.time_ns() // constants.NANOSECS_IN_MILLISECS
70
+ elif isinstance(value, float):
71
+ value = int(value * constants.MILLISECS_IN_SECS)
72
+ if value > constants.MAX_TIMESTAMP:
73
+ raise ValueError("Value exceeds maximum possible timestamp")
74
+ return value
75
+
76
+ def randomness(self, current_timestamp: int | None = None) -> bytes:
77
+ 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)
86
+
87
+ self.prev_randomness = randomness
88
+ self.prev_timestamp = current_timestamp
89
+ return randomness
90
+
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")
55
94
 
56
95
 
57
96
  @functools.total_ordering
58
97
  class ULID:
98
+ provider = ValueProvider()
99
+
59
100
  """The :class:`ULID` object consists of a timestamp part of 48 bits and of 80 random bits.
60
101
 
61
102
  .. code-block:: text
@@ -83,13 +124,11 @@ class ULID:
83
124
  def __init__(self, value: bytes | None = None) -> None:
84
125
  if value is not None and len(value) != constants.BYTES_LEN:
85
126
  raise ValueError("ULID has to be exactly 16 bytes long.")
86
- self.bytes: bytes = (
87
- value or ULID.from_timestamp(time.time_ns() // constants.NANOSECS_IN_MILLISECS).bytes
88
- )
127
+ self.bytes: bytes = value or ULID.from_timestamp(self.provider.timestamp()).bytes
89
128
 
90
129
  @classmethod
91
130
  @validate_type(datetime)
92
- def from_datetime(cls: type[U], value: datetime) -> U:
131
+ def from_datetime(cls, value: datetime) -> Self:
93
132
  """Create a new :class:`ULID`-object from a :class:`datetime`. The timestamp part of the
94
133
  `ULID` will be set to the corresponding timestamp of the datetime.
95
134
 
@@ -103,7 +142,7 @@ class ULID:
103
142
 
104
143
  @classmethod
105
144
  @validate_type(int, float)
106
- def from_timestamp(cls: type[U], value: float) -> U:
145
+ def from_timestamp(cls, value: float) -> Self:
107
146
  """Create a new :class:`ULID`-object from a timestamp. The timestamp can be either a
108
147
  `float` representing the time in seconds (as it would be returned by :func:`time.time()`)
109
148
  or an `int` in milliseconds.
@@ -114,15 +153,14 @@ class ULID:
114
153
  >>> ULID.from_timestamp(time.time())
115
154
  ULID(01E75QWN5HKQ0JAVX9FG1K4YP4)
116
155
  """
117
- if isinstance(value, float):
118
- value = int(value * constants.MILLISECS_IN_SECS)
119
- timestamp = int.to_bytes(value, constants.TIMESTAMP_LEN, "big")
120
- randomness = os.urandom(constants.RANDOMNESS_LEN)
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)
121
159
  return cls.from_bytes(timestamp + randomness)
122
160
 
123
161
  @classmethod
124
162
  @validate_type(uuid.UUID)
125
- def from_uuid(cls: type[U], value: uuid.UUID) -> U:
163
+ def from_uuid(cls, value: uuid.UUID) -> Self:
126
164
  """Create a new :class:`ULID`-object from a :class:`uuid.UUID`. The timestamp part will be
127
165
  random in that case.
128
166
 
@@ -136,37 +174,38 @@ class ULID:
136
174
 
137
175
  @classmethod
138
176
  @validate_type(bytes)
139
- def from_bytes(cls: type[U], bytes_: bytes) -> U:
177
+ def from_bytes(cls, bytes_: bytes) -> Self:
140
178
  """Create a new :class:`ULID`-object from sequence of 16 bytes."""
141
179
  return cls(bytes_)
142
180
 
143
181
  @classmethod
144
182
  @validate_type(str)
145
- def from_hex(cls: type[U], value: str) -> U:
183
+ def from_hex(cls, value: str) -> Self:
146
184
  """Create a new :class:`ULID`-object from 32 character string of hex values."""
147
185
  return cls.from_bytes(bytes.fromhex(value))
148
186
 
149
187
  @classmethod
150
188
  @validate_type(str)
151
- def from_str(cls: type[U], string: str) -> U:
189
+ def from_str(cls, string: str) -> Self:
152
190
  """Create a new :class:`ULID`-object from a 26 char long string representation."""
153
191
  return cls(base32.decode(string))
154
192
 
155
193
  @classmethod
156
194
  @validate_type(int)
157
- def from_int(cls: type[U], value: int) -> U:
195
+ def from_int(cls, value: int) -> Self:
158
196
  """Create a new :class:`ULID`-object from an `int`."""
159
197
  return cls(int.to_bytes(value, constants.BYTES_LEN, "big"))
160
198
 
161
199
  @classmethod
162
- def parse(cls: type[U], value: Any) -> U:
200
+ def parse(cls, value: Any) -> Self:
163
201
  """Create a new :class:`ULID`-object from a given value.
164
202
 
165
- .. note:: This method should only be used when the caller is trying to parse a ULID from
166
- a value when they're unsure what format/primitive type it will be given in.
203
+ .. note::
204
+ This method should only be used when the caller is trying to parse a ULID from
205
+ a value when they're unsure what format/primitive type it will be given in.
167
206
  """
168
207
  if isinstance(value, ULID):
169
- return cast(U, value)
208
+ return cast("Self", value)
170
209
  if isinstance(value, uuid.UUID):
171
210
  return cls.from_uuid(value)
172
211
  if isinstance(value, str):
@@ -190,7 +229,7 @@ class ULID:
190
229
  return cls.from_bytes(value)
191
230
  raise TypeError(f"Cannot parse ULID from type {type(value)}")
192
231
 
193
- @property
232
+ @functools.cached_property
194
233
  def milliseconds(self) -> int:
195
234
  """The timestamp part as epoch time in milliseconds.
196
235
 
@@ -201,7 +240,7 @@ class ULID:
201
240
  """
202
241
  return int.from_bytes(self.bytes[: constants.TIMESTAMP_LEN], byteorder="big")
203
242
 
204
- @property
243
+ @functools.cached_property
205
244
  def timestamp(self) -> float:
206
245
  """The timestamp part as epoch time in seconds.
207
246
 
@@ -212,7 +251,7 @@ class ULID:
212
251
  """
213
252
  return self.milliseconds / constants.MILLISECS_IN_SECS
214
253
 
215
- @property
254
+ @functools.cached_property
216
255
  def datetime(self) -> datetime:
217
256
  """Return the timestamp part as timezone-aware :class:`datetime` in UTC.
218
257
 
@@ -223,7 +262,7 @@ class ULID:
223
262
  """
224
263
  return datetime.fromtimestamp(self.timestamp, timezone.utc)
225
264
 
226
- @property
265
+ @functools.cached_property
227
266
  def hex(self) -> str:
228
267
  """Encode the :class:`ULID`-object as a 32 char sequence of hex values."""
229
268
  return self.bytes.hex()
@@ -248,6 +287,84 @@ class ULID:
248
287
  """
249
288
  return uuid.UUID(bytes=self.bytes, version=4)
250
289
 
290
+ def to_uuid7(self, *, compliant: bool = False) -> uuid.UUID:
291
+ """Convert the :class:`ULID` to a UUIDv7 (:class:`uuid.UUID` version 7).
292
+
293
+ UUIDv7 encodes a Unix timestamp in milliseconds in the first 48 bits (just like ULID).
294
+ The timestamp is always transparently preserved regardless of compliant mode.
295
+
296
+ Args:
297
+ compliant: If True, sets RFC 4122 version (0x7) and variant (0b10) bits,
298
+ losing 6 bits of randomness. If False (default), preserves all 80 bits
299
+ of randomness by clobbering version/variant bits, enabling perfect
300
+ round-trip conversion. Most tools (PostgreSQL, standard libraries)
301
+ accept non-compliant UUIDv7s.
302
+
303
+ Examples:
304
+
305
+ >>> 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
309
+ >>> uuid7_compliant.version
310
+ 7
311
+ """
312
+ # ULID: [48 bits timestamp_ms][80 bits randomness]
313
+ # UUIDv7: [48 bits timestamp_ms][4 bits ver][12 bits rand_a][2 bits var][62 bits rand_b]
314
+
315
+ timestamp_ms = self.milliseconds
316
+
317
+ # Get the 80 bits of randomness from ULID
318
+ randomness_bits = int.from_bytes(self.bytes[6:], byteorder="big")
319
+
320
+ if compliant:
321
+ # RFC 4122 compliant: set version and variant bits, losing 6 bits of randomness
322
+ # Extract 74 bits of randomness (losing 6 bits for version/variant)
323
+ rand_a = (randomness_bits >> 68) & 0xFFF # Top 12 bits
324
+ rand_b = randomness_bits & ((1 << 62) - 1) # Bottom 62 bits
325
+
326
+ # Build UUIDv7: [48-bit timestamp_ms][4-bit version][12-bit rand_a][2-bit variant][62-bit rand_b]
327
+ uuid_int = (timestamp_ms << 80) | (0x7 << 76) | (rand_a << 64) | (0x2 << 62) | rand_b
328
+ else:
329
+ # Non-compliant: preserve all 80 bits of randomness for perfect round-trip
330
+ # Build UUIDv7: [48-bit timestamp_ms][80-bit randomness] (clobbers version/variant)
331
+ uuid_int = (timestamp_ms << 80) | randomness_bits
332
+
333
+ uuid_bytes = uuid_int.to_bytes(16, byteorder="big")
334
+ return uuid.UUID(bytes=uuid_bytes)
335
+
336
+ @classmethod
337
+ @validate_type(uuid.UUID)
338
+ def from_uuidv7(cls, value: uuid.UUID) -> Self:
339
+ """Create a new :class:`ULID` from a UUIDv7 (:class:`uuid.UUID` version 7).
340
+
341
+ Extracts the timestamp from the UUIDv7's first 48 bits (milliseconds since epoch)
342
+ and the remaining 80 bits as randomness. The timestamp is always transparently
343
+ preserved, providing perfect round-trip conversion with :meth:`to_uuidv7`.
344
+
345
+ Examples:
346
+
347
+ >>> uuid7 = uuid.UUID("01936c5e-f4c0-7000-8000-000000000000")
348
+ >>> ulid = ULID.from_uuidv7(uuid7)
349
+ >>> ulid.datetime
350
+ datetime.datetime(2025, 11, 10, ...)
351
+ """
352
+ uuid_int = int.from_bytes(value.bytes, byteorder="big")
353
+
354
+ # Extract timestamp from UUIDv7 layout (always in first 48 bits)
355
+ # Bits 0-47: timestamp_ms (48 bits)
356
+ timestamp_ms = uuid_int >> 80
357
+
358
+ # Extract all 80 bits after the timestamp (bits 48-127) as randomness
359
+ # This includes version/variant bits if present, enabling perfect round-trip
360
+ randomness_bits = uuid_int & ((1 << 80) - 1)
361
+
362
+ # Build ULID bytes: [48-bit timestamp][80-bit randomness]
363
+ timestamp_bytes = timestamp_ms.to_bytes(6, byteorder="big")
364
+ randomness_bytes = randomness_bits.to_bytes(10, byteorder="big")
365
+
366
+ return cls.from_bytes(timestamp_bytes + randomness_bytes)
367
+
251
368
  def __repr__(self) -> str:
252
369
  return f"ULID({self!s})"
253
370
 
@@ -297,7 +414,11 @@ class ULID:
297
414
  core_schema.union_schema([
298
415
  core_schema.is_instance_schema(ULID),
299
416
  core_schema.no_info_plain_validator_function(ULID),
300
- core_schema.str_schema(pattern=r"[A-Z0-9]{26}", min_length=26, max_length=26),
417
+ core_schema.str_schema(
418
+ pattern=rf"[0-7][{base32.ENCODE}]{{25}}",
419
+ min_length=26,
420
+ max_length=26,
421
+ ),
301
422
  core_schema.bytes_schema(min_length=16, max_length=16),
302
423
  ]),
303
424
  serialization=core_schema.to_string_ser_schema(
@@ -309,6 +430,7 @@ class ULID:
309
430
  def _pydantic_validate(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any:
310
431
  from pydantic_core import PydanticCustomError
311
432
 
433
+ ulid: ULID
312
434
  try:
313
435
  if isinstance(value, int):
314
436
  ulid = cls.from_int(value)
ulid/__main__.py CHANGED
@@ -89,6 +89,7 @@ def make_parser(prog: str | None = None) -> argparse.ArgumentParser:
89
89
  s.add_argument("ulid", help="the ULID to inspect. The special value - reads from stdin")
90
90
  s.add_argument("--uuid", action="store_true", help="convert to fully random UUID")
91
91
  s.add_argument("--uuid4", action="store_true", help="convert to RFC 4122 compliant UUIDv4")
92
+ s.add_argument("--uuid7", action="store_true", help="convert to RFC 4122 compliant UUIDv7")
92
93
  s.add_argument("--hex", action="store_true", help="convert to hex")
93
94
  s.add_argument("--int", action="store_true", help="convert to int")
94
95
  s.add_argument("--timestamp", "--ts", action="store_true", help="show timestamp")
@@ -141,6 +142,8 @@ def show(args: argparse.Namespace) -> str:
141
142
  return str(ulid.to_uuid())
142
143
  if args.uuid4:
143
144
  return str(ulid.to_uuid4())
145
+ if args.uuid7:
146
+ return str(ulid.to_uuid7(compliant=True))
144
147
  if args.hex:
145
148
  return ulid.hex
146
149
  if args.int:
ulid/constants.py CHANGED
@@ -1,6 +1,12 @@
1
1
  MILLISECS_IN_SECS = 1000
2
2
  NANOSECS_IN_MILLISECS = 1000000
3
3
 
4
+ MIN_RANDOMNESS = b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
5
+ MAX_RANDOMNESS = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"
6
+
7
+ MIN_TIMESTAMP = 0
8
+ MAX_TIMESTAMP = 2**48 - 1
9
+
4
10
  TIMESTAMP_LEN = 6
5
11
  RANDOMNESS_LEN = 10
6
12
  BYTES_LEN = TIMESTAMP_LEN + RANDOMNESS_LEN
@@ -1,10 +0,0 @@
1
- ulid/__init__.py,sha256=yoBtXpTz-t8xnfOHtM70-hYIeYKk75ya7t6B6uWILhg,10971
2
- ulid/__main__.py,sha256=TUo3dkWkJLfzRJyd55nfqGqvv1NcG7fBpsZHIi_p4rw,5421
3
- ulid/base32.py,sha256=iUE5BGPW5Dul-Eu3ITvcymk8acNkIFAVTlrL12YIPKI,5961
4
- ulid/constants.py,sha256=tJByGuryoTT_xfECvpMNPNwRvZMp2K_ZlZ7tOLeaXJs,335
5
- ulid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- python_ulid-3.0.0.dist-info/METADATA,sha256=82s8fmElXLy5Um_bjqhtZfiXd54YF0TDI2iOlKliLMs,5782
7
- python_ulid-3.0.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
8
- python_ulid-3.0.0.dist-info/entry_points.txt,sha256=9214ghzfSgS8a-TjjlWDF7bI7KSaeewOCBKn5uz7bdU,50
9
- python_ulid-3.0.0.dist-info/licenses/LICENSE,sha256=DiDPNhW5yUHIqaKowvX0c1c9xWiI0xW4ji8R9N2NJbw,1056
10
- python_ulid-3.0.0.dist-info/RECORD,,