python-ulid 3.2.1__tar.gz → 4.0.1__tar.gz

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.
Files changed (33) hide show
  1. {python_ulid-3.2.1 → python_ulid-4.0.1}/AGENTS.md +12 -0
  2. {python_ulid-3.2.1 → python_ulid-4.0.1}/CHANGELOG.rst +48 -0
  3. python_ulid-4.0.1/CONTEXT.md +34 -0
  4. {python_ulid-3.2.1 → python_ulid-4.0.1}/PKG-INFO +59 -1
  5. {python_ulid-3.2.1 → python_ulid-4.0.1}/README.rst +58 -0
  6. python_ulid-4.0.1/docs/source/api.rst +52 -0
  7. {python_ulid-3.2.1 → python_ulid-4.0.1}/docs/source/index.rst +4 -0
  8. {python_ulid-3.2.1 → python_ulid-4.0.1}/tests/test_ulid.py +117 -9
  9. {python_ulid-3.2.1 → python_ulid-4.0.1}/ulid/__init__.py +213 -59
  10. python_ulid-3.2.1/docs/source/api.rst +0 -13
  11. {python_ulid-3.2.1 → python_ulid-4.0.1}/.envrc +0 -0
  12. {python_ulid-3.2.1 → python_ulid-4.0.1}/.github/workflows/lint-and-test.yml +0 -0
  13. {python_ulid-3.2.1 → python_ulid-4.0.1}/.github/workflows/publish.yml +0 -0
  14. {python_ulid-3.2.1 → python_ulid-4.0.1}/.gitignore +0 -0
  15. {python_ulid-3.2.1 → python_ulid-4.0.1}/.pre-commit-config.yaml +0 -0
  16. {python_ulid-3.2.1 → python_ulid-4.0.1}/.readthedocs.yml +0 -0
  17. {python_ulid-3.2.1 → python_ulid-4.0.1}/.ruff_defaults.toml +0 -0
  18. {python_ulid-3.2.1 → python_ulid-4.0.1}/CONTRIBUTING.md +0 -0
  19. {python_ulid-3.2.1 → python_ulid-4.0.1}/LICENSE +0 -0
  20. {python_ulid-3.2.1 → python_ulid-4.0.1}/docs/Makefile +0 -0
  21. {python_ulid-3.2.1 → python_ulid-4.0.1}/docs/requirements.txt +0 -0
  22. {python_ulid-3.2.1 → python_ulid-4.0.1}/docs/source/changelog.rst +0 -0
  23. {python_ulid-3.2.1 → python_ulid-4.0.1}/docs/source/conf.py +0 -0
  24. {python_ulid-3.2.1 → python_ulid-4.0.1}/logo.png +0 -0
  25. {python_ulid-3.2.1 → python_ulid-4.0.1}/pyproject.toml +0 -0
  26. {python_ulid-3.2.1 → python_ulid-4.0.1}/tests/__init__.py +0 -0
  27. {python_ulid-3.2.1 → python_ulid-4.0.1}/tests/test_base32.py +0 -0
  28. {python_ulid-3.2.1 → python_ulid-4.0.1}/tests/test_cli.py +0 -0
  29. {python_ulid-3.2.1 → python_ulid-4.0.1}/ulid/__main__.py +0 -0
  30. {python_ulid-3.2.1 → python_ulid-4.0.1}/ulid/base32.py +0 -0
  31. {python_ulid-3.2.1 → python_ulid-4.0.1}/ulid/constants.py +0 -0
  32. {python_ulid-3.2.1 → python_ulid-4.0.1}/ulid/py.typed +0 -0
  33. {python_ulid-3.2.1 → python_ulid-4.0.1}/uv.lock +0 -0
@@ -46,6 +46,18 @@ We require full, strict type annotations across the entire codebase.
46
46
 
47
47
  ---
48
48
 
49
+ ## 📝 Changelog
50
+
51
+ We maintain a human-readable changelog in [CHANGELOG.rst](file:///Users/martin.domke/Source/private/ulid/CHANGELOG.rst) following the **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/)** conventions.
52
+
53
+ - **Section headings**: Group entries under the standard headings, in this order: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. Omit any section that has no entries.
54
+ - **Removals belong under `Removed`**: Do *not* fold removed or renamed public APIs into `Changed`.
55
+ - **Versioning**: Follow [Semantic Versioning](https://semver.org). A breaking change (e.g. a removed public API) requires a **major** version bump and a `.. warning::` admonition describing the migration path.
56
+ - **Entry format**: Each release has a ``` `X.Y.Z`_ - YYYY-MM-DD ``` heading plus a matching compare link at the bottom of the file (``.. _X.Y.Z: https://github.com/mdomke/python-ulid/compare/PREV...X.Y.Z``).
57
+ - **reStructuredText**: Keep lines within **100 characters** (enforced by `doc8`) and reference public symbols with Sphinx roles such as `:class:` and `:meth:` — the changelog is included into the rendered documentation.
58
+
59
+ ---
60
+
49
61
  ## 🛠️ Verification Workflow
50
62
 
51
63
  Before completing any task, you **MUST** run the verification commands to ensure no regressions or style issues are introduced.
@@ -5,6 +5,51 @@ Changelog
5
5
 
6
6
  Versions follow `Semantic Versioning <http://www.semver.org>`_
7
7
 
8
+ `4.0.1`_ - 2026-07-20
9
+ ---------------------
10
+ Changed
11
+ ~~~~~~~
12
+ * Renamed ``ULID.from_uuidv7`` to :meth:`.ULID.from_uuid7` for naming symmetry with
13
+ :meth:`.ULID.to_uuid7`, correcting an inconsistency introduced in 3.2.0.
14
+
15
+ `4.0.0`_ - 2026-07-20
16
+ ---------------------
17
+
18
+ .. warning::
19
+
20
+ **Breaking change:** the ``ValueProvider`` class and the ``ULID.provider`` class attribute
21
+ have been removed. ULID generation is now handled by the new :class:`.ULIDGenerator` together
22
+ with pluggable monotonicity policies. To customize generation, construct a
23
+ :class:`.ULIDGenerator` (optionally with a custom clock, randomness source, or policy) and
24
+ either call its ``generate()`` method or assign it to ``ulid.default_generator``.
25
+
26
+ Added
27
+ ~~~~~
28
+ * Added a public :class:`.ULIDGenerator` class that encapsulates ULID generation and can be
29
+ configured with a custom clock, randomness source, and monotonicity policy.
30
+ * Added pluggable monotonicity policies: :class:`.StrictMonotonicPolicy` (the default),
31
+ :class:`.LaxMonotonicPolicy` and :class:`.PureRandomPolicy`, together with the
32
+ :class:`.MonotonicityPolicy` protocol and the :class:`.BaseMonotonicPolicy` base class for
33
+ implementing custom policies.
34
+ * Added a module-level ``ulid.default_generator`` that can be reassigned to route ``ULID()`` and
35
+ the ``ULID.from_*`` constructors through a custom :class:`.ULIDGenerator`.
36
+
37
+ Removed
38
+ ~~~~~~~
39
+ * Removed the ``ValueProvider`` class and the ``ULID.provider`` attribute in favour of
40
+ :class:`.ULIDGenerator` and the monotonicity policies. Code that replaced ``ULID.provider`` or
41
+ subclassed ``ValueProvider`` must migrate to a custom :class:`.ULIDGenerator` assigned to
42
+ ``ulid.default_generator``.
43
+ * Removed the internal ``validate_type`` decorator. The ``ULID.from_*`` constructors still raise
44
+ ``TypeError`` for arguments of the wrong type, so runtime behaviour is unchanged.
45
+
46
+ `3.2.1`_ - 2026-07-17
47
+ ---------------------
48
+ Fixed
49
+ ~~~~~
50
+ * Corrected the build and publish pipeline and the generated source distribution. This release
51
+ contains no changes to the library code.
52
+
8
53
  `3.2.0`_ - 2026-07-17
9
54
  ---------------------
10
55
  Added
@@ -220,6 +265,9 @@ Changed
220
265
  * The package now has no external dependencies.
221
266
  * The test-coverage has been raised to 100%.
222
267
 
268
+ .. _4.0.1: https://github.com/mdomke/python-ulid/compare/4.0.0...4.0.1
269
+ .. _4.0.0: https://github.com/mdomke/python-ulid/compare/3.2.1...4.0.0
270
+ .. _3.2.1: https://github.com/mdomke/python-ulid/compare/3.2.0...3.2.1
223
271
  .. _3.2.0: https://github.com/mdomke/python-ulid/compare/3.1.0...3.2.0
224
272
  .. _3.1.0: https://github.com/mdomke/python-ulid/compare/3.0.0...3.1.0
225
273
  .. _3.0.0: https://github.com/mdomke/python-ulid/compare/2.7.0...3.0.0
@@ -0,0 +1,34 @@
1
+ # Domain Glossary & Context: python-ulid
2
+
3
+ Welcome to the architectural domain reference for the `python-ulid` project. This document outlines the ubiquitous language, core domain entities, and structural concepts of this implementation.
4
+
5
+ ---
6
+
7
+ ## Core Concepts & Vocabulary
8
+
9
+ ### ULID
10
+ * **Definition**: A Universally Unique Lexicographically Sortable Identifier.
11
+ * **Format**: A 128-bit value consisting of:
12
+ * **Timestamp**: 48 bits, representing epoch time in milliseconds.
13
+ * **Randomness**: 80 bits, representing high-entropy random generation.
14
+ * **Representation**: Encoded as a 26-character Base32 string.
15
+ * **Nature**: Act as an immutable, hashable **Value Object**.
16
+
17
+ ### ULIDGenerator
18
+ * **Definition**: A deep, stateful generator responsible for orchestrating the creation of new `ULID` identifiers.
19
+ * **Responsibilities**:
20
+ * Sampling system or injected clocks for the **Timestamp**.
21
+ * Sourcing entropy for the **Randomness**.
22
+ * Tracking state and enforcing **Monotonicity** rules.
23
+ * Guaranteeing thread-safe generation across execution contexts.
24
+
25
+ ### Monotonicity & Policies
26
+ * **Definition**: The deterministic ordering property where multiple ULID instances generated within the exact same millisecond resolve their randomness component to prevent sorting collisions.
27
+ * **MonotonicityPolicy**: An extensible interface (seam) for configuring generator behavior:
28
+ * **StrictMonotonicPolicy**: Always increments the randomness by 1 under same-millisecond collisions, raising an overflow error if randomness is exhausted.
29
+ * **PureRandomPolicy**: Ignores previous states and always generates fresh random bytes, maximizing security and entropy.
30
+ * **LaxMonotonicPolicy**: Increments monotonically, but on same-millisecond randomness exhaustion, regenerates fresh randomness instead of raising an error or sleeping.
31
+
32
+ ### Base32 Engine
33
+ * **Definition**: Crockford's Base32 translation layer.
34
+ * **Nature**: Encodes/decodes binary representation using a restricted alphabet of 32 characters (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`), omitting ambiguous characters like `I`, `L`, `O`, and `U` to guarantee maximum readability.
@@ -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
@@ -165,6 +165,64 @@ timestamp, then :math:`r_2 = r_1 + 1`.
165
165
 
166
166
  .. monotonic-end
167
167
 
168
+ .. generators-begin
169
+
170
+ Generators and policies
171
+ -----------------------
172
+
173
+ Every ``ULID`` is produced by a ``ULIDGenerator``, which samples a clock for the timestamp,
174
+ sources entropy for the randomness, and enforces a *monotonicity policy*. The bare ``ULID()``
175
+ constructor and the ``ULID.from_*`` factory methods delegate to a shared module-level
176
+ ``default_generator``.
177
+
178
+ For most use cases the default is all you need. To customize generation — a different clock, a
179
+ custom entropy source, or another monotonicity policy — create your own ``ULIDGenerator`` and
180
+ call ``generate()``
181
+
182
+ .. code-block:: pycon
183
+
184
+ >>> from ulid import ULIDGenerator
185
+ >>> generator = ULIDGenerator()
186
+ >>> generator.generate()
187
+ ULID(01HB0N8Q4RCE7YB1M2VZK9WX3T)
188
+
189
+ Choosing a monotonicity policy
190
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
191
+
192
+ A policy decides how the randomness is resolved when multiple ULIDs are generated within the same
193
+ millisecond. Three policies are available:
194
+
195
+ * ``StrictMonotonicPolicy`` *(default)* — increments the randomness by 1 on a same-millisecond
196
+ collision and raises ``ValueError`` if the randomness is exhausted. This is the behaviour
197
+ described under `Monotonic Support`_.
198
+ * ``LaxMonotonicPolicy`` — increments like the strict policy, but regenerates fresh randomness
199
+ instead of raising when the randomness is exhausted.
200
+ * ``PureRandomPolicy`` — ignores previous state and always draws fresh randomness, maximizing
201
+ entropy at the cost of same-millisecond sort order.
202
+
203
+ .. code-block:: pycon
204
+
205
+ >>> from ulid import ULIDGenerator, PureRandomPolicy
206
+ >>> generator = ULIDGenerator(policy=PureRandomPolicy())
207
+ >>> generator.generate()
208
+ ULID(01HB0N9F3TA5KDQ6ZE0WYV7MRC)
209
+
210
+ Overriding the default generator
211
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
212
+
213
+ To make ``ULID()`` and the ``ULID.from_*`` constructors use a custom generator globally, reassign
214
+ ``ulid.default_generator``
215
+
216
+ .. code-block:: pycon
217
+
218
+ >>> import ulid
219
+ >>> from ulid import ULID, ULIDGenerator, LaxMonotonicPolicy
220
+ >>> ulid.default_generator = ULIDGenerator(policy=LaxMonotonicPolicy())
221
+ >>> ULID() # now generated with the lax policy
222
+ ULID(01HB0NB7X2M4C8VKQ0ZF5WD9RA)
223
+
224
+ .. generators-end
225
+
168
226
  .. cli-begin
169
227
 
170
228
  Command line interface
@@ -0,0 +1,52 @@
1
+ .. _api:
2
+
3
+ Developer Interface
4
+ ===================
5
+
6
+ .. module:: ulid
7
+
8
+
9
+ ULID
10
+ ----
11
+
12
+ .. autoclass:: ULID
13
+ :members:
14
+
15
+
16
+ Generators
17
+ ----------
18
+
19
+ Every :class:`ULID` is produced by a :class:`ULIDGenerator`. The bare :class:`ULID` constructor
20
+ and the ``ULID.from_*`` factory methods delegate to a shared module-level
21
+ :data:`default_generator`. Create your own :class:`ULIDGenerator` to customize the clock, the
22
+ randomness source, or the :class:`MonotonicityPolicy`.
23
+
24
+ .. autoclass:: ULIDGenerator
25
+ :members:
26
+
27
+ .. autodata:: default_generator
28
+ :no-value:
29
+
30
+
31
+ Monotonicity policies
32
+ ---------------------
33
+
34
+ A monotonicity policy decides how the randomness component is resolved when several ULIDs are
35
+ generated within the same millisecond. Pass an instance to :class:`ULIDGenerator`. Any object
36
+ satisfying the :class:`MonotonicityPolicy` protocol can be used; stateful policies can subclass
37
+ :class:`BaseMonotonicPolicy` and only implement the overflow behaviour.
38
+
39
+ .. autoclass:: MonotonicityPolicy
40
+ :members:
41
+
42
+ .. autoclass:: BaseMonotonicPolicy
43
+ :members:
44
+
45
+ .. autoclass:: StrictMonotonicPolicy
46
+ :members:
47
+
48
+ .. autoclass:: LaxMonotonicPolicy
49
+ :members:
50
+
51
+ .. autoclass:: PureRandomPolicy
52
+ :members:
@@ -24,6 +24,10 @@ Release v\ |release| (:ref:`What's new <changelog>`)
24
24
  :start-after: monotonic-begin
25
25
  :end-before: monotonic-end
26
26
 
27
+ .. include:: ../../README.rst
28
+ :start-after: generators-begin
29
+ :end-before: generators-end
30
+
27
31
  .. include:: ../../README.rst
28
32
  :start-after: cli-begin
29
33
  :end-before: cli-end
@@ -12,9 +12,14 @@ from freezegun import freeze_time
12
12
  from pydantic import BaseModel
13
13
  from pydantic import ValidationError
14
14
 
15
+ import ulid as ulid_pkg
15
16
  from ulid import base32
16
17
  from ulid import constants
18
+ from ulid import LaxMonotonicPolicy
19
+ from ulid import PureRandomPolicy
20
+ from ulid import StrictMonotonicPolicy
17
21
  from ulid import ULID
22
+ from ulid import ULIDGenerator
18
23
 
19
24
 
20
25
  def utcnow() -> datetime:
@@ -80,10 +85,44 @@ def test_z_real_time_monotonic_sorting() -> None:
80
85
 
81
86
  @freeze_time()
82
87
  def test_same_millisecond_overflow() -> None:
83
- ULID.provider.prev_timestamp = ULID.provider.timestamp()
84
- ULID.provider.prev_randomness = constants.MAX_RANDOMNESS
88
+ generator = ULIDGenerator(randomness=lambda _: constants.MAX_RANDOMNESS)
89
+ generator.generate()
85
90
  with pytest.raises(ValueError, match="Randomness within same millisecond exhausted"):
86
- ULID()
91
+ generator.generate()
92
+
93
+
94
+ def test_generator_custom_clock_and_randomness() -> None:
95
+ custom_ts = 123456789
96
+ custom_rand = b"1234567890"
97
+ generator = ULIDGenerator(
98
+ clock=lambda: custom_ts,
99
+ randomness=lambda _: custom_rand,
100
+ )
101
+ ulid = generator.generate()
102
+ assert ulid.milliseconds == custom_ts
103
+ assert ulid.bytes[constants.TIMESTAMP_LEN :] == custom_rand
104
+
105
+
106
+ def test_generator_state_isolation() -> None:
107
+ # Ensure two generators do not share state
108
+ g1 = ULIDGenerator()
109
+ g2 = ULIDGenerator()
110
+
111
+ assert isinstance(g1.policy, StrictMonotonicPolicy)
112
+ assert isinstance(g2.policy, StrictMonotonicPolicy)
113
+
114
+ # Initially, both states should be untouched
115
+ assert g1.policy.prev_timestamp == constants.MIN_TIMESTAMP
116
+ assert g2.policy.prev_timestamp == constants.MIN_TIMESTAMP
117
+
118
+ # Generating with g1 should update g1's state, but g2 should remain untouched
119
+ g1.generate()
120
+ assert g1.policy.prev_timestamp != constants.MIN_TIMESTAMP
121
+ assert g2.policy.prev_timestamp == constants.MIN_TIMESTAMP
122
+
123
+ # Generating with g2 should update g2's state independently
124
+ g2.generate()
125
+ assert g2.policy.prev_timestamp != constants.MIN_TIMESTAMP
87
126
 
88
127
 
89
128
  def assert_sorted(seq: list[Any]) -> None:
@@ -122,7 +161,7 @@ def test_idempotency() -> None:
122
161
  assert ULID.from_bytes(ulid.bytes) == ulid
123
162
  assert ULID.from_str(str(ulid)) == ulid
124
163
  assert ULID.from_uuid(ulid.to_uuid()) == ulid
125
- assert ULID.from_uuidv7(ulid.to_uuid7(compliant=False)) == ulid
164
+ assert ULID.from_uuid7(ulid.to_uuid7(compliant=False)) == ulid
126
165
  assert ULID.from_int(int(ulid)) == ulid
127
166
  assert ULID.from_hex(ulid.hex) == ulid
128
167
  assert ULID.parse(ulid) == ulid
@@ -170,7 +209,7 @@ def test_uuid7_perfect_roundtrip() -> None:
170
209
  """Test perfect round-trip conversion with compliant=False."""
171
210
  ulid = ULID()
172
211
  uuid7 = ulid.to_uuid7(compliant=False)
173
- ulid_restored = ULID.from_uuidv7(uuid7)
212
+ ulid_restored = ULID.from_uuid7(uuid7)
174
213
  # Perfect round-trip: all 128 bits should be identical
175
214
  assert ulid_restored == ulid
176
215
  assert ulid_restored.bytes == ulid.bytes
@@ -181,7 +220,7 @@ def test_uuid7_compliant_roundtrip() -> None:
181
220
  """Test round-trip with compliant=True preserves timestamp but loses some randomness."""
182
221
  ulid = ULID()
183
222
  uuid7 = ulid.to_uuid7(compliant=True)
184
- ulid_restored = ULID.from_uuidv7(uuid7)
223
+ ulid_restored = ULID.from_uuid7(uuid7)
185
224
  # Timestamp should be perfectly preserved
186
225
  assert ulid_restored.milliseconds == ulid.milliseconds
187
226
  # Full ULID won't match due to lost randomness in version/variant bits (6 bits lost)
@@ -197,7 +236,7 @@ def test_uuid7_timestamp_preservation() -> None:
197
236
  # Test both compliant and non-compliant modes
198
237
  for compliant in [False, True]:
199
238
  uuid7 = ulid.to_uuid7(compliant=compliant)
200
- ulid_from_uuid7 = ULID.from_uuidv7(uuid7)
239
+ ulid_from_uuid7 = ULID.from_uuid7(uuid7)
201
240
 
202
241
  # Check timestamp is perfectly preserved (exact millisecond match)
203
242
  assert ulid_from_uuid7.milliseconds == ulid.milliseconds
@@ -232,7 +271,7 @@ def test_uuid7_same_millisecond() -> None:
232
271
 
233
272
  # Perfect round-trip for all
234
273
  for ulid, uuid7 in zip(ulids, uuids, strict=True):
235
- assert ULID.from_uuidv7(uuid7) == ulid
274
+ assert ULID.from_uuid7(uuid7) == ulid
236
275
 
237
276
 
238
277
  def test_from_uuid7_with_external_uuid() -> None:
@@ -250,7 +289,7 @@ def test_from_uuid7_with_external_uuid() -> None:
250
289
  uuid_int = (timestamp_ms << 80) | (0x7 << 76) | (rand_a << 64) | (0x2 << 62) | rand_b
251
290
  uuid7 = uuid.UUID(bytes=uuid_int.to_bytes(16, byteorder="big"))
252
291
 
253
- ulid = ULID.from_uuidv7(uuid7)
292
+ ulid = ULID.from_uuid7(uuid7)
254
293
 
255
294
  # Check timestamp is correctly extracted (should be exact since it's in milliseconds)
256
295
  assert ulid.milliseconds == timestamp_ms
@@ -400,3 +439,72 @@ def test_pydantic_protocol() -> None:
400
439
  assert {
401
440
  "type": "null",
402
441
  } in model_json_schema["properties"]["ulid"]["anyOf"]
442
+
443
+
444
+ def test_pure_random_policy() -> None:
445
+ # Ensure PureRandomPolicy generates non-monotonic fresh randomness
446
+ custom_ts = 123456789
447
+ rand1 = b"1" * constants.RANDOMNESS_LEN
448
+ rand2 = b"2" * constants.RANDOMNESS_LEN
449
+ rands = [rand1, rand2]
450
+
451
+ # Iterator to return our mocks
452
+ iterator = iter(rands)
453
+ generator = ULIDGenerator(
454
+ clock=lambda: custom_ts,
455
+ randomness=lambda _: next(iterator),
456
+ policy=PureRandomPolicy(),
457
+ )
458
+
459
+ ulid1 = generator.generate()
460
+ ulid2 = generator.generate()
461
+
462
+ assert ulid1.bytes[constants.TIMESTAMP_LEN :] == rand1
463
+ assert ulid2.bytes[constants.TIMESTAMP_LEN :] == rand2
464
+
465
+
466
+ def test_lax_monotonic_policy() -> None:
467
+ # Under LaxMonotonicPolicy, normal operations increase monotonically
468
+ custom_ts = 123456789
469
+ generator = ULIDGenerator(
470
+ clock=lambda: custom_ts,
471
+ policy=LaxMonotonicPolicy(),
472
+ )
473
+ ulid1 = generator.generate()
474
+ ulid2 = generator.generate()
475
+ assert ulid1 < ulid2
476
+
477
+ # Mocking same-millisecond overflow
478
+ # Set the state of the lax policy to MAX_RANDOMNESS
479
+ assert isinstance(generator.policy, LaxMonotonicPolicy)
480
+ generator.policy.prev_randomness = constants.MAX_RANDOMNESS
481
+
482
+ # Generating again should regenerate fresh randomness instead of raising ValueError or sleeping
483
+ ulid3 = generator.generate()
484
+ assert isinstance(ulid3, ULID)
485
+
486
+
487
+ def test_default_generator_is_strict() -> None:
488
+ # The default generator is public and enforces strict monotonicity out of the box
489
+ assert isinstance(ulid_pkg.default_generator, ULIDGenerator)
490
+ assert isinstance(ulid_pkg.default_generator.policy, StrictMonotonicPolicy)
491
+
492
+
493
+ def test_default_generator_swappable(monkeypatch: pytest.MonkeyPatch) -> None:
494
+ # Reassigning default_generator routes ULID() and the from_* constructors through it.
495
+ # monkeypatch restores the original default at teardown, keeping tests isolated.
496
+ custom_ts = 123456789
497
+ custom_rand = b"1234567890"
498
+ custom = ULIDGenerator(clock=lambda: custom_ts, randomness=lambda _: custom_rand)
499
+ monkeypatch.setattr(ulid_pkg, "default_generator", custom)
500
+
501
+ # ULID() draws both timestamp (via the clock) and randomness from the swapped generator
502
+ now_ulid = ULID()
503
+ assert now_ulid.milliseconds == custom_ts
504
+ assert now_ulid.bytes[constants.TIMESTAMP_LEN :] == custom_rand
505
+
506
+ # Explicit-timestamp constructors keep their own timestamp but still draw randomness
507
+ # from the swapped generator (each distinct timestamp yields fresh randomness).
508
+ assert ULID.from_timestamp(1000).bytes[constants.TIMESTAMP_LEN :] == custom_rand
509
+ assert ULID.from_datetime(utcnow()).bytes[constants.TIMESTAMP_LEN :] == custom_rand
510
+ assert ULID.parse(2000).bytes[constants.TIMESTAMP_LEN :] == custom_rand
@@ -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,13 +0,0 @@
1
- .. _api:
2
-
3
- Developer Interface
4
- ===================
5
-
6
- .. module:: ulid
7
-
8
-
9
- ULID
10
- ----
11
-
12
- .. autoclass:: ULID
13
- :members:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes