Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ This repository continues the `overflowdigital` `cuid2` package (`b02c8ca`); ver

## [Unreleased]

### Changed

- `Cuid`, `Cuid.generate`, `create_id`, and `cuid_wrapper` now enforce the CUID2 specification length bounds (`2..32`, i.e., `MIN_LENGTH`..`BIG_LENGTH`) and raise `ValueError` for length `1` and lengths `> 32` (previously a `FutureWarning` in 2.3.0 and `DeprecationWarning` in 2.0.3–2.2.1); the `cuid2` CLI likewise rejects lengths outside `2..32` with exit code 2.
- `Cuid`, `Cuid.generate`, `create_id`, and `cuid_wrapper` now raise `TypeError` for any non-integer `length` (such as a `float`, `Decimal`, `Fraction`, or `numpy.bool_`) when it is passed. Previously an in-range value such as `10.0` was accepted and failed on the first generated ID, and an out-of-range float such as `0.5` or `100.0` raised `ValueError`. Integer-like values such as `numpy` integers and `IntEnum` members are still accepted.
- `is_cuid` (and `Cuid.is_cuid` and `cuid2 --check`) now defaults `max_length` to `BIG_LENGTH` (`32`, down from `98`, matching the upstream `@paralleldrive/cuid2` `isCuid` default) and clamps `max_length` to `32`.

### Removed

- `MAXIMUM_LENGTH` (`98`) from `cuid2`, `cuid2.generator`, and `cuid2.utils`; use `BIG_LENGTH` (`32`) for the maximum specification length.
- Out-of-spec length `FutureWarning` deprecation machinery from the library and CLI.

**Upgrade note:** Callers passing `length=1` or `length > 32` (up to `98`), or importing `MAXIMUM_LENGTH` must either update lengths to the CUID2 specification range (`2..32`) and replace `MAXIMUM_LENGTH` with `BIG_LENGTH` (`32`), or pin `cuid2<3` until ready to migrate. `is_cuid()` does not raise for IDs longer than 32 characters: it returns `False` even when `max_length` is greater than `32`, and `cuid2 --check` exits with code 1 (`cuid2 --check -l N` with `N` greater than `32` exits with code 2), so a service that validates stored legacy IDs would start rejecting them silently; validate those IDs with your own check (for example `re.fullmatch(r"[a-z][0-9a-z]+", s)` plus your own length bound) or pin `cuid2<3`. `Cuid(...)` and `cuid_wrapper(...)` validate `length` when constructed, so an out-of-range length in a module-level declaration (for example a Django model default in `models.py`) now raises `ValueError` at import time. Existing Django migrations that serialize `functools.partial(cuid2.generator.create_id, ...)` with a serialized `'length': N` where `N` is outside `2..32` (search migration files for `create_id`) must be edited to a length in `2..32`, otherwise operations that evaluate the default, such as `AddField` during `migrate` or test-database creation, raise `ValueError`.

## [2.3.0] - 2026-11-07

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ short_cuid: str = create_id(length=10)
print(is_cuid(my_cuid)) # True
```

The CUID2 specification allows lengths 2..32; lengths `1` and `33`–`98` are still accepted in 2.x but emit a `FutureWarning` (shown as `cuid2: warning: ...` by the CLI), and 3.0.0 will reject them with `ValueError`.
The CUID2 specification allows lengths 2..32 (`MIN_LENGTH` to `BIG_LENGTH`); lengths outside `2..32` raise `ValueError` (and exit with code 2 on the CLI).

> **Tip for Django & ORM defaults:** Pass `default=create_id` directly on model fields (e.g., `models.CharField(max_length=24, default=create_id)`). Because `create_id` is a top-level function backed by a lazy singleton generator, Django `makemigrations` can serialize it directly and `Cuid()` initialization is deferred until the first ID is generated in each worker process.

Expand Down Expand Up @@ -125,7 +125,7 @@ def main() -> None:
```

`is_cuid` (and `Cuid.is_cuid`) also accepts optional `min_length` and `max_length` arguments to tighten the allowed length.
The bounds are clamped to 2 and 98 characters, so they can narrow but never widen what is considered valid.
The bounds are clamped to 2 and 32 characters (`MIN_LENGTH` and `BIG_LENGTH`), so they can narrow but never widen what is considered valid.

## Changelog

Expand Down
163 changes: 45 additions & 118 deletions local/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@
import io
import os
import runpy
import warnings
from typing import TYPE_CHECKING

import pytest

import cuid2.cli
import cuid2.generator
import cuid2.utils
from cuid2 import DEFAULT_LENGTH, MAXIMUM_LENGTH, create_id, is_cuid
from cuid2.cli import _console_main as console_main
from cuid2 import BIG_LENGTH, DEFAULT_LENGTH, MIN_LENGTH, create_id, is_cuid
from cuid2.cli import main as cli_main

if TYPE_CHECKING:
Expand Down Expand Up @@ -44,24 +42,16 @@ def test_cli_module_execution(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Tests that running python -m cuid2 invokes the console entry point, including its warning format."""
"""Tests that running python -m cuid2 invokes the console entry point."""
monkeypatch.setattr("sys.argv", ["cuid2", "--slug"])
runpy.run_module("cuid2", run_name="__main__", alter_sys=True)
out = capsys.readouterr().out.strip()
assert is_cuid(out, min_length=5, max_length=5) is True

monkeypatch.setattr("sys.argv", ["cuid2", "40"])
with warnings.catch_warnings():
warnings.simplefilter("default", FutureWarning)
runpy.run_module("cuid2", run_name="__main__", alter_sys=True)
captured = capsys.readouterr()
assert captured.err.startswith("cuid2: warning: Length 40 outside the CUID2 specification range")
assert is_cuid(captured.out.strip(), min_length=40, max_length=40) is True

def test_cli_console_script_entry_point(self: TestCli) -> None:
"""Tests that the installed cuid2 console script targets the warning-formatting entry wrapper."""
"""Tests that the installed cuid2 console script targets cuid2.cli.main."""
(entry_point,) = importlib.metadata.entry_points(group="console_scripts", name="cuid2")
assert entry_point.load() is cuid2.cli._console_main
assert entry_point.load() is cuid2.cli.main

@pytest.mark.parametrize("flag", ["-h", "--help"])
def test_cli_help(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None:
Expand All @@ -74,6 +64,8 @@ def test_cli_help(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str])
"usage: cuid2",
"24",
"2..32",
"Generation",
"validation",
"--slug",
"--count",
"--fingerprint",
Expand Down Expand Up @@ -109,6 +101,8 @@ def test_cli_lowercase_v_is_not_version(self: TestCli, capsys: pytest.CaptureFix
(["10"], 10),
(["32"], 32),
(["-l", "10"], 10),
(["-l", "2"], MIN_LENGTH),
(["-l", "32"], BIG_LENGTH),
(["--length", "16"], 16),
(["--slug"], 5),
],
Expand All @@ -125,47 +119,27 @@ def test_cli_custom_length(
assert is_cuid(captured.out.strip(), min_length=expected_length, max_length=expected_length) is True
assert captured.err == ""

@pytest.mark.parametrize(
("args", "length"),
[
(["1"], 1),
(["-l", "33"], 33),
([str(MAXIMUM_LENGTH)], MAXIMUM_LENGTH),
],
)
def test_cli_boundary_lengths(
self: TestCli,
args: list[str],
length: int,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Tests that the CLI emits FutureWarning as a cuid2: warning: line and prints a CUID for boundary lengths."""
with warnings.catch_warnings():
warnings.simplefilter("default", FutureWarning)
console_main(args)
captured = capsys.readouterr()
assert captured.err == (
f"cuid2: warning: Length {length} outside the CUID2 specification range (2..32) "
"is deprecated and will raise ValueError in v3.0.0.\n"
)
output = captured.out.strip()
assert len(output) <= length
if length <= 50:
assert len(output) == length
assert output[0].isalpha()

@pytest.mark.parametrize(
("args", "expected_substring"),
[
(["0"], "length must be at least 1 character"),
(["-5"], "length must be at least 1 character"),
(["--", "-5"], "length must be at least 1 character"),
(["-l", "-5"], "length must be at least 1 character"),
([str(MAXIMUM_LENGTH + 1)], "length must never exceed 98 characters"),
(["-l", "0"], "length must be at least 1 character"),
(["--length", str(MAXIMUM_LENGTH + 1)], "length must never exceed 98 characters"),
(["invalid"], "invalid int value"),
(["-l", "abc"], "invalid int value"),
(["1"], "length must be at least 2 characters"),
(["-l", "1"], "length must be at least 2 characters"),
(["0"], "length must be at least 2 characters"),
(["-l", "0"], "length must be at least 2 characters"),
(["-5"], "length must be at least 2 characters"),
(["--", "-5"], "length must be at least 2 characters"),
(["-l", "-5"], "length must be at least 2 characters"),
(["--length=-5"], "length must be at least 2 characters"),
(["--check", "ab", "-l", "1"], "length must be at least 2 characters"),
(["--check", "ab", "1"], "length must be at least 2 characters"),
([str(BIG_LENGTH + 1)], "length must never exceed 32 characters"),
(["-l", str(BIG_LENGTH + 1)], "length must never exceed 32 characters"),
(["--length", str(BIG_LENGTH + 1)], "length must never exceed 32 characters"),
(["98"], "length must never exceed 32 characters"),
(["99"], "length must never exceed 32 characters"),
(["--check", "ab", "-l", "40"], "length must never exceed 32 characters"),
(["invalid"], "invalid int value:"),
(["-l", "abc"], "invalid int value:"),
],
)
def test_cli_invalid_lengths(
Expand Down Expand Up @@ -215,12 +189,13 @@ def test_cli_batch_count(
assert len(set(lines)) == 5
assert all(is_cuid(item, min_length=DEFAULT_LENGTH, max_length=DEFAULT_LENGTH) is True for item in lines)

with warnings.catch_warnings():
warnings.simplefilter("default", FutureWarning)
console_main(["-l", "33", "-n", "3"])
cli_main(["-l", "12", "-n", "3"])
captured = capsys.readouterr()
assert captured.err.count("cuid2: warning:") == 1
assert len(captured.out.splitlines()) == 3
batch = captured.out.splitlines()
assert len(batch) == 3
assert len(set(batch)) == 3
assert all(is_cuid(item, min_length=12, max_length=12) is True for item in batch)
assert captured.err == ""

@pytest.mark.parametrize(
("args", "expected_substring"),
Expand Down Expand Up @@ -272,8 +247,7 @@ def test_cli_check_direct_argument(self: TestCli, capsys: pytest.CaptureFixture[
"""Tests --check and --validate with direct candidate arguments and optional length constraints."""
valid_id = create_id()
short_id = create_id(length=10)
with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range"):
long_id = create_id(length=40)
big_id = create_id(length=BIG_LENGTH)

for flag in ("--check", "--validate"):
with pytest.raises(SystemExit) as exc_info:
Expand Down Expand Up @@ -312,63 +286,22 @@ def test_cli_check_direct_argument(self: TestCli, capsys: pytest.CaptureFixture[
assert exc_info.value.code == 1
assert capsys.readouterr().out == f"{valid_id}: invalid\n"

with warnings.catch_warnings():
warnings.simplefilter("error", FutureWarning)
with pytest.raises(SystemExit) as exc_info:
cli_main(["--check", long_id, "-l", "40"])
assert exc_info.value.code == 0
assert capsys.readouterr().out == f"{long_id}: valid\n"

@pytest.mark.parametrize(("stderr_case", "expected_writes"), [("missing", 0), ("write_raises", 1)])
def test_cli_out_of_spec_warning_without_usable_stderr(
self: TestCli,
stderr_case: str,
expected_writes: int,
monkeypatch: pytest.MonkeyPatch,
mocker: Mock,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Tests that a missing or failing sys.stderr drops the out-of-spec warning and still prints the CUID."""
broken_stderr = io.StringIO()
write_mock = mocker.patch.object(broken_stderr, "write", side_effect=BrokenPipeError)
with monkeypatch.context() as patch, warnings.catch_warnings():
patch.setattr("sys.stderr", None if stderr_case == "missing" else broken_stderr)
warnings.simplefilter("default", FutureWarning)
console_main(["40"])
assert is_cuid(capsys.readouterr().out.strip(), min_length=40, max_length=40) is True
assert write_mock.call_count == expected_writes

def test_cli_main_leaves_warning_delivery_to_caller(self: TestCli, capsys: pytest.CaptureFixture[str]) -> None:
"""Tests that main() emits a standard FutureWarning that callers can capture, and writes nothing to stderr."""
with pytest.warns(FutureWarning, match=r"Length 40 outside the CUID2 specification range") as record:
cli_main(["40"])
assert len(record) == 1
captured = capsys.readouterr()
assert captured.err == ""
assert is_cuid(captured.out.strip(), min_length=40, max_length=40) is True

def test_cli_out_of_spec_warning_respects_filters(self: TestCli, capsys: pytest.CaptureFixture[str]) -> None:
"""Tests that the console entry point honors warning filters and never leaks its warning output override."""
original = warnings.showwarning
with pytest.raises(SystemExit) as exc_info:
cli_main(["--check", big_id, "-l", str(BIG_LENGTH)])
assert exc_info.value.code == 0
assert capsys.readouterr().out == f"{big_id}: valid\n"

with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
console_main(["40"])
assert warnings.showwarning is original
with pytest.raises(SystemExit) as exc_info:
cli_main(["--check", big_id])
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert captured.out == f"{big_id}: valid\n"
assert captured.err == ""
assert is_cuid(captured.out.strip(), min_length=40, max_length=40) is True

with warnings.catch_warnings():
warnings.simplefilter("error", FutureWarning)
with pytest.raises(SystemExit) as exc_info:
console_main(["40"])
assert warnings.showwarning is original
captured = capsys.readouterr()
assert exc_info.value.code == 2
assert captured.out == ""
assert "cuid2: error: Length 40 outside the CUID2 specification range" in captured.err
assert "Traceback" not in captured.err
with pytest.raises(SystemExit) as exc_info:
cli_main(["--check", "a" * (BIG_LENGTH + 1)])
assert exc_info.value.code == 1
assert capsys.readouterr().out == f"{'a' * (BIG_LENGTH + 1)}: invalid\n"

def test_cli_check_stdin_and_quiet(
self: TestCli,
Expand Down Expand Up @@ -444,12 +377,6 @@ def test_cli_check_invalid_flag_combinations(
assert exc_info.value.code == 2
assert "not allowed with --check/--validate" in capsys.readouterr().err

for length_args in (["-l", "1"], ["1"]):
with pytest.raises(SystemExit) as exc_info:
cli_main(["--check", "a", *length_args])
assert exc_info.value.code == 2
assert "argument --check/--validate: length must be at least 2 characters" in capsys.readouterr().err

def test_cli_broken_pipe(
self: TestCli,
monkeypatch: pytest.MonkeyPatch,
Expand Down
Loading
Loading