diff --git a/.github/workflows/test_lint_scan.yml b/.github/workflows/test_lint_scan.yml index fc26d30..424776f 100644 --- a/.github/workflows/test_lint_scan.yml +++ b/.github/workflows/test_lint_scan.yml @@ -80,6 +80,8 @@ jobs: "$WHEEL_TEST_DIR/venv/bin/python" -I -c "import pathlib, cuid2; assert 'site-packages' in pathlib.Path(cuid2.__file__).parts, cuid2.__file__" CLI_ID="$("$WHEEL_TEST_DIR/venv/bin/cuid2")" test "${#CLI_ID}" -eq 24 + MOD_ID="$("$WHEEL_TEST_DIR/venv/bin/python" -I -m cuid2)" + test "${#MOD_ID}" -eq 24 ( cd "$WHEEL_TEST_DIR" COVERAGE_FILE="$WHEEL_TEST_DIR/.coverage" "$WHEEL_TEST_DIR/venv/bin/python" -I -m pytest -c "$GITHUB_WORKSPACE/pyproject.toml" --cov-config="$GITHUB_WORKSPACE/pyproject.toml" -o cache_dir="$WHEEL_TEST_DIR/.pytest_cache" "$GITHUB_WORKSPACE/local/tests" @@ -139,6 +141,8 @@ jobs: languages: python - name: Perform Runtime Code Analysis uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + category: "/language:python" env: CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION: "3.13" - name: Perform Secure Code Analysis (Secrets) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f00a3..5140e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,16 @@ This repository continues the `overflowdigital` `cuid2` package (`b02c8ca`); ver - Python 3.15 support in CI and package classifiers. - `Changelog` and `Issues` project URLs in the package metadata. +- CLI flags `-V`/`--version`, `-l`/`--length`, `--slug` (5-character short ID preset), `-n`/`-c`/`--count`, `-f`/`--fingerprint` (used verbatim as the fingerprint, as in `@paralleldrive/cuid2`), and `--check`/`--validate` (with `-` `stdin`, skipping blank lines, and `-q`/`--quiet` support), plus `python -m cuid2` module execution. A count below `1` or an empty fingerprint exits with code `2`. ### Changed -- Internal: strengthened `ruff`, `mypy`, and `pyright` static checking, enabled `pytest` 9 strict mode and 100% branch coverage enforcement, added `hypothesis` + `atheris` property-based fuzzing and bounded-memory statistical collision testing in CI, added `rumdl` markdown linting/formatting, `zizmor` and `actionlint` workflow linting, `prek.toml` pre-commit hooks, and OpenSSF Scorecard analysis, hardened the release pipeline with SLSA build provenance and PEP 740 PyPI attestations plus `CHANGELOG.md` release notes, extended `codespell` to repository documentation, and removed `pylint` and `tox`. +- `cuid_wrapper()` is no longer deprecated and now returns `functools.partial(create_id, length=length)`, making it picklable and directly usable as a Django migration `default=` callable (passing an extra positional argument now raises `TypeError: create_id() got multiple values for argument 'length'`, and passing `length=...` by keyword overrides the bound length). +- Internal: strengthened `ruff`, `mypy`, and `pyright` static checking, enabled `pytest` 9 strict mode and 100% branch coverage enforcement, added `hypothesis` + `atheris` property-based fuzzing and bounded-memory statistical collision testing in CI, added `rumdl` markdown linting/formatting, `zizmor` and `actionlint` workflow linting, `prek.toml` pre-commit hooks, and OpenSSF Scorecard analysis, set a fixed CodeQL analysis category shared by the CI and release workflows, hardened the release pipeline with SLSA build provenance and PEP 740 PyPI attestations plus `CHANGELOG.md` release notes, extended `codespell` to repository documentation, and removed `pylint` and `tox`. + +### Fixed + +- CLI out-of-range lengths (`<= 0` or `> 98`) now exit with code `2` and an `argparse` usage error on `stderr` instead of an uncaught `ValueError` traceback. ## [2.1.0] - 2026-10-25 diff --git a/README.md b/README.md index 9bbf18e..79ed6a6 100644 --- a/README.md +++ b/README.md @@ -79,22 +79,27 @@ print(is_cuid(my_cuid)) # True > **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. -You can also generate CUIDs directly in the terminal (optionally specifying a custom length): +You can also generate and validate CUIDs directly in the terminal: ```bash -cuid2 -cuid2 10 +cuid2 # Generate a 24-character CUID2 +cuid2 -l 10 # Generate a 10-character CUID2 (or positional: cuid2 10) +cuid2 --slug # Generate a 5-character short ID slug +cuid2 -n 5 # Generate 5 CUID2s in a batch +cuid2 -f server-1 # Use "server-1" verbatim as the fingerprint (IDs stay random) +cuid2 --check # Validate an ID (prints ": valid" or ": invalid"; -q for exit code only) +cuid2 -n 5 | cuid2 --check - # Validate newline-delimited IDs from stdin (blank lines are skipped) +python -m cuid2 # Run via module execution ``` -If a framework, dataclass, or Django migration requires a zero-argument callable with a custom length, use `functools.partial` with `create_id`: +If a framework, dataclass, or Django migration requires a zero-argument callable with a custom length, use `cuid_wrapper` (which returns `functools.partial(create_id, length=10)` directly, making it picklable for `multiprocessing` and serializable by Django `makemigrations`): ```python from collections.abc import Callable -from functools import partial -from cuid2 import create_id +from cuid2 import cuid_wrapper -short_cuid_generator: Callable[[], str] = partial(create_id, length=10) +short_cuid_generator: Callable[[], str] = cuid_wrapper(length=10) def main() -> None: diff --git a/local/tests/test_cli.py b/local/tests/test_cli.py new file mode 100644 index 0000000..08a5c00 --- /dev/null +++ b/local/tests/test_cli.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import importlib +import importlib.metadata +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 main as cli_main + +if TYPE_CHECKING: + from unittest.mock import Mock + + +class TestCli: + def test_cli_default_length( + self: TestCli, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that the CLI prints a default-length CUID when invoked without arguments.""" + cli_main([]) + output = capsys.readouterr().out.strip() + assert is_cuid(output, min_length=DEFAULT_LENGTH, max_length=DEFAULT_LENGTH) is True + + monkeypatch.setattr("sys.argv", ["cuid2"]) + cli_main() + output_default_argv = capsys.readouterr().out.strip() + assert is_cuid(output_default_argv, min_length=DEFAULT_LENGTH, max_length=DEFAULT_LENGTH) is True + + def test_cli_module_execution( + self: TestCli, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that running python -m cuid2 invokes the CLI 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 + + @pytest.mark.parametrize("flag", ["-h", "--help"]) + def test_cli_help(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None: + """Tests that -h and --help print usage and flag documentation to stdout and exit 0.""" + with pytest.raises(SystemExit) as exc_info: + cli_main([flag]) + assert exc_info.value.code == 0 + captured = capsys.readouterr() + for expected in ( + "usage: cuid2", + "24", + "2..32", + "--slug", + "--count", + "--fingerprint", + "--check", + "--quiet", + ): + assert expected in captured.out + assert captured.err == "" + + @pytest.mark.parametrize("flag", ["-V", "--version"]) + def test_cli_version(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None: + """Tests that -V and --version print the package version to stdout and exit 0.""" + with pytest.raises(SystemExit) as exc_info: + cli_main([flag]) + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert captured.out == f"cuid2 {importlib.metadata.version('cuid2')}\n" + assert captured.err == "" + + def test_cli_lowercase_v_is_not_version(self: TestCli, capsys: pytest.CaptureFixture[str]) -> None: + """Tests that -v is not a version alias, leaving it free for a conventional verbose flag.""" + with pytest.raises(SystemExit) as exc_info: + cli_main(["-v"]) + assert exc_info.value.code == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "unrecognized arguments: -v" in captured.err + + @pytest.mark.parametrize( + ("args", "expected_length"), + [ + (["2"], 2), + (["10"], 10), + (["32"], 32), + (["-l", "10"], 10), + (["--length", "16"], 16), + (["--slug"], 5), + ], + ) + def test_cli_custom_length( + self: TestCli, + args: list[str], + expected_length: int, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that the CLI prints a CUID of the requested custom length.""" + cli_main(args) + output = capsys.readouterr().out.strip() + assert is_cuid(output, min_length=expected_length, max_length=expected_length) is True + + @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 DeprecationWarning and prints a CUID for deprecated boundary lengths.""" + with pytest.warns(DeprecationWarning, match=r"outside the CUID2 specification range"): + cli_main(args) + output = capsys.readouterr().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"), + ], + ) + def test_cli_invalid_lengths( + self: TestCli, + args: list[str], + expected_substring: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that the CLI exits with code 2 when given an invalid or out-of-range length.""" + with pytest.raises(SystemExit) as exc_info: + cli_main(args) + assert exc_info.value.code == 2 + err = capsys.readouterr().err + assert "cuid2: error:" in err + assert expected_substring in err + + @pytest.mark.parametrize( + "args", + [ + ["10", "-l", "12"], + ["10", "--slug"], + ["-l", "10", "--slug"], + ["10", "-l", "12", "--slug"], + ], + ) + def test_cli_conflicting_length_arguments( + self: TestCli, + args: list[str], + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that specifying multiple length options exits with code 2.""" + with pytest.raises(SystemExit) as exc_info: + cli_main(args) + assert exc_info.value.code == 2 + assert "conflicting length arguments" in capsys.readouterr().err + + @pytest.mark.parametrize("flag", [["-n", "5"], ["-c", "5"], ["--count", "5"]]) + def test_cli_batch_count( + self: TestCli, + flag: list[str], + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that batch count flags print multiple unique CUIDs and instantiate Cuid once.""" + cli_main(flag) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 5 + 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 pytest.warns(DeprecationWarning, match=r"outside the CUID2 specification range") as record: + cli_main(["-l", "33", "-n", "3"]) + assert len(record) == 1 + batch_lines = capsys.readouterr().out.splitlines() + assert len(batch_lines) == 3 + + @pytest.mark.parametrize( + ("args", "expected_substring"), + [ + (["-n", "0"], "count must be at least 1"), + (["-c", "-2"], "count must be at least 1"), + (["--count", "abc"], "invalid int value"), + ], + ) + def test_cli_invalid_count( + self: TestCli, + args: list[str], + expected_substring: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that invalid count values exit with code 2.""" + with pytest.raises(SystemExit) as exc_info: + cli_main(args) + assert exc_info.value.code == 2 + assert expected_substring in capsys.readouterr().err + + def test_cli_custom_fingerprint( + self: TestCli, + mocker: Mock, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that -f/--fingerprint is hashed verbatim (like upstream) and rejects empty strings.""" + hash_spy = mocker.spy(cuid2.utils, "create_hash") + cli_main(["-f", "host-a", "-n", "2"]) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 2 + assert all(is_cuid(item, min_length=DEFAULT_LENGTH, max_length=DEFAULT_LENGTH) is True for item in lines) + assert hash_spy.call_count == 2 + assert all(call.args[0].endswith("host-a") for call in hash_spy.call_args_list) + + hash_spy.reset_mock() + cli_main(["--fingerprint", "host-b"]) + single = capsys.readouterr().out.strip() + assert is_cuid(single, min_length=DEFAULT_LENGTH, max_length=DEFAULT_LENGTH) is True + hash_spy.assert_called_once() + assert hash_spy.call_args.args[0].endswith("host-b") + + with pytest.raises(SystemExit) as exc_info: + cli_main(["-f", ""]) + assert exc_info.value.code == 2 + assert "fingerprint must not be empty" in capsys.readouterr().err + + def test_cli_check_direct_argument(self: TestCli, capsys: pytest.CaptureFixture[str]) -> None: + """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(DeprecationWarning, match=r"outside the CUID2 specification range"): + long_id = create_id(length=40) + + for flag in ("--check", "--validate"): + with pytest.raises(SystemExit) as exc_info: + cli_main([flag, valid_id]) + assert exc_info.value.code == 0 + assert capsys.readouterr().out == f"{valid_id}: valid\n" + + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "NotACuid!"]) + assert exc_info.value.code == 1 + assert capsys.readouterr().out == "NotACuid!: invalid\n" + + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", ""]) + assert exc_info.value.code == 1 + assert capsys.readouterr().out == ": invalid\n" + + for raw, escaped in (("a\x1b[31m\rX", "a\\x1b[31m\\rX"), ("é", "\\xe9")): + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", raw]) + assert exc_info.value.code == 1 + assert capsys.readouterr().out == f"{escaped}: invalid\n" + + for check_args in ( + ["--check", short_id, "-l", "10"], + ["10", "--check", short_id], + ["--slug", "--check", create_id(length=5)], + ): + with pytest.raises(SystemExit) as exc_info: + cli_main(check_args) + assert exc_info.value.code == 0 + capsys.readouterr() + + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", valid_id, "-l", "10"]) + assert exc_info.value.code == 1 + assert capsys.readouterr().out == f"{valid_id}: invalid\n" + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + 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" + + def test_cli_check_stdin_and_quiet( + self: TestCli, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests --check with '-' stdin streams and -q/--quiet suppression.""" + id1 = create_id() + id2 = create_id() + + monkeypatch.setattr("sys.stdin", io.StringIO(f"{id1}\n{id2}\r\n")) + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "-"]) + assert exc_info.value.code == 0 + assert capsys.readouterr().out == f"{id1}: valid\n{id2}: valid\n" + + monkeypatch.setattr("sys.stdin", io.StringIO(f"{id1}\nBAD_ID\n")) + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "-"]) + assert exc_info.value.code == 1 + assert capsys.readouterr().out == f"{id1}: valid\nBAD_ID: invalid\n" + + monkeypatch.setattr("sys.stdin", io.StringIO(f"\n{id1}\n\r\n{id2}\n\n")) + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "-"]) + assert exc_info.value.code == 0 + assert capsys.readouterr().out == f"{id1}: valid\n{id2}: valid\n" + + for empty_stdin in ("", "\n\r\n"): + monkeypatch.setattr("sys.stdin", io.StringIO(empty_stdin)) + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "-"]) + assert exc_info.value.code == 1 + assert capsys.readouterr().out == ": invalid\n" + + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", id1, "-q"]) + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + with pytest.raises(SystemExit) as exc_info: + cli_main(["--validate", "BAD_ID", "--quiet"]) + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + monkeypatch.setattr("sys.stdin", io.StringIO("")) + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "-", "-q"]) + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_cli_check_invalid_flag_combinations( + self: TestCli, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that incompatible flag combinations with --check or -q exit with code 2.""" + valid_id = create_id() + + with pytest.raises(SystemExit) as exc_info: + cli_main(["-q"]) + assert exc_info.value.code == 2 + assert "argument -q/--quiet: only allowed with --check/--validate" in capsys.readouterr().err + + for bad_args in (["--check", valid_id, "-n", "1"], ["--check", valid_id, "-f", "srv"]): + with pytest.raises(SystemExit) as exc_info: + cli_main(bad_args) + 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, + mocker: Mock, + ) -> None: + """Tests that BrokenPipeError on stdout redirects stdout fd to devnull when available.""" + fake_stdout = io.StringIO() + monkeypatch.setattr("sys.stdout", fake_stdout) + write_mock = mocker.patch.object(fake_stdout, "write", side_effect=BrokenPipeError) + cli_main(["-n", "5"]) + write_mock.assert_called_once() + assert fake_stdout.getvalue() == "" + + mocker.patch.object(fake_stdout, "fileno", return_value=1) + open_mock = mocker.patch("os.open", return_value=99) + dup2_mock = mocker.patch("os.dup2") + close_mock = mocker.patch("os.close") + cli_main(["-n", "5"]) + open_mock.assert_called_once_with(os.devnull, os.O_WRONLY) + dup2_mock.assert_called_once_with(99, 1) + close_mock.assert_called_once_with(99) + + def test_cli_check_broken_pipe_is_not_success( + self: TestCli, + monkeypatch: pytest.MonkeyPatch, + mocker: Mock, + ) -> None: + """Tests that --check exits 1 when stdout closes before every candidate is validated.""" + fake_stdout = io.StringIO() + monkeypatch.setattr("sys.stdout", fake_stdout) + mocker.patch.object(fake_stdout, "write", side_effect=BrokenPipeError) + monkeypatch.setattr("sys.stdin", io.StringIO(f"{create_id()}\n{create_id()}\n")) + with pytest.raises(SystemExit) as exc_info: + cli_main(["--check", "-"]) + assert exc_info.value.code == 1 + + def test_cli_does_not_initialize_generator_on_import( + self: TestCli, + mocker: Mock, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Tests that importing cuid2.cli does not initialize a CUID generator or version metadata until needed.""" + try: + cuid_spy = mocker.spy(cuid2.generator, "Cuid") + version_spy = mocker.spy(importlib.metadata, "version") + importlib.reload(cuid2.cli) + assert cuid_spy.call_count == 0 + assert version_spy.call_count == 0 + assert cuid2.generator._default_generator is None + assert not hasattr(cuid2.cli, "generate_cuid") + cuid2.cli.main([]) + assert cuid_spy.call_count == 1 + assert version_spy.call_count == 0 + assert cuid2.generator._default_generator is None + output = capsys.readouterr().out.strip() + assert len(output) == DEFAULT_LENGTH + finally: + mocker.stopall() + importlib.reload(cuid2.cli) diff --git a/local/tests/test_cuid.py b/local/tests/test_cuid.py index c5a5dfa..9301d44 100644 --- a/local/tests/test_cuid.py +++ b/local/tests/test_cuid.py @@ -1,7 +1,8 @@ from __future__ import annotations -import importlib +import pickle import warnings +from functools import partial from math import floor from secrets import SystemRandom from timeit import repeat @@ -9,7 +10,6 @@ import pytest -import cuid2.cli import cuid2.generator from cuid2 import ( BIG_LENGTH, @@ -22,7 +22,6 @@ cuid_wrapper, is_cuid, ) -from cuid2.cli import main as cli_main if TYPE_CHECKING: from collections.abc import Callable @@ -454,8 +453,7 @@ def test_is_cuid_accepts_generated_ids(self: TestIsCuid, length: int) -> None: # Tests that ids generated via generate(length=...) and via the wrapper also validate. def test_is_cuid_accepts_generate_override_and_wrapper(self: TestIsCuid) -> None: cuid = Cuid() - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - wrapper = cuid_wrapper() + wrapper = cuid_wrapper() for _ in range(250): assert Cuid.is_cuid(cuid.generate(length=8)) is True assert Cuid.is_cuid(wrapper()) is True @@ -561,17 +559,18 @@ def test_create_id_invalid_length( class TestCuidWrapper: def test_cuid_wrapper_is_callable(self: TestCuidWrapper) -> None: - """Tests that the function emits DeprecationWarning and returns a callable.""" - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated") as record: - cuid_func: Callable[[], str] = cuid_wrapper() - assert record[0].filename == __file__ + """Tests that cuid_wrapper() returns a functools.partial bound to create_id.""" + cuid_func: Callable[[], str] = cuid_wrapper() assert callable(cuid_func) + assert isinstance(cuid_func, partial) + assert cuid_func.func is create_id + assert cuid_func.args == () + assert cuid_func.keywords == {"length": DEFAULT_LENGTH} def test_cuid_wrapper_delegates_to_create_id_lazily(self: TestCuidWrapper, mocker: Mock) -> None: """Tests that cuid_wrapper() is lazy at creation time and delegates to create_id().""" spy = mocker.spy(cuid2.generator, "create_id") - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - cuid_func: Callable[[], str] = cuid_wrapper(length=10) + cuid_func: Callable[[], str] = cuid_wrapper(length=10) assert cuid2.generator._default_generator is None assert spy.call_count == 0 generated: str = cuid_func() @@ -581,45 +580,51 @@ def test_cuid_wrapper_delegates_to_create_id_lazily(self: TestCuidWrapper, mocke def test_cuid_wrapper_return_type(self: TestCuidWrapper) -> None: """Tests that the function returns a callable that generates a CUID string.""" - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - cuid_func: Callable[[], str] = cuid_wrapper() + cuid_func: Callable[[], str] = cuid_wrapper() assert isinstance(cuid_func(), str) def test_cuid_wrapper_no_parameters(self: TestCuidWrapper) -> None: - """Tests that the function doesn't accept any parameters.""" - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - cuid_func: Callable[[], str] = cuid_wrapper() + """Tests that positional length raises TypeError while keyword override is accepted by partial.""" + cuid_func: Callable[[], str] = cuid_wrapper() with pytest.raises( TypeError, - match="cuid\\(\\) takes 0 positional arguments but 1 was given", + match=r"create_id\(\) got multiple values for argument 'length'", ): cuid_func(DEFAULT_LENGTH) # type: ignore[call-arg] + overridden: str = cuid_func(length=10) # type: ignore[call-arg] + assert len(overridden) == 10 + assert is_cuid(overridden) is True + + def test_cuid_wrapper_pickle_round_trip(self: TestCuidWrapper) -> None: + """Tests that the returned functools.partial can be pickled and unpickled.""" + restored = pickle.loads(pickle.dumps(cuid_wrapper(length=10))) # noqa: S301 + assert isinstance(restored, partial) + assert restored.func is create_id + assert restored.keywords == {"length": 10} + assert len(restored()) == 10 def test_cuid_wrapper_length(self: TestCuidWrapper) -> None: """Tests that the function returns a callable that generates a CUID string with the default length.""" - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - cuid_func: Callable[[], str] = cuid_wrapper() + cuid_func: Callable[[], str] = cuid_wrapper() assert len(cuid_func()) == DEFAULT_LENGTH @pytest.mark.parametrize("length", [MIN_LENGTH, 10, BIG_LENGTH]) def test_cuid_wrapper_custom_length(self: TestCuidWrapper, length: int) -> None: """Tests that cuid_wrapper generates a CUID string with a specified length.""" - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - cuid_func: Callable[[], str] = cuid_wrapper(length=length) + 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 DeprecationWarning for lengths outside MIN_LENGTH..BIG_LENGTH.""" - with pytest.warns( - DeprecationWarning, - match=r"(outside the CUID2 specification range|cuid_wrapper\(\) is deprecated)", - ) as record: + with pytest.warns(DeprecationWarning, match=r"outside the CUID2 specification range") as record: cuid_func: Callable[[], str] = cuid_wrapper(length=length) - assert len(record) == 2 - assert all(warning.filename == __file__ for warning in record) - with pytest.warns(DeprecationWarning, match=r"outside the CUID2 specification range"): + assert len(record) == 1 + assert record[0].filename == __file__ + with pytest.warns(DeprecationWarning, 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 @@ -642,79 +647,3 @@ def test_cuid_wrapper_invalid_length( """Tests that cuid_wrapper raises ValueError when length is out of bounds.""" with pytest.raises(ValueError, match=expected_match): cuid_wrapper(length=invalid_length) - - -class TestCli: - def test_cli_default_length( - self: TestCli, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Tests that the CLI prints a default-length CUID when invoked without arguments.""" - cli_main([]) - output = capsys.readouterr().out.strip() - assert len(output) == DEFAULT_LENGTH - assert output[0].isalpha() - - monkeypatch.setattr("sys.argv", ["cuid2"]) - cli_main() - output_default_argv = capsys.readouterr().out.strip() - assert len(output_default_argv) == DEFAULT_LENGTH - assert output_default_argv[0].isalpha() - - @pytest.mark.parametrize("length", [MIN_LENGTH, 10, BIG_LENGTH]) - def test_cli_custom_length(self: TestCli, length: int, capsys: pytest.CaptureFixture[str]) -> None: - """Tests that the CLI prints a CUID of the requested custom length.""" - cli_main([str(length)]) - output = capsys.readouterr().out.strip() - assert len(output) == length - assert output[0].isalpha() - - @pytest.mark.parametrize("length", [1, MAXIMUM_LENGTH]) - def test_cli_boundary_lengths(self: TestCli, length: int, capsys: pytest.CaptureFixture[str]) -> None: - """Tests that the CLI emits DeprecationWarning and prints a CUID for deprecated boundary lengths.""" - with pytest.warns(DeprecationWarning, match=r"outside the CUID2 specification range"): - cli_main([str(length)]) - output = capsys.readouterr().out.strip() - assert len(output) <= length - if length < MAXIMUM_LENGTH - 1: - assert len(output) == length - assert output[0].isalpha() - - @pytest.mark.parametrize( - ("arg", "expected_match"), - [ - ("0", r"Length must be at least 1 character\."), - ("-5", r"Length must be at least 1 character\."), - (str(MAXIMUM_LENGTH + 1), r"Length must never exceed 98 characters\."), - ], - ) - def test_cli_invalid_lengths(self: TestCli, arg: str, expected_match: str) -> None: - """Tests that the CLI rejects lengths <= 0 and > MAXIMUM_LENGTH.""" - with pytest.raises(ValueError, match=expected_match): - cli_main([arg]) - - def test_cli_non_integer_argument(self: TestCli) -> None: - """Tests that the CLI exits when given a non-integer argument.""" - with pytest.raises(SystemExit): - cli_main(["invalid"]) - - def test_cli_does_not_initialize_generator_on_import( - self: TestCli, - mocker: Mock, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Tests that importing cuid2.cli does not initialize a CUID generator until main() is called.""" - try: - spy = mocker.spy(cuid2.generator, "create_id") - importlib.reload(cuid2.cli) - assert spy.call_count == 0 - assert cuid2.generator._default_generator is None - assert not hasattr(cuid2.cli, "generate_cuid") - cuid2.cli.main([]) - assert spy.call_count == 1 - output = capsys.readouterr().out.strip() - assert len(output) == DEFAULT_LENGTH - finally: - mocker.stopall() - importlib.reload(cuid2.cli) diff --git a/local/tests/test_fuzz.py b/local/tests/test_fuzz.py index f6ba796..ff7c6ac 100644 --- a/local/tests/test_fuzz.py +++ b/local/tests/test_fuzz.py @@ -121,8 +121,7 @@ def test_fuzz_cuid_spec_lengths(seed: int, length: int) -> None: assert len(uid_default) == length assert is_cuid(uid_default, min_length=length, max_length=length) is True - with pytest.warns(DeprecationWarning, match=r"cuid_wrapper\(\) is deprecated"): - wrapper = cuid_wrapper(length=length) + wrapper = cuid_wrapper(length=length) uid_wrapped: str = wrapper() assert len(uid_wrapped) == length assert is_cuid(uid_wrapped, min_length=length, max_length=length) is True diff --git a/local/verify_release.py b/local/verify_release.py index abce98d..a481fb9 100644 --- a/local/verify_release.py +++ b/local/verify_release.py @@ -172,6 +172,7 @@ def expected_wheel_members(version: str) -> set[str]: dist_info = f"cuid2-{version}.dist-info" return { "cuid2/__init__.py", + "cuid2/__main__.py", "cuid2/cli.py", "cuid2/generator.py", "cuid2/utils.py", @@ -205,6 +206,7 @@ def expected_sdist_members(version: str) -> set[str]: f"{sdist_prefix}/pyproject.toml", f"{sdist_prefix}/pyproject.toml.orig", f"{sdist_prefix}/src/cuid2/__init__.py", + f"{sdist_prefix}/src/cuid2/__main__.py", f"{sdist_prefix}/src/cuid2/cli.py", f"{sdist_prefix}/src/cuid2/generator.py", f"{sdist_prefix}/src/cuid2/utils.py", diff --git a/src/cuid2/__main__.py b/src/cuid2/__main__.py new file mode 100644 index 0000000..468e514 --- /dev/null +++ b/src/cuid2/__main__.py @@ -0,0 +1,7 @@ +"""Module execution entry point for ``python -m cuid2``.""" + +from __future__ import annotations + +from cuid2.cli import main + +main() diff --git a/src/cuid2/cli.py b/src/cuid2/cli.py index 59d6a9a..7353e22 100644 --- a/src/cuid2/cli.py +++ b/src/cuid2/cli.py @@ -1,27 +1,270 @@ -"""Command-line interface for generating CUID2 identifiers.""" +"""Command-line interface for generating and validating CUID2 identifiers.""" from __future__ import annotations -from argparse import ArgumentParser +import os +import sys +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 create_id -from cuid2.utils import DEFAULT_LENGTH +from cuid2.generator import Cuid, is_cuid +from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, MAXIMUM_LENGTH, MIN_LENGTH + +__all__ = ["main"] if TYPE_CHECKING: - from collections.abc import Sequence + from _random import Random + from argparse import Namespace + from collections.abc import Iterable, Iterator, Sequence +_SLUG_LENGTH: int = 5 -def main(args: Sequence[str] | None = None) -> None: - """Print out a CUID generated string. Used by the CLI console script.""" - arguments_parser = ArgumentParser() - arguments_parser.add_argument( + +def _write_lines(lines: Iterable[str]) -> None: + try: + for line in lines: + sys.stdout.write(line) + sys.stdout.flush() + except BrokenPipeError: + with suppress(OSError): + stdout_fd = sys.stdout.fileno() + devnull = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull, stdout_fd) + finally: + os.close(devnull) + + +class _VersionAction(Action): + def __call__( + self: _VersionAction, + parser: ArgumentParser, + namespace: Namespace, + values: object, + option_string: str | None = None, + ) -> None: + """Print package version to stdout and exit.""" + del namespace, values, option_string + from importlib.metadata import version # noqa: PLC0415 + + _write_lines((f"{parser.prog} {version('cuid2')}\n",)) + parser.exit(0) + + +def _parse_length(raw: str) -> int: + try: + value = int(raw, 10) + 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" + raise ArgumentTypeError(msg) + if value > MAXIMUM_LENGTH: + msg = f"length must never exceed {MAXIMUM_LENGTH} characters" + raise ArgumentTypeError(msg) + return value + + +def _parse_count(raw: str) -> int: + try: + value = int(raw, 10) + except ValueError as err: + msg = f"invalid int value: {raw!r}" + raise ArgumentTypeError(msg) from err + if value < 1: + msg = "count must be at least 1" + raise ArgumentTypeError(msg) + return value + + +def _parse_fingerprint(raw: str) -> str: + if not raw: + msg = "fingerprint must not be empty" + raise ArgumentTypeError(msg) + return raw + + +def _fixed_fingerprint(random_generator: Random, *, fingerprint: str) -> str: + # Matches @paralleldrive/cuid2 `init({ fingerprint })`, which hashes the string as-is. + del random_generator + return fingerprint + + +def _build_parser() -> ArgumentParser: + parser = ArgumentParser( + prog="cuid2", + description=( + f"Generate or validate collision-resistant CUID2 identifiers " + f"(default length: {DEFAULT_LENGTH}, spec range: {MIN_LENGTH}..{BIG_LENGTH})." + ), + epilog=( + "examples:\n" + " cuid2\n" + " cuid2 -l 10\n" + " cuid2 --slug\n" + " cuid2 -n 5\n" + " cuid2 -f server-1\n" + " cuid2 --check \n" + " cuid2 -n 5 | cuid2 --check -" + ), + formatter_class=RawDescriptionHelpFormatter, + ) + parser.add_argument( + "length", nargs="?", - dest="length", - help="Length of generated cuid2", - type=int, - default=DEFAULT_LENGTH, + type=_parse_length, + default=None, + help=f"Length of CUID2 to generate or validate (default: {DEFAULT_LENGTH}, spec: {MIN_LENGTH}..{BIG_LENGTH}).", + ) + parser.add_argument( + "-V", + "--version", + action=_VersionAction, + nargs=0, + default=SUPPRESS, + help="Show program's version number and exit.", + ) + parser.add_argument( + "-l", + "--length", + dest="opt_length", + metavar="LENGTH", + type=_parse_length, + default=None, + help=f"Length of CUID2 to generate or validate (default: {DEFAULT_LENGTH}, spec: {MIN_LENGTH}..{BIG_LENGTH}).", + ) + parser.add_argument( + "--slug", + action="store_true", + help="Generate or validate a 5-character short CUID2 slug.", + ) + parser.add_argument( + "-n", + "-c", + "--count", + dest="count", + metavar="COUNT", + type=_parse_count, + default=None, + help="Number of CUID2 identifiers to generate (default: 1).", ) - cli_arguments = arguments_parser.parse_args(args) + parser.add_argument( + "-f", + "--fingerprint", + dest="fingerprint", + metavar="FINGERPRINT", + type=_parse_fingerprint, + default=None, + help="Custom fingerprint used verbatim in place of the host fingerprint (IDs remain random).", + ) + parser.add_argument( + "--check", + "--validate", + dest="check", + metavar="ID", + default=None, + help="Validate a CUID2 identifier (or '-' to read newline-delimited IDs from stdin).", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Suppress stdout output in --check/--validate mode.", + ) + return parser + + +def _resolve_length(parser: ArgumentParser, cli_args: Namespace) -> int | None: + sources = [ + val + for val in (cli_args.length, cli_args.opt_length, _SLUG_LENGTH if cli_args.slug else None) + if val is not None + ] + if len(sources) > 1: + parser.error("conflicting length arguments: specify at most one of [length], -l/--length, or --slug") + return sources[0] if sources else None + + +def _validate_mode_flags(parser: ArgumentParser, cli_args: Namespace, target_length: int | None) -> 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]: + if check_arg != "-": + yield check_arg + return + empty = True + for line in sys.stdin: + candidate = line.rstrip("\r\n") + if candidate: + empty = False + yield candidate + if empty: + yield "" + - print(create_id(cli_arguments.length)) # noqa: T201 (print statement) +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 + all_valid = True + exhausted = False + + def _evaluate() -> Iterator[str]: + nonlocal all_valid, exhausted + for candidate in _iter_candidates(check_arg): + valid = is_cuid(candidate, min_length=min_len, max_length=max_len) + all_valid = all_valid and valid + if not quiet: + # ascii() escapes control and non-ASCII characters from untrusted input. + label = ascii(candidate)[1:-1] if candidate else "" + yield f"{label}: {'valid' if valid else 'invalid'}\n" + exhausted = True + + _write_lines(_evaluate()) + # A closed stdout stops evaluation early; unchecked candidates must not count as valid. + return 0 if all_valid and exhausted else 1 + + +def main(args: Sequence[str] | None = None) -> None: + """Execute the ``cuid2`` command-line interface. + + Parameters + ---------- + args : Sequence[str] | None, optional + Command-line argument tokens to parse, by default ``None`` (which reads + ``sys.argv[1:]``). + + Raises + ------ + SystemExit + Raised with exit code ``0`` or ``1`` in ``--check``/``--validate`` or + ``--version``/``--help`` modes, or exit code ``2`` on invalid arguments. + """ + parser = _build_parser() + cli_args = parser.parse_args(args) + target_length = _resolve_length(parser, cli_args) + _validate_mode_flags(parser, cli_args, target_length) + + if cli_args.check is not None: + exit_code = _run_check(cli_args.check, target_length, quiet=cli_args.quiet) + raise SystemExit(exit_code) + + 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 + 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), + ) + ) + _write_lines(f"{cuid_generator.generate()}\n" for _ in range(count)) diff --git a/src/cuid2/generator.py b/src/cuid2/generator.py index e307911..e26423a 100644 --- a/src/cuid2/generator.py +++ b/src/cuid2/generator.py @@ -6,6 +6,7 @@ import re import time import warnings +from functools import partial from math import floor from secrets import SystemRandom from typing import TYPE_CHECKING, ClassVar, Final, Protocol @@ -244,11 +245,7 @@ def create_id(length: int | None = None) -> str: def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]: - """Wrap `create_id` and return a zero-argument callable that generates a CUID string. - - .. deprecated:: 2.1.0 - `cuid_wrapper` is deprecated and will be removed in v3.0.0. - Use `create_id` or `functools.partial(create_id, length=...)` instead. + """Wrap `create_id` in `functools.partial` and return a callable that generates a CUID string. Parameters ---------- @@ -261,7 +258,7 @@ def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]: Returns ------- Callable[[], str] - A callable that generates a CUID string. + A `functools.partial(create_id, length=length)` callable that generates a CUID string. Raises ------ @@ -271,18 +268,7 @@ def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]: Warns ----- DeprecationWarning - Always emitted because `cuid_wrapper` is deprecated and will be removed in v3.0.0, - and also emitted if the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters). + If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters). """ _validate_length(length) - warnings.warn( - "cuid_wrapper() is deprecated and will be removed in v3.0.0. " - "Use create_id() or functools.partial(create_id, length=...) instead.", - DeprecationWarning, - stacklevel=2, - ) - - def cuid() -> str: - return create_id(length=length) - - return cuid + return partial(create_id, length=length)