diff --git a/CHANGELOG.md b/CHANGELOG.md index 26bc2fc..86d9bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b90a4c8..61f9fba 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/local/tests/test_cli.py b/local/tests/test_cli.py index ef511c9..ec8f9e4 100644 --- a/local/tests/test_cli.py +++ b/local/tests/test_cli.py @@ -5,7 +5,6 @@ import io import os import runpy -import warnings from typing import TYPE_CHECKING import pytest @@ -13,8 +12,7 @@ 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: @@ -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: @@ -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", @@ -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), ], @@ -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( @@ -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"), @@ -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: @@ -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, @@ -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, diff --git a/local/tests/test_cuid.py b/local/tests/test_cuid.py index 57d9cb7..856b935 100644 --- a/local/tests/test_cuid.py +++ b/local/tests/test_cuid.py @@ -10,7 +10,9 @@ import sys import threading import time -import warnings +from decimal import Decimal +from enum import IntEnum +from fractions import Fraction from functools import partial from random import SystemRandom from timeit import repeat @@ -23,7 +25,6 @@ BIG_LENGTH, DEFAULT_LENGTH, INITIAL_COUNT_MAX, - MAXIMUM_LENGTH, MIN_LENGTH, Cuid, create_id, @@ -67,13 +68,13 @@ def test_import_time(self: TestCuid) -> None: match = pattern.search(stderr) assert match is not None, stderr cumulative_us.append(int(match.group(1))) - # A cold `import cuid2` (including its standard library imports) should take less than 10 ms locally. Shared + # A cold `import cuid2` (including its standard library imports) should take less than 15 ms locally. Shared # CI runners (CI=true) are slower and noisier: 25 ms on Linux, or 45 ms on macOS and Windows, whose runners # load modules up to about twice as slowly. if os.environ.get("CI", "").lower() == "true": limit_us = 25_000 if sys.platform.startswith("linux") else 45_000 else: - limit_us = 10_000 + limit_us = 15_000 assert min(cumulative_us) < limit_us # Tests that generate() returns a string of length DEFAULT_LENGTH. @@ -91,63 +92,72 @@ def test_generate_length_none(self: TestCuid) -> None: cuid = Cuid(length=10) assert len(cuid.generate(length=None)) == 10 - # Tests that generate() works when length parameter is really long. - def test_generate_really_long_length(self: TestCuid) -> None: - for invalid_length in (99, 100): - with pytest.raises(ValueError, match=r"Length must never exceed 98 characters\."): - Cuid(length=invalid_length) - cuid = Cuid() - with pytest.raises(ValueError, match=r"Length must never exceed 98 characters\."): - cuid.generate(length=invalid_length) - - # Tests that Cuid and generate() raise ValueError when length is less than 1 or a boolean. - @pytest.mark.parametrize("invalid_length", [0, -1, -5, True, False]) - def test_generate_non_positive_length(self: TestCuid, invalid_length: int) -> None: - with pytest.raises(ValueError, match=r"Length must be at least 1 character\."): + # Tests that Cuid and generate() raise ValueError when length is greater than BIG_LENGTH (32). + @pytest.mark.parametrize("invalid_length", [BIG_LENGTH + 1, 50, 98, 99, 100]) + def test_generate_length_above_maximum(self: TestCuid, invalid_length: int) -> None: + with pytest.raises(ValueError, match=r"Length must never exceed 32 characters\."): Cuid(length=invalid_length) cuid = Cuid() - with pytest.raises(ValueError, match=r"Length must be at least 1 character\."): + with pytest.raises(ValueError, match=r"Length must never exceed 32 characters\."): cuid.generate(length=invalid_length) - # Tests that generate() works when length parameter is less than 2. - def test_generate_length_less_than_2(self: TestCuid) -> None: + # Tests that Cuid and generate() raise ValueError when length is less than 2 or a boolean. + @pytest.mark.parametrize("invalid_length", [1, 0, -1, -5, True, False]) + def test_generate_length_below_minimum(self: TestCuid, invalid_length: int) -> None: + with pytest.raises(ValueError, match=r"Length must be at least 2 characters\."): + Cuid(length=invalid_length) cuid = Cuid() - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as record: - assert len(cuid.generate(length=1)) == 1 - assert record[0].filename == __file__ - - # Tests that lengths outside MIN_LENGTH..BIG_LENGTH (1 and 33..98) emit FutureWarning attributed to caller. - @pytest.mark.parametrize("deprecated_length", [1, BIG_LENGTH + 1, MAXIMUM_LENGTH - 2]) - def test_deprecated_lengths_emit_warning(self: TestCuid, deprecated_length: int) -> None: - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as record: - cuid_instance = Cuid(length=deprecated_length) - assert record[0].category is FutureWarning - assert record[0].filename == __file__ - assert len(cuid_instance.generate()) == deprecated_length + with pytest.raises(ValueError, match=r"Length must be at least 2 characters\."): + cuid.generate(length=invalid_length) - cuid = Cuid() - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as record: - generated = cuid.generate(length=deprecated_length) - assert record[0].filename == __file__ - assert len(generated) == deprecated_length + # Tests that Cuid, generate(), create_id() and cuid_wrapper() raise TypeError for non-integer lengths. + @pytest.mark.parametrize( + "invalid_length", + [10.0, 2.5, float("nan"), Decimal(10), Fraction(5), "10", [10]], + ids=["float", "fractional-float", "nan", "decimal", "fraction", "str", "list"], + ) + def test_generate_non_integer_length(self: TestCuid, invalid_length: Any) -> None: # noqa: ANN401 + with pytest.raises(TypeError): + Cuid(length=invalid_length) + with pytest.raises(TypeError): + Cuid().generate(length=invalid_length) + with pytest.raises(TypeError): + create_id(length=invalid_length) + with pytest.raises(TypeError): + cuid_wrapper(length=invalid_length) + + # Tests that integer-like lengths (int subclasses) are accepted and produce IDs of exactly that length. + def test_generate_integer_like_length(self: TestCuid) -> None: + class Size(IntEnum): + SHORT = 10 - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as record: - generated_from_create_id = create_id(length=deprecated_length) - assert record[0].filename == __file__ - assert len(generated_from_create_id) == deprecated_length + assert len(Cuid(length=Size.SHORT).generate()) == Size.SHORT + assert len(Cuid().generate(length=Size.SHORT)) == Size.SHORT + assert len(create_id(length=Size.SHORT)) == Size.SHORT + assert len(cuid_wrapper(length=Size.SHORT)()) == Size.SHORT - # Tests that specification boundary lengths MIN_LENGTH (2) and BIG_LENGTH (32) emit no FutureWarning. + # Tests that specification boundary lengths MIN_LENGTH (2) and BIG_LENGTH (32) generate valid IDs of that length. @pytest.mark.parametrize("valid_length", [MIN_LENGTH, BIG_LENGTH]) - def test_spec_boundary_lengths_emit_no_warning(self: TestCuid, valid_length: int) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error", FutureWarning) - cuid_instance = Cuid(length=valid_length) - assert len(cuid_instance.generate()) == valid_length + def test_spec_boundary_lengths_generate(self: TestCuid, valid_length: int) -> None: + cuid_instance = Cuid(length=valid_length) + generated = cuid_instance.generate() + assert len(generated) == valid_length + assert is_cuid(generated) + + cuid = Cuid() + generated = cuid.generate(length=valid_length) + assert len(generated) == valid_length + assert is_cuid(generated) - cuid = Cuid() - assert len(cuid.generate(length=valid_length)) == valid_length + generated = create_id(length=valid_length) + assert len(generated) == valid_length + assert is_cuid(generated) - assert len(create_id(length=valid_length)) == valid_length + # Tests that MAXIMUM_LENGTH is not part of the public or utils namespaces. + def test_maximum_length_not_exported(self: TestCuid) -> None: + assert not hasattr(cuid2, "MAXIMUM_LENGTH") + assert not hasattr(cuid2.generator, "MAXIMUM_LENGTH") + assert not hasattr(utils, "MAXIMUM_LENGTH") # Tests that multiple instances of Cuid generate unique IDs. def test_generate_multiple_instances_unique_ids(self: TestCuid) -> None: @@ -438,9 +448,10 @@ class TestIsCuid: pytest.param("ab", id="minimum-length"), pytest.param("a0", id="letter-then-digit"), pytest.param("zz", id="last-letter"), - pytest.param("a" * MAXIMUM_LENGTH, id="maximum-length"), - pytest.param("z" + "9" * (MAXIMUM_LENGTH - 1), id="maximum-length-digits"), - pytest.param("a0123456789abcdefghijklmnopqrstuvwxyz", id="full-base36-alphabet"), + pytest.param("a" * BIG_LENGTH, id="big-length"), + pytest.param("z" + "9" * (BIG_LENGTH - 1), id="big-length-digits"), + pytest.param("a0123456789abcdefgh", id="base36-first-half"), + pytest.param("zijklmnopqrstuvwxyz", id="base36-second-half"), pytest.param("cjld2cjxh0000qzrmn831i7r", id="24-chars-starting-with-c"), pytest.param("dlwvlnik2o0lh47uh4kg6q8j", id="24-chars-random"), pytest.param("yi7rqj1trke", id="upstream-valid-vector"), @@ -459,9 +470,10 @@ def test_is_cuid_valid(self: TestIsCuid, value: str) -> None: pytest.param("42", id="digits-only"), pytest.param("0a", id="leading-zero"), pytest.param("1b", id="leading-digit"), - pytest.param("9" + "a" * (MAXIMUM_LENGTH - 1), id="leading-digit-maximum-length"), - pytest.param("a" * (MAXIMUM_LENGTH + 1), id="one-over-maximum-length"), - pytest.param("a" * 1000, id="far-over-maximum-length"), + pytest.param("9" + "a" * (BIG_LENGTH - 1), id="leading-digit-big-length"), + pytest.param("a" * (BIG_LENGTH + 1), id="one-over-big-length"), + pytest.param("a" * 98, id="length-98-over-big-length"), + pytest.param("a" * 1000, id="far-over-big-length"), ], ) def test_is_cuid_invalid_shape_or_length(self: TestIsCuid, value: str) -> None: @@ -598,16 +610,16 @@ def test_is_cuid_min_length_cannot_go_below_minimum( def test_is_cuid_min_length_below_minimum_still_accepts_valid(self: TestIsCuid, min_length: int) -> None: assert Cuid.is_cuid("ab", min_length=min_length) is True - # Tests that max_length cannot go above MAXIMUM_LENGTH, matching the generator's own limit. - @pytest.mark.parametrize("max_length", [MAXIMUM_LENGTH + 1, 200, 10**9]) - def test_is_cuid_max_length_cannot_exceed_maximum(self: TestIsCuid, max_length: int) -> None: - assert Cuid.is_cuid("a" * (MAXIMUM_LENGTH + 1), max_length=max_length) is False + # Tests that max_length cannot go above BIG_LENGTH, matching the generator's own limit. + @pytest.mark.parametrize("max_length", [BIG_LENGTH + 1, 98, 200, 10**9]) + def test_is_cuid_max_length_cannot_exceed_big_length(self: TestIsCuid, max_length: int) -> None: + assert Cuid.is_cuid("a" * (BIG_LENGTH + 1), max_length=max_length) is False assert Cuid.is_cuid("a" * 150, max_length=max_length) is False - assert Cuid.is_cuid("a" * MAXIMUM_LENGTH, max_length=max_length) is True + assert Cuid.is_cuid("a" * BIG_LENGTH, max_length=max_length) is True - # Tests that a min_length above MAXIMUM_LENGTH can never match. - def test_is_cuid_min_length_above_maximum(self: TestIsCuid) -> None: - assert Cuid.is_cuid("a" * MAXIMUM_LENGTH, min_length=MAXIMUM_LENGTH + 1) is False + # Tests that a min_length above BIG_LENGTH can never match. + def test_is_cuid_min_length_above_big_length(self: TestIsCuid) -> None: + assert Cuid.is_cuid("a" * BIG_LENGTH, min_length=BIG_LENGTH + 1) is False # Tests that max_length below 2 can never match. @pytest.mark.parametrize("max_length", [1, 0, -1]) @@ -635,21 +647,13 @@ def test_is_cuid_callable_on_instance(self: TestIsCuid) -> None: assert cuid.is_cuid("NOT-A-CUID") is False # Tests that every generated id validates for every supported length, including the boundaries. - # The exact length is only asserted below MAXIMUM_LENGTH - 1: at 97 and 98 characters the generator - # occasionally returns a shorter id (the SHA3-512 base36 digest is not always long enough), but - # such ids are still valid. - @pytest.mark.parametrize("length", [2, 3, 10, DEFAULT_LENGTH, BIG_LENGTH, 50, MAXIMUM_LENGTH - 2, MAXIMUM_LENGTH]) + @pytest.mark.parametrize("length", [MIN_LENGTH, 3, 10, DEFAULT_LENGTH, BIG_LENGTH]) def test_is_cuid_accepts_generated_ids(self: TestIsCuid, length: int) -> None: - if length > BIG_LENGTH: - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range"): - cuid = Cuid(length=length) - else: - cuid = Cuid(length=length) + cuid = Cuid(length=length) for _ in range(250): generated = cuid.generate() - assert len(generated) <= length - if length < MAXIMUM_LENGTH - 1: - assert len(generated) == length + assert len(generated) == length + assert is_cuid(generated) is True assert Cuid.is_cuid(generated) is True # Tests that ids generated via generate(length=...) and via the wrapper also validate. @@ -667,15 +671,6 @@ def test_is_cuid_generated_id_exact_bounds(self: TestIsCuid) -> None: assert Cuid.is_cuid(generated, min_length=DEFAULT_LENGTH + 1) is False assert Cuid.is_cuid(generated, max_length=DEFAULT_LENGTH - 1) is False - # Documents a known asymmetry: the generator can emit 1-character ids, but a valid CUID2 needs 2+ characters. - # If the generator's minimum length is ever raised to 2, this test should be updated. - def test_is_cuid_rejects_single_character_generated_id(self: TestIsCuid) -> None: - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range"): - generated = Cuid(length=1).generate() - assert len(generated) == 1 - assert Cuid.is_cuid(generated) is False - assert Cuid.is_cuid(generated, min_length=1) is False - # Tests that mutating a generated id in each way is detected. def test_is_cuid_detects_mutated_generated_id(self: TestIsCuid) -> None: generated = Cuid().generate() @@ -731,22 +726,16 @@ def test_create_id_unique_ids(self: TestCreateId) -> None: """Tests that consecutive create_id() calls return distinct IDs.""" assert create_id() != create_id() - @pytest.mark.parametrize("length", [1, BIG_LENGTH + 1]) - def test_create_id_deprecated_length(self: TestCreateId, length: int) -> None: - """Tests that create_id() emits FutureWarning attributed to the caller for lengths outside 2..32.""" - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as record: - generated: str = create_id(length=length) - assert len(generated) == length - assert record[0].filename == __file__ - @pytest.mark.parametrize( ("invalid_length", "expected_match"), [ - (0, r"Length must be at least 1 character\."), - (-1, r"Length must be at least 1 character\."), - (True, r"Length must be at least 1 character\."), - (False, r"Length must be at least 1 character\."), - (MAXIMUM_LENGTH + 1, r"Length must never exceed 98 characters\."), + (1, r"Length must be at least 2 characters\."), + (0, r"Length must be at least 2 characters\."), + (-1, r"Length must be at least 2 characters\."), + (True, r"Length must be at least 2 characters\."), + (False, r"Length must be at least 2 characters\."), + (BIG_LENGTH + 1, r"Length must never exceed 32 characters\."), + (98, r"Length must never exceed 32 characters\."), ], ) def test_create_id_invalid_length( @@ -871,29 +860,16 @@ def test_cuid_wrapper_custom_length(self: TestCuidWrapper, length: int) -> None: cuid_func: Callable[[], str] = cuid_wrapper(length=length) assert len(cuid_func()) == length - @pytest.mark.parametrize("length", [1, BIG_LENGTH + 1, MAXIMUM_LENGTH]) - def test_cuid_wrapper_deprecated_length(self: TestCuidWrapper, length: int) -> None: - """Tests that cuid_wrapper emits FutureWarning for lengths outside MIN_LENGTH..BIG_LENGTH.""" - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as record: - cuid_func: Callable[[], str] = cuid_wrapper(length=length) - assert len(record) == 1 - assert record[0].filename == __file__ - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range") as call_record: - generated = cuid_func() - assert len(call_record) == 1 - assert call_record[0].filename == __file__ - assert len(generated) <= length - if length < MAXIMUM_LENGTH - 1: - assert len(generated) == length - @pytest.mark.parametrize( ("invalid_length", "expected_match"), [ - (0, r"Length must be at least 1 character\."), - (-1, r"Length must be at least 1 character\."), - (True, r"Length must be at least 1 character\."), - (False, r"Length must be at least 1 character\."), - (MAXIMUM_LENGTH + 1, r"Length must never exceed 98 characters\."), + (1, r"Length must be at least 2 characters\."), + (0, r"Length must be at least 2 characters\."), + (-1, r"Length must be at least 2 characters\."), + (True, r"Length must be at least 2 characters\."), + (False, r"Length must be at least 2 characters\."), + (BIG_LENGTH + 1, r"Length must never exceed 32 characters\."), + (98, r"Length must never exceed 32 characters\."), ], ) def test_cuid_wrapper_invalid_length( diff --git a/local/tests/test_fuzz.py b/local/tests/test_fuzz.py index 87a10bf..e9a4303 100644 --- a/local/tests/test_fuzz.py +++ b/local/tests/test_fuzz.py @@ -16,7 +16,6 @@ BASE36_RADIX, BIG_LENGTH, INITIAL_COUNT_MAX, - MAXIMUM_LENGTH, MIN_LENGTH, ) @@ -37,14 +36,14 @@ class _FuzzTarget(Protocol): def test_fuzz_is_cuid_arbitrary_strings(candidate: str, min_length: int, max_length: int) -> None: expected: bool = ( len(candidate) >= MIN_LENGTH - and min_length <= len(candidate) <= min(max_length, MAXIMUM_LENGTH) + and min_length <= len(candidate) <= min(max_length, BIG_LENGTH) and candidate[0] in ASCII_LOWERCASE and all(ch in BASE36_ALPHABET for ch in candidate[1:]) ) assert is_cuid(candidate, min_length=min_length, max_length=max_length) is expected -@given(candidate=st.from_regex(r"\A[a-z][0-9a-z]{1,97}\Z", fullmatch=True)) +@given(candidate=st.from_regex(r"\A[a-z][0-9a-z]{1,31}\Z", fullmatch=True)) def test_fuzz_is_cuid_valid_regex(candidate: str) -> None: assert is_cuid(candidate) is True assert is_cuid(candidate, min_length=len(candidate), max_length=len(candidate)) is True @@ -93,7 +92,8 @@ def test_fuzz_utils_primitives( utils.create_entropy(rng, invalid_entropy_len) digest: str = utils.create_hash(data) - assert 1 <= len(digest) <= MAXIMUM_LENGTH + 1 + # A 512-bit SHA3-512 digest has at most 100 base36 digits and create_hash drops the leading digit. + assert 1 <= len(digest) <= 99 assert all(ch in BASE36_ALPHABET for ch in digest) fingerprint: str = utils.create_fingerprint(rng, data) @@ -130,47 +130,39 @@ def test_fuzz_cuid_spec_lengths(seed: int, length: int) -> None: @given( - deprecated_length=st.one_of(st.just(1), st.integers(BIG_LENGTH + 1, MAXIMUM_LENGTH)), invalid_length=st.one_of( st.booleans(), - st.integers(max_value=0), - st.integers(min_value=MAXIMUM_LENGTH + 1, max_value=500), + st.integers(max_value=MIN_LENGTH - 1), + st.integers(min_value=BIG_LENGTH + 1, max_value=10_000), ), ) -def test_fuzz_cuid_deprecated_and_invalid_lengths(deprecated_length: int, invalid_length: int) -> None: - with pytest.warns(FutureWarning, match=r"outside the CUID2 specification range"): - uid: str = create_id(length=deprecated_length) - assert 1 <= len(uid) <= deprecated_length - +def test_fuzz_cuid_invalid_lengths(invalid_length: int) -> None: cuid = Cuid() - with pytest.raises(ValueError, match=r"Length must (be at least 1 character|never exceed 98 characters)\."): + with pytest.raises(ValueError, match=r"Length must (be at least 2|never exceed 32) characters\."): Cuid(length=invalid_length) - with pytest.raises(ValueError, match=r"Length must (be at least 1 character|never exceed 98 characters)\."): + with pytest.raises(ValueError, match=r"Length must (be at least 2|never exceed 32) characters\."): cuid.generate(length=invalid_length) - with pytest.raises(ValueError, match=r"Length must (be at least 1 character|never exceed 98 characters)\."): + with pytest.raises(ValueError, match=r"Length must (be at least 2|never exceed 32) characters\."): create_id(length=invalid_length) - with pytest.raises(ValueError, match=r"Length must (be at least 1 character|never exceed 98 characters)\."): + with pytest.raises(ValueError, match=r"Length must (be at least 2|never exceed 32) characters\."): cuid_wrapper(length=invalid_length) @given( digest_int=st.integers(min_value=0, max_value=(1 << 512) - 1), - length=st.integers(min_value=1, max_value=MAXIMUM_LENGTH), + length=st.integers(min_value=1, max_value=BIG_LENGTH), ) @example(digest_int=0, length=1) @example(digest_int=35, length=BIG_LENGTH) @example(digest_int=36, length=BIG_LENGTH) -@example(digest_int=36**97 - 1, length=MAXIMUM_LENGTH) -@example(digest_int=36**98 - 1, length=MAXIMUM_LENGTH - 1) +@example(digest_int=36**97 - 1, length=BIG_LENGTH) +@example(digest_int=36**98 - 1, length=BIG_LENGTH) @example(digest_int=36**98, length=1) -@example(digest_int=36**98, length=MAXIMUM_LENGTH - 1) -@example(digest_int=36**98, length=MAXIMUM_LENGTH) -@example(digest_int=36**99 - 1, length=MAXIMUM_LENGTH - 1) +@example(digest_int=36**98, length=BIG_LENGTH) +@example(digest_int=36**99 - 1, length=BIG_LENGTH) @example(digest_int=36**99, length=1) -@example(digest_int=36**99, length=MAXIMUM_LENGTH - 1) -@example(digest_int=36**99, length=MAXIMUM_LENGTH) -@example(digest_int=(1 << 512) - 1, length=MAXIMUM_LENGTH - 1) -@example(digest_int=(1 << 512) - 1, length=MAXIMUM_LENGTH) +@example(digest_int=36**99, length=BIG_LENGTH) +@example(digest_int=(1 << 512) - 1, length=BIG_LENGTH) def test_fuzz_create_hash_slice_arbitrary_digest(digest_int: int, length: int) -> None: mock_hash = Mock() mock_hash.digest.return_value = digest_int.to_bytes(64, byteorder="big") @@ -183,7 +175,7 @@ def test_fuzz_create_hash_slice_arbitrary_digest(digest_int: int, length: int) - @given( data=st.text(max_size=200), - length=st.integers(min_value=1, max_value=MAXIMUM_LENGTH), + length=st.integers(min_value=1, max_value=BIG_LENGTH), u53=st.integers(min_value=0, max_value=(1 << 53) - 1), dyadic_j=st.integers(min_value=0, max_value=(1 << 20) - 1), ) @@ -224,7 +216,7 @@ def test_fuzz_create_hash_slice_and_scale_randbits( test_fuzz_base36_encode_negative_raises, test_fuzz_utils_primitives, test_fuzz_cuid_spec_lengths, - test_fuzz_cuid_deprecated_and_invalid_lengths, + test_fuzz_cuid_invalid_lengths, test_fuzz_create_hash_slice_arbitrary_digest, test_fuzz_create_hash_slice_and_scale_randbits, ), diff --git a/local/tests/test_utils.py b/local/tests/test_utils.py index 3c97f19..b61857b 100644 --- a/local/tests/test_utils.py +++ b/local/tests/test_utils.py @@ -543,7 +543,7 @@ def test_low_bit_stuck_random_immunity(self: TestFixedPointScalingAndHashSlice) assert {cuid.generate()[0] for _ in range(500)} == set(utils.ASCII_LOWERCASE) @pytest.mark.parametrize("boundary_int", [0, 36**98 - 1, 36**98, 36**99, (1 << 512) - 1]) - @pytest.mark.parametrize("length", [1, utils.BIG_LENGTH, utils.MAXIMUM_LENGTH]) + @pytest.mark.parametrize("length", [1, utils.BIG_LENGTH]) def test_create_hash_slice_branches( self: TestFixedPointScalingAndHashSlice, mocker: Mock, @@ -603,7 +603,7 @@ def test_mock_with_getrandbits_never_calls_random(self: TestRandomOnlyGenerators mock_random.random.assert_not_called() @pytest.mark.parametrize("rng_type", [_RandomOnlyRandom, _LowResolutionRandom]) - @pytest.mark.parametrize("length", [1, 4, 24, utils.MAXIMUM_LENGTH]) + @pytest.mark.parametrize("length", [1, 4, 24, utils.BIG_LENGTH]) def test_random_only_draws_match_per_character_reference( self: TestRandomOnlyGenerators, rng_type: type[random.Random], diff --git a/pyproject.toml b/pyproject.toml index a2d6bb9..9a360ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ Changelog = "https://github.com/gordon-code/cuid2/blob/main/CHANGELOG.md" Issues = "https://github.com/gordon-code/cuid2/issues" [project.scripts] -cuid2 = "cuid2.cli:_console_main" +cuid2 = "cuid2.cli:main" [tool.uv] default-groups = ["build", "lint", "test", "typing"] diff --git a/src/cuid2/__init__.py b/src/cuid2/__init__.py index 6af5d73..d3de46f 100644 --- a/src/cuid2/__init__.py +++ b/src/cuid2/__init__.py @@ -1,13 +1,12 @@ """Next generation GUIDs. Collision-resistant ids optimized for horizontal scaling and performance.""" from cuid2.generator import Cuid, create_id, cuid_wrapper, is_cuid -from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, INITIAL_COUNT_MAX, MAXIMUM_LENGTH, MIN_LENGTH +from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, INITIAL_COUNT_MAX, MIN_LENGTH __all__ = [ "BIG_LENGTH", "DEFAULT_LENGTH", "INITIAL_COUNT_MAX", - "MAXIMUM_LENGTH", "MIN_LENGTH", "Cuid", "create_id", diff --git a/src/cuid2/__main__.py b/src/cuid2/__main__.py index 4c09dcc..468e514 100644 --- a/src/cuid2/__main__.py +++ b/src/cuid2/__main__.py @@ -2,6 +2,6 @@ from __future__ import annotations -from cuid2.cli import _console_main # pyright: ignore[reportPrivateUsage] +from cuid2.cli import main -_console_main() +main() diff --git a/src/cuid2/cli.py b/src/cuid2/cli.py index d7bbcfd..6666017 100644 --- a/src/cuid2/cli.py +++ b/src/cuid2/cli.py @@ -4,14 +4,13 @@ import os import sys -import warnings from argparse import SUPPRESS, Action, ArgumentParser, ArgumentTypeError, RawDescriptionHelpFormatter from contextlib import suppress from functools import partial from typing import TYPE_CHECKING from cuid2.generator import Cuid, is_cuid -from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, MAXIMUM_LENGTH, MIN_LENGTH +from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, MIN_LENGTH __all__ = ["main"] @@ -19,7 +18,6 @@ from argparse import Namespace from collections.abc import Iterable, Iterator, Sequence from random import Random - from typing import TextIO _SLUG_LENGTH: int = 5 @@ -61,11 +59,11 @@ def _parse_length(raw: str) -> int: except ValueError as err: msg = f"invalid int value: {raw!r}" raise ArgumentTypeError(msg) from err - if value < 1: - msg = "length must be at least 1 character" + if value < MIN_LENGTH: + msg = f"length must be at least {MIN_LENGTH} characters" raise ArgumentTypeError(msg) - if value > MAXIMUM_LENGTH: - msg = f"length must never exceed {MAXIMUM_LENGTH} characters" + if value > BIG_LENGTH: + msg = f"length must never exceed {BIG_LENGTH} characters" raise ArgumentTypeError(msg) return value @@ -95,21 +93,10 @@ def _fixed_fingerprint(random_generator: Random, *, fingerprint: str) -> str: return fingerprint -def _show_cli_warning( # noqa: PLR0913, PLR0917 - message: Warning | str, - category: type[Warning], - filename: str, - lineno: int, - file: TextIO | None = None, - line: str | None = None, -) -> None: - # Prints warnings as one clean `cuid2: warning: ` stderr line, without the source path or code line. - del category, filename, lineno, line - stream = file if file is not None else sys.stderr - if stream is None: # No stderr (pythonw or fd 2 closed at startup): drop the warning like the stdlib does. - return - with suppress(OSError): - stream.write(f"cuid2: warning: {message}\n") +_LENGTH_HELP: str = ( + f"Length of CUID2 to generate or validate (allowed: {MIN_LENGTH}..{BIG_LENGTH}). " + f"Generation defaults to {DEFAULT_LENGTH}; validation accepts any allowed length unless one is given." +) def _build_parser() -> ArgumentParser: @@ -117,7 +104,7 @@ def _build_parser() -> ArgumentParser: prog="cuid2", description=( f"Generate or validate collision-resistant CUID2 identifiers " - f"(default length: {DEFAULT_LENGTH}, spec range: {MIN_LENGTH}..{BIG_LENGTH})." + f"(default generated length: {DEFAULT_LENGTH}, spec range: {MIN_LENGTH}..{BIG_LENGTH})." ), epilog=( "examples:\n" @@ -136,7 +123,7 @@ def _build_parser() -> ArgumentParser: nargs="?", type=_parse_length, default=None, - help=f"Length of CUID2 to generate or validate (default: {DEFAULT_LENGTH}, spec: {MIN_LENGTH}..{BIG_LENGTH}).", + help=_LENGTH_HELP, ) parser.add_argument( "-V", @@ -153,7 +140,7 @@ def _build_parser() -> ArgumentParser: metavar="LENGTH", type=_parse_length, default=None, - help=f"Length of CUID2 to generate or validate (default: {DEFAULT_LENGTH}, spec: {MIN_LENGTH}..{BIG_LENGTH}).", + help=_LENGTH_HELP, ) parser.add_argument( "--slug", @@ -207,13 +194,11 @@ def _resolve_length(parser: ArgumentParser, cli_args: Namespace) -> int | None: return sources[0] if sources else None -def _validate_mode_flags(parser: ArgumentParser, cli_args: Namespace, target_length: int | None) -> None: +def _validate_mode_flags(parser: ArgumentParser, cli_args: Namespace) -> None: if cli_args.check is None and cli_args.quiet: parser.error("argument -q/--quiet: only allowed with --check/--validate") if cli_args.check is not None and (cli_args.count is not None or cli_args.fingerprint is not None): parser.error("arguments -n/-c/--count and -f/--fingerprint are not allowed with --check/--validate") - if cli_args.check is not None and target_length is not None and target_length < MIN_LENGTH: - parser.error(f"argument --check/--validate: length must be at least {MIN_LENGTH} characters") def _iter_candidates(check_arg: str) -> Iterator[str]: @@ -232,7 +217,7 @@ def _iter_candidates(check_arg: str) -> Iterator[str]: def _run_check(check_arg: str, target_length: int | None, *, quiet: bool) -> int: min_len = target_length if target_length is not None else MIN_LENGTH - max_len = target_length if target_length is not None else MAXIMUM_LENGTH + max_len = target_length if target_length is not None else BIG_LENGTH all_valid = True exhausted = False @@ -266,20 +251,12 @@ def main(args: Sequence[str] | None = None) -> None: SystemExit Raised with exit code ``0`` or ``1`` in ``--check``/``--validate`` or ``--version``/``--help`` modes, or exit code ``2`` on invalid arguments, - including a length warning that a warnings filter turned into an error. - - Warns - ----- - FutureWarning - When generating IDs (not in ``--check``/``--validate`` mode) with a length - outside the CUID2 specification range (``MIN_LENGTH``..``BIG_LENGTH``, - i.e., 2..32). The ``cuid2`` console script and ``python -m cuid2`` print it - as a ``cuid2: warning: ...`` line. + including a length outside ``MIN_LENGTH``..``BIG_LENGTH`` (2..32). """ parser = _build_parser() cli_args = parser.parse_args(args) target_length = _resolve_length(parser, cli_args) - _validate_mode_flags(parser, cli_args, target_length) + _validate_mode_flags(parser, cli_args) if cli_args.check is not None: exit_code = _run_check(cli_args.check, target_length, quiet=cli_args.quiet) @@ -287,24 +264,12 @@ def main(args: Sequence[str] | None = None) -> None: resolved_length = target_length if target_length is not None else DEFAULT_LENGTH count = cli_args.count if cli_args.count is not None else 1 - try: - cuid_generator = ( - Cuid(length=resolved_length) - if cli_args.fingerprint is None - else Cuid( - length=resolved_length, - fingerprint=partial(_fixed_fingerprint, fingerprint=cli_args.fingerprint), - ) + cuid_generator = ( + Cuid(length=resolved_length) + if cli_args.fingerprint is None + else Cuid( + length=resolved_length, + fingerprint=partial(_fixed_fingerprint, fingerprint=cli_args.fingerprint), ) - except FutureWarning as warning: - # A warnings filter such as -W error or PYTHONWARNINGS=error turned the length warning into an error. - parser.error(str(warning)) + ) _write_lines(f"{cuid_generator.generate()}\n" for _ in range(count)) - - -def _console_main(args: Sequence[str] | None = None) -> None: # pyright: ignore[reportUnusedFunction] - # Entry point for the `cuid2` console script and `python -m cuid2`: prints warnings as `cuid2: warning:` lines. - # main() leaves warning delivery to its caller, so programmatic use never touches the global showwarning hook. - with warnings.catch_warnings(): - warnings.showwarning = _show_cli_warning - main(args) diff --git a/src/cuid2/generator.py b/src/cuid2/generator.py index ee01752..3167a98 100644 --- a/src/cuid2/generator.py +++ b/src/cuid2/generator.py @@ -2,11 +2,11 @@ from __future__ import annotations +import operator import os import re import threading import time -import warnings from contextlib import suppress from copy import copy, deepcopy from functools import partial @@ -14,13 +14,12 @@ from typing import TYPE_CHECKING, NamedTuple from cuid2 import utils -from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, INITIAL_COUNT_MAX, MAXIMUM_LENGTH, MIN_LENGTH +from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, INITIAL_COUNT_MAX, MIN_LENGTH __all__ = [ "BIG_LENGTH", "DEFAULT_LENGTH", "INITIAL_COUNT_MAX", - "MAXIMUM_LENGTH", "MIN_LENGTH", "Cuid", "create_id", @@ -47,7 +46,7 @@ def __call__( self: IsCuidCallable, cuid_str: str, min_length: int = MIN_LENGTH, - max_length: int = MAXIMUM_LENGTH, + max_length: int = BIG_LENGTH, ) -> bool: """Validate whether a string is a CUID2 identifier within the given length bounds.""" ... @@ -67,29 +66,19 @@ def _validate_length(length: int) -> None: Raises ------ ValueError - If `length` is a boolean, less than 1, or greater than `MAXIMUM_LENGTH` (98). - - Warns - ----- - FutureWarning - If `length` is outside the CUID2 specification range (`MIN_LENGTH`..`BIG_LENGTH`, i.e., 2..32). + If `length` is a boolean, less than `MIN_LENGTH` (2), or greater than `BIG_LENGTH` (32). + TypeError + If `length` is not an integer (a float, `Decimal`, `Fraction`, or string, for example). """ - if isinstance(length, bool) or length < 1: - msg = "Length must be at least 1 character." + if isinstance(length, bool) or operator.index(length) < MIN_LENGTH: + msg = f"Length must be at least {MIN_LENGTH} characters." raise ValueError(msg) - if length > MAXIMUM_LENGTH: - msg = "Length must never exceed 98 characters." + if length > BIG_LENGTH: + msg = f"Length must never exceed {BIG_LENGTH} characters." raise ValueError(msg) - if length < MIN_LENGTH or length > BIG_LENGTH: - warnings.warn( - f"Length {length} outside the CUID2 specification range ({MIN_LENGTH}..{BIG_LENGTH}) " - "is deprecated and will raise ValueError in v3.0.0.", - FutureWarning, - stacklevel=3, - ) -def is_cuid(cuid_str: str, min_length: int = MIN_LENGTH, max_length: int = MAXIMUM_LENGTH) -> bool: +def is_cuid(cuid_str: str, min_length: int = MIN_LENGTH, max_length: int = BIG_LENGTH) -> bool: """Validate whether a given string is a valid CUID2 identifier. Parameters @@ -99,8 +88,8 @@ def is_cuid(cuid_str: str, min_length: int = MIN_LENGTH, max_length: int = MAXIM min_length : int, default=MIN_LENGTH (2) The minimum allowed length of the identifier. Values below 2 have no effect, since a valid identifier always has a leading letter followed by at least one more character. - max_length : int, default=MAXIMUM_LENGTH (98) - The maximum allowed length of the identifier. Values above `MAXIMUM_LENGTH` are clamped to it. + max_length : int, default=BIG_LENGTH (32) + The maximum allowed length of the identifier. Values above `BIG_LENGTH` are clamped to it. Returns ------- @@ -108,9 +97,9 @@ def is_cuid(cuid_str: str, min_length: int = MIN_LENGTH, max_length: int = MAXIM True if `cuid_str` is a string starting with a lowercase ASCII letter followed by lowercase base36 characters and has length between `min_length` and `max_length` inclusive; False otherwise. The length bounds can only narrow the range of valid - lengths (2 to `MAXIMUM_LENGTH`), never widen it. + lengths (2 to `BIG_LENGTH`), never widen it. """ - max_length = min(max_length, MAXIMUM_LENGTH) + max_length = min(max_length, BIG_LENGTH) return ( isinstance(cuid_str, str) # pyright: ignore[reportUnnecessaryIsInstance] and min_length <= len(cuid_str) <= max_length @@ -171,10 +160,10 @@ def __init__( is called. The `create_counter` function from the `utils` module is used by default. Called again with a new random starting value in a forked child process. length : int, default=DEFAULT_LENGTH (24) - The length parameter is an integer that determines the maximum length of the generated string. + The length parameter is an integer that determines the length of the generated string. It has a default value of DEFAULT_LENGTH (24). - A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError. - Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0). + A length value less than `MIN_LENGTH` (2) or greater than `BIG_LENGTH` (32 characters) will raise a + ValueError. fingerprint : "FingerprintCallable", default=utils.create_fingerprint The "fingerprint" parameter is a callable function that generates a unique identifier. Called again in a forked child process; the default includes the child's `os.getpid()`. @@ -182,12 +171,10 @@ def __init__( Raises ------ ValueError - If the length parameter is a boolean, less than 1, or greater than `MAXIMUM_LENGTH` (98 characters). - - Warns - ----- - FutureWarning - If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters). + If the length parameter is a boolean, less than `MIN_LENGTH` (2), or greater than `BIG_LENGTH` (32 + characters). + TypeError + If the length parameter is not an integer. Notes ----- @@ -271,25 +258,23 @@ def generate(self: Cuid, length: int | None = None) -> str: length : int, optional The length parameter is an optional integer value that specifies the length of the generated string. If it is not provided, the default length value provided during class initialization is used. - A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError. - Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0). + A length value less than `MIN_LENGTH` (2) or greater than `BIG_LENGTH` (32 characters) will raise a + ValueError. Returns ------- str Starts with a single, random letter, followed by a hash. The hash is generated using a combination of the current time in base 36, a random salt, a base 36 counter, and a system fingerprint. - The length of the returned string is limited by `length`. + The returned string has exactly `length` characters. Raises ------ ValueError - If the length parameter is a boolean, less than 1, or greater than `MAXIMUM_LENGTH` (98 characters). - - Warns - ----- - FutureWarning - If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters). + If the length parameter is a boolean, less than `MIN_LENGTH` (2), or greater than `BIG_LENGTH` (32 + characters). + TypeError + If the length parameter is not an integer. """ if length is not None: _validate_length(length) @@ -329,8 +314,7 @@ def create_id(length: int | None = None) -> str: ---------- length : int, optional The optional length of the generated CUID2 string. Defaults to `DEFAULT_LENGTH` (24) when omitted. - A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError. - Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0). + A length value less than `MIN_LENGTH` (2) or greater than `BIG_LENGTH` (32 characters) will raise a ValueError. Returns ------- @@ -340,12 +324,9 @@ def create_id(length: int | None = None) -> str: Raises ------ ValueError - If the length parameter is a boolean, less than 1, or greater than `MAXIMUM_LENGTH` (98 characters). - - Warns - ----- - FutureWarning - If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters). + If the length parameter is a boolean, less than `MIN_LENGTH` (2), or greater than `BIG_LENGTH` (32 characters). + TypeError + If the length parameter is not an integer. """ global _default_generator # noqa: PLW0603 if length is not None: @@ -366,10 +347,9 @@ def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]: Parameters ---------- length : int, default=DEFAULT_LENGTH (24) - The length parameter is an integer that determines the maximum length of the generated string. + The length parameter is an integer that determines the length of the generated string. It has a default value of DEFAULT_LENGTH (24). - A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError. - Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0). + A length value less than `MIN_LENGTH` (2) or greater than `BIG_LENGTH` (32 characters) will raise a ValueError. Returns ------- @@ -379,12 +359,9 @@ def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]: Raises ------ ValueError - If the length parameter is a boolean, less than 1, or greater than `MAXIMUM_LENGTH` (98 characters). - - Warns - ----- - FutureWarning - If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters). + If the length parameter is a boolean, less than `MIN_LENGTH` (2), or greater than `BIG_LENGTH` (32 characters). + TypeError + If the length parameter is not an integer. """ _validate_length(length) return partial(create_id, length=length) diff --git a/src/cuid2/utils.py b/src/cuid2/utils.py index 0c45e1f..a257218 100644 --- a/src/cuid2/utils.py +++ b/src/cuid2/utils.py @@ -20,7 +20,6 @@ MIN_LENGTH: Final[int] = 2 DEFAULT_LENGTH: Final[int] = 24 BIG_LENGTH: Final[int] = 32 -MAXIMUM_LENGTH: Final[int] = 98 ASCII_LOWERCASE: Final[str] = "abcdefghijklmnopqrstuvwxyz" ASCII_LOWERCASE_LEN: Final[int] = len(ASCII_LOWERCASE) @@ -106,7 +105,9 @@ def _create_hash_slice(data: str, start: int, stop: int) -> str: hashed_int: int = int.from_bytes(sha3_512(data.encode()).digest(), byteorder="big") raw_start: int = start + 1 raw_stop: int = stop + 1 - if hashed_int >= _HASH_MIN_99_DIGITS and stop < MAXIMUM_LENGTH: + # Callers pass stop <= BIG_LENGTH. The division fast path is valid only for stop <= 98: with a 99-digit digest and + # a larger stop, _POWERS_OF_36[total_digits - raw_stop] would index from the end and return a wrong slice. + if hashed_int >= _HASH_MIN_99_DIGITS: total_digits: int = 100 if hashed_int >= _HASH_MIN_100_DIGITS else 99 return base36_encode(hashed_int // _POWERS_OF_36[total_digits - raw_stop])[raw_start:raw_stop] return base36_encode(hashed_int)[raw_start:raw_stop]