Skip to content
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ We require full, strict type annotations across the entire codebase.

---

## 📝 Changelog

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.

- **Section headings**: Group entries under the standard headings, in this order: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. Omit any section that has no entries.
- **Removals belong under `Removed`**: Do *not* fold removed or renamed public APIs into `Changed`.
- **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.
- **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``).
- **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.

---

## 🛠️ Verification Workflow

Before completing any task, you **MUST** run the verification commands to ensure no regressions or style issues are introduced.
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@ Changelog

Versions follow `Semantic Versioning <http://www.semver.org>`_

`4.0.0`_ - 2026-07-20
---------------------

.. warning::

**Breaking change:** the ``ValueProvider`` class and the ``ULID.provider`` class attribute
have been removed. ULID generation is now handled by the new :class:`.ULIDGenerator` together
with pluggable monotonicity policies. To customize generation, construct a
:class:`.ULIDGenerator` (optionally with a custom clock, randomness source, or policy) and
either call its ``generate()`` method or assign it to ``ulid.default_generator``.

Added
~~~~~
* Added a public :class:`.ULIDGenerator` class that encapsulates ULID generation and can be
configured with a custom clock, randomness source, and monotonicity policy.
* Added pluggable monotonicity policies: :class:`.StrictMonotonicPolicy` (the default),
:class:`.LaxMonotonicPolicy` and :class:`.PureRandomPolicy`, together with the
:class:`.MonotonicityPolicy` protocol and the :class:`.BaseMonotonicPolicy` base class for
implementing custom policies.
* Added a module-level ``ulid.default_generator`` that can be reassigned to route ``ULID()`` and
the ``ULID.from_*`` constructors through a custom :class:`.ULIDGenerator`.

Removed
~~~~~~~
* Removed the ``ValueProvider`` class and the ``ULID.provider`` attribute in favour of
:class:`.ULIDGenerator` and the monotonicity policies. Code that replaced ``ULID.provider`` or
subclassed ``ValueProvider`` must migrate to a custom :class:`.ULIDGenerator` assigned to
``ulid.default_generator``.
* Removed the internal ``validate_type`` decorator. The ``ULID.from_*`` constructors still raise
``TypeError`` for arguments of the wrong type, so runtime behaviour is unchanged.

`3.2.1`_ - 2026-07-17
---------------------
Fixed
~~~~~
* Corrected the build and publish pipeline and the generated source distribution. This release
contains no changes to the library code.

`3.2.0`_ - 2026-07-17
---------------------
Added
Expand Down Expand Up @@ -220,6 +258,8 @@ Changed
* The package now has no external dependencies.
* The test-coverage has been raised to 100%.

.. _4.0.0: https://github.com/mdomke/python-ulid/compare/3.2.1...4.0.0
.. _3.2.1: https://github.com/mdomke/python-ulid/compare/3.2.0...3.2.1
.. _3.2.0: https://github.com/mdomke/python-ulid/compare/3.1.0...3.2.0
.. _3.1.0: https://github.com/mdomke/python-ulid/compare/3.0.0...3.1.0
.. _3.0.0: https://github.com/mdomke/python-ulid/compare/2.7.0...3.0.0
Expand Down
34 changes: 34 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Domain Glossary & Context: python-ulid

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.

---

## Core Concepts & Vocabulary

### ULID
* **Definition**: A Universally Unique Lexicographically Sortable Identifier.
* **Format**: A 128-bit value consisting of:
* **Timestamp**: 48 bits, representing epoch time in milliseconds.
* **Randomness**: 80 bits, representing high-entropy random generation.
* **Representation**: Encoded as a 26-character Base32 string.
* **Nature**: Act as an immutable, hashable **Value Object**.

### ULIDGenerator
* **Definition**: A deep, stateful generator responsible for orchestrating the creation of new `ULID` identifiers.
* **Responsibilities**:
* Sampling system or injected clocks for the **Timestamp**.
* Sourcing entropy for the **Randomness**.
* Tracking state and enforcing **Monotonicity** rules.
* Guaranteeing thread-safe generation across execution contexts.

### Monotonicity & Policies
* **Definition**: The deterministic ordering property where multiple ULID instances generated within the exact same millisecond resolve their randomness component to prevent sorting collisions.
* **MonotonicityPolicy**: An extensible interface (seam) for configuring generator behavior:
* **StrictMonotonicPolicy**: Always increments the randomness by 1 under same-millisecond collisions, raising an overflow error if randomness is exhausted.
* **PureRandomPolicy**: Ignores previous states and always generates fresh random bytes, maximizing security and entropy.
* **LaxMonotonicPolicy**: Increments monotonically, but on same-millisecond randomness exhaustion, regenerates fresh randomness instead of raising an error or sleeping.

### Base32 Engine
* **Definition**: Crockford's Base32 translation layer.
* **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.
58 changes: 58 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,64 @@ timestamp, then :math:`r_2 = r_1 + 1`.

.. monotonic-end

.. generators-begin

Generators and policies
-----------------------

Every ``ULID`` is produced by a ``ULIDGenerator``, which samples a clock for the timestamp,
sources entropy for the randomness, and enforces a *monotonicity policy*. The bare ``ULID()``
constructor and the ``ULID.from_*`` factory methods delegate to a shared module-level
``default_generator``.

For most use cases the default is all you need. To customize generation — a different clock, a
custom entropy source, or another monotonicity policy — create your own ``ULIDGenerator`` and
call ``generate()``

.. code-block:: pycon

>>> from ulid import ULIDGenerator
>>> generator = ULIDGenerator()
>>> generator.generate()
ULID(01HB0N8Q4RCE7YB1M2VZK9WX3T)

Choosing a monotonicity policy
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A policy decides how the randomness is resolved when multiple ULIDs are generated within the same
millisecond. Three policies are available:

* ``StrictMonotonicPolicy`` *(default)* — increments the randomness by 1 on a same-millisecond
collision and raises ``ValueError`` if the randomness is exhausted. This is the behaviour
described under `Monotonic Support`_.
* ``LaxMonotonicPolicy`` — increments like the strict policy, but regenerates fresh randomness
instead of raising when the randomness is exhausted.
* ``PureRandomPolicy`` — ignores previous state and always draws fresh randomness, maximizing
entropy at the cost of same-millisecond sort order.

.. code-block:: pycon

>>> from ulid import ULIDGenerator, PureRandomPolicy
>>> generator = ULIDGenerator(policy=PureRandomPolicy())
>>> generator.generate()
ULID(01HB0N9F3TA5KDQ6ZE0WYV7MRC)

Overriding the default generator
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

To make ``ULID()`` and the ``ULID.from_*`` constructors use a custom generator globally, reassign
``ulid.default_generator``

.. code-block:: pycon

>>> import ulid
>>> from ulid import ULID, ULIDGenerator, LaxMonotonicPolicy
>>> ulid.default_generator = ULIDGenerator(policy=LaxMonotonicPolicy())
>>> ULID() # now generated with the lax policy
ULID(01HB0NB7X2M4C8VKQ0ZF5WD9RA)

.. generators-end

.. cli-begin

Command line interface
Expand Down
39 changes: 39 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,42 @@ ULID

.. autoclass:: ULID
:members:


Generators
----------

Every :class:`ULID` is produced by a :class:`ULIDGenerator`. The bare :class:`ULID` constructor
and the ``ULID.from_*`` factory methods delegate to a shared module-level
:data:`default_generator`. Create your own :class:`ULIDGenerator` to customize the clock, the
randomness source, or the :class:`MonotonicityPolicy`.

.. autoclass:: ULIDGenerator
:members:

.. autodata:: default_generator
:no-value:


Monotonicity policies
---------------------

A monotonicity policy decides how the randomness component is resolved when several ULIDs are
generated within the same millisecond. Pass an instance to :class:`ULIDGenerator`. Any object
satisfying the :class:`MonotonicityPolicy` protocol can be used; stateful policies can subclass
:class:`BaseMonotonicPolicy` and only implement the overflow behaviour.

.. autoclass:: MonotonicityPolicy
:members:

.. autoclass:: BaseMonotonicPolicy
:members:

.. autoclass:: StrictMonotonicPolicy
:members:

.. autoclass:: LaxMonotonicPolicy
:members:

.. autoclass:: PureRandomPolicy
:members:
4 changes: 4 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Release v\ |release| (:ref:`What's new <changelog>`)
:start-after: monotonic-begin
:end-before: monotonic-end

.. include:: ../../README.rst
:start-after: generators-begin
:end-before: generators-end

.. include:: ../../README.rst
:start-after: cli-begin
:end-before: cli-end
Expand Down
114 changes: 111 additions & 3 deletions tests/test_ulid.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@
from pydantic import BaseModel
from pydantic import ValidationError

import ulid as ulid_pkg
from ulid import base32
from ulid import constants
from ulid import LaxMonotonicPolicy
from ulid import PureRandomPolicy
from ulid import StrictMonotonicPolicy
from ulid import ULID
from ulid import ULIDGenerator


def utcnow() -> datetime:
Expand Down Expand Up @@ -80,10 +85,44 @@ def test_z_real_time_monotonic_sorting() -> None:

@freeze_time()
def test_same_millisecond_overflow() -> None:
ULID.provider.prev_timestamp = ULID.provider.timestamp()
ULID.provider.prev_randomness = constants.MAX_RANDOMNESS
generator = ULIDGenerator(randomness=lambda _: constants.MAX_RANDOMNESS)
generator.generate()
with pytest.raises(ValueError, match="Randomness within same millisecond exhausted"):
ULID()
generator.generate()


def test_generator_custom_clock_and_randomness() -> None:
custom_ts = 123456789
custom_rand = b"1234567890"
generator = ULIDGenerator(
clock=lambda: custom_ts,
randomness=lambda _: custom_rand,
)
ulid = generator.generate()
assert ulid.milliseconds == custom_ts
assert ulid.bytes[constants.TIMESTAMP_LEN :] == custom_rand


def test_generator_state_isolation() -> None:
# Ensure two generators do not share state
g1 = ULIDGenerator()
g2 = ULIDGenerator()

assert isinstance(g1.policy, StrictMonotonicPolicy)
assert isinstance(g2.policy, StrictMonotonicPolicy)

# Initially, both states should be untouched
assert g1.policy.prev_timestamp == constants.MIN_TIMESTAMP
assert g2.policy.prev_timestamp == constants.MIN_TIMESTAMP

# Generating with g1 should update g1's state, but g2 should remain untouched
g1.generate()
assert g1.policy.prev_timestamp != constants.MIN_TIMESTAMP
assert g2.policy.prev_timestamp == constants.MIN_TIMESTAMP

# Generating with g2 should update g2's state independently
g2.generate()
assert g2.policy.prev_timestamp != constants.MIN_TIMESTAMP


def assert_sorted(seq: list[Any]) -> None:
Expand Down Expand Up @@ -400,3 +439,72 @@ class Model(BaseModel):
assert {
"type": "null",
} in model_json_schema["properties"]["ulid"]["anyOf"]


def test_pure_random_policy() -> None:
# Ensure PureRandomPolicy generates non-monotonic fresh randomness
custom_ts = 123456789
rand1 = b"1" * constants.RANDOMNESS_LEN
rand2 = b"2" * constants.RANDOMNESS_LEN
rands = [rand1, rand2]

# Iterator to return our mocks
iterator = iter(rands)
generator = ULIDGenerator(
clock=lambda: custom_ts,
randomness=lambda _: next(iterator),
policy=PureRandomPolicy(),
)

ulid1 = generator.generate()
ulid2 = generator.generate()

assert ulid1.bytes[constants.TIMESTAMP_LEN :] == rand1
assert ulid2.bytes[constants.TIMESTAMP_LEN :] == rand2


def test_lax_monotonic_policy() -> None:
# Under LaxMonotonicPolicy, normal operations increase monotonically
custom_ts = 123456789
generator = ULIDGenerator(
clock=lambda: custom_ts,
policy=LaxMonotonicPolicy(),
)
ulid1 = generator.generate()
ulid2 = generator.generate()
assert ulid1 < ulid2

# Mocking same-millisecond overflow
# Set the state of the lax policy to MAX_RANDOMNESS
assert isinstance(generator.policy, LaxMonotonicPolicy)
generator.policy.prev_randomness = constants.MAX_RANDOMNESS

# Generating again should regenerate fresh randomness instead of raising ValueError or sleeping
ulid3 = generator.generate()
assert isinstance(ulid3, ULID)


def test_default_generator_is_strict() -> None:
# The default generator is public and enforces strict monotonicity out of the box
assert isinstance(ulid_pkg.default_generator, ULIDGenerator)
assert isinstance(ulid_pkg.default_generator.policy, StrictMonotonicPolicy)


def test_default_generator_swappable(monkeypatch: pytest.MonkeyPatch) -> None:
# Reassigning default_generator routes ULID() and the from_* constructors through it.
# monkeypatch restores the original default at teardown, keeping tests isolated.
custom_ts = 123456789
custom_rand = b"1234567890"
custom = ULIDGenerator(clock=lambda: custom_ts, randomness=lambda _: custom_rand)
monkeypatch.setattr(ulid_pkg, "default_generator", custom)

# ULID() draws both timestamp (via the clock) and randomness from the swapped generator
now_ulid = ULID()
assert now_ulid.milliseconds == custom_ts
assert now_ulid.bytes[constants.TIMESTAMP_LEN :] == custom_rand

# Explicit-timestamp constructors keep their own timestamp but still draw randomness
# from the swapped generator (each distinct timestamp yields fresh randomness).
assert ULID.from_timestamp(1000).bytes[constants.TIMESTAMP_LEN :] == custom_rand
assert ULID.from_datetime(utcnow()).bytes[constants.TIMESTAMP_LEN :] == custom_rand
assert ULID.parse(2000).bytes[constants.TIMESTAMP_LEN :] == custom_rand
Loading