From 8a3b3698ba99df5b4518d0cbdffc90c40034ee0e Mon Sep 17 00:00:00 2001 From: Reyhaan S Date: Mon, 12 Oct 2026 20:37:06 +0530 Subject: [PATCH] fix(base32): accept lower case ULID strings when decoding The ULID spec and the README describe the string representation as case insensitive, and the DECODE table maps lower case letters, but the alphabet check added in 2.6.0 only allowed upper case characters. Check against both cases so ULID.from_str accepts lower case input again. --- CHANGELOG.rst | 8 ++++++++ tests/test_ulid.py | 7 +++++++ ulid/base32.py | 4 +++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e353870..8276cec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,13 @@ Changelog Versions follow `Semantic Versioning `_ +`Unreleased`_ +------------- +Fixed +~~~~~ +* :meth:`.ULID.from_str` accepts lower case strings again, as the ULID spec defines the string + representation as case insensitive. Lower case input has been rejected since 2.6.0. + `4.0.1`_ - 2026-07-20 --------------------- Changed @@ -265,6 +272,7 @@ Changed * The package now has no external dependencies. * The test-coverage has been raised to 100%. +.. _Unreleased: https://github.com/mdomke/python-ulid/compare/4.0.1...HEAD .. _4.0.1: https://github.com/mdomke/python-ulid/compare/4.0.0...4.0.1 .. _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 diff --git a/tests/test_ulid.py b/tests/test_ulid.py index 0cac156..3703cb4 100644 --- a/tests/test_ulid.py +++ b/tests/test_ulid.py @@ -177,6 +177,12 @@ def test_idempotency() -> None: assert ULID.parse(ulid.bytes) == ulid +def test_from_str_is_case_insensitive() -> None: + ulid = ULID() + assert ULID.from_str(str(ulid).lower()) == ulid + assert ULID.parse(str(ulid).lower()) == ulid + + def test_to_uuid4() -> None: ulid = ULID() uuid = ulid.to_uuid4() @@ -350,6 +356,7 @@ def test_ulid_from_timestamp_overflow() -> None: (ULID.from_str, "not-enough"), # invalid length (ULID.from_str, 123), # inavlid type (ULID.from_str, "notavalidulidnotavalidulid"), # invalid alphabet + (ULID.from_str, "0" * 25 + "u"), # excluded letter in lower case (ULID.from_str, "Z" * 26), # invalid timestamp (ULID.from_int, "not-an-int"), # invalid type (ULID.from_uuid, "not-a-uuid"), # invalid type diff --git a/ulid/base32.py b/ulid/base32.py index fb94031..2cbd532 100644 --- a/ulid/base32.py +++ b/ulid/base32.py @@ -7,6 +7,8 @@ # https://github.com/RobThree/NUlid/blob/89f5a9fc827d191ae5adafe42547575ed3a47723/NUlid/Ulid.cs#L168 ENCODE: str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +# The ULID spec defines the string representation as case insensitive. +DECODE_ALPHABET: str = ENCODE + ENCODE.lower() DECODE: Sequence[int] = [ 0xFF, 0xFF, @@ -194,7 +196,7 @@ def encode_randomness(binary: bytes) -> str: def decode(encoded: str) -> bytes: if len(encoded) != constants.REPR_LEN: raise ValueError("Encoded ULID has to be exactly 26 characters long.") - if any((c not in ENCODE) for c in encoded): + if any((c not in DECODE_ALPHABET) for c in encoded): raise ValueError(f"Encoded ULID can only consist of letters in {ENCODE}.") return decode_timestamp(encoded[: constants.TIMESTAMP_REPR_LEN]) + decode_randomness( encoded[constants.TIMESTAMP_REPR_LEN :]