From f5c3eb0ebe115169ea13cc97a830a643e10802fd Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 29 Oct 2026 19:22:11 -0400 Subject: [PATCH 1/6] feat: implements cuid_wrapper with functools.partial and overhauls CLI UX --- .github/workflows/test_lint_scan.yml | 2 + CHANGELOG.md | 6 + README.md | 19 +- local/tests/test_cuid.py | 399 +++++++++++++++++++++++---- local/tests/test_fuzz.py | 3 +- local/verify_release.py | 2 + src/cuid2/__main__.py | 7 + src/cuid2/cli.py | 236 +++++++++++++++- src/cuid2/generator.py | 24 +- 9 files changed, 602 insertions(+), 96 deletions(-) create mode 100644 src/cuid2/__main__.py diff --git a/.github/workflows/test_lint_scan.yml b/.github/workflows/test_lint_scan.yml index fc26d30..644f2fa 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" diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f00a3..e0cc219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,17 @@ 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`/`-v`/`--version`, `-l`/`--length`, `--slug` (5-character short ID preset), `-n`/`-c`/`--count`, `-f`/`--fingerprint`, and `--check`/`--validate` (with `-` `stdin` and `-q`/`--quiet` support), plus `python -m cuid2` module execution. ### Changed +- `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, 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 length (`<= 0` or `> 98`), count (`<= 0`), and empty fingerprint (`""`) arguments now exit with code `2` and an `argparse` usage error on `stderr` instead of an uncaught `ValueError` traceback. + ## [2.1.0] - 2026-10-25 ### Added diff --git a/README.md b/README.md index 9bbf18e..0145095 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 # Generate with a custom host fingerprint +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 +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_cuid.py b/local/tests/test_cuid.py index c5a5dfa..15e5361 100644 --- a/local/tests/test_cuid.py +++ b/local/tests/test_cuid.py @@ -1,7 +1,13 @@ from __future__ import annotations import importlib +import importlib.metadata +import io +import pickle +import runpy +import sys import warnings +from functools import partial from math import floor from secrets import SystemRandom from timeit import repeat @@ -454,8 +460,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 +566,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 +587,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 @@ -653,66 +665,345 @@ def test_cli_default_length( """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() + 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 len(output_default_argv) == DEFAULT_LENGTH - assert output_default_argv[0].isalpha() + assert is_cuid(output_default_argv, min_length=DEFAULT_LENGTH, max_length=DEFAULT_LENGTH) is True - @pytest.mark.parametrize("length", [MIN_LENGTH, 10, BIG_LENGTH]) - def test_cli_custom_length(self: TestCli, length: int, capsys: pytest.CaptureFixture[str]) -> None: + 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", "-v", "--version"]) + def test_cli_version(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None: + """Tests that -V, -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 == "" + + @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([str(length)]) + cli_main(args) output = capsys.readouterr().out.strip() - assert len(output) == length - assert output[0].isalpha() + assert is_cuid(output, min_length=expected_length, max_length=expected_length) is True - @pytest.mark.parametrize("length", [1, MAXIMUM_LENGTH]) - def test_cli_boundary_lengths(self: TestCli, length: int, capsys: pytest.CaptureFixture[str]) -> None: + @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([str(length)]) + cli_main(args) output = capsys.readouterr().out.strip() assert len(output) <= length - if length < MAXIMUM_LENGTH - 1: + if length <= 50: assert len(output) == length assert output[0].isalpha() @pytest.mark.parametrize( - ("arg", "expected_match"), + ("args", "expected_substring"), [ - ("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\."), + (["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, 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_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 passes fingerprint_data to create_fingerprint and rejects empty strings.""" + spy = mocker.spy(cuid2.cli, "create_fingerprint") + 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) + spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="host-a") + + 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 + spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="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 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" - 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"]) + 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("")) + 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 + + def test_cli_broken_pipe(self: TestCli, mocker: Mock) -> None: + """Tests that BrokenPipeError on stdout is handled gracefully without raising.""" + mocker.patch.object(sys.stdout, "write", side_effect=BrokenPipeError) + cli_main(["-n", "5"]) 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.""" + """Tests that importing cuid2.cli does not initialize a CUID generator or version metadata until needed.""" try: - spy = mocker.spy(cuid2.generator, "create_id") + cuid_spy = mocker.spy(cuid2.generator, "Cuid") + version_spy = mocker.spy(importlib.metadata, "version") importlib.reload(cuid2.cli) - assert spy.call_count == 0 + 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 spy.call_count == 1 + 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: 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..1aa757c 100644 --- a/src/cuid2/cli.py +++ b/src/cuid2/cli.py @@ -1,27 +1,235 @@ -"""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 sys +from argparse import Action, ArgumentParser, ArgumentTypeError, RawDescriptionHelpFormatter +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, create_fingerprint + +__all__ = ["main"] if TYPE_CHECKING: + from argparse import Namespace from collections.abc import 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_output(text: str) -> None: + try: + sys.stdout.write(text) + sys.stdout.flush() + except BrokenPipeError: + return + + +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_output(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 _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", + "-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.", ) - cli_arguments = arguments_parser.parse_args(args) + parser.add_argument( + "-n", + "-c", + "--count", + dest="count", + metavar="COUNT", + type=_parse_count, + default=None, + help="Number of CUID2 identifiers to generate (default: 1).", + ) + parser.add_argument( + "-f", + "--fingerprint", + dest="fingerprint", + metavar="FINGERPRINT", + type=_parse_fingerprint, + default=None, + help="Custom host/process fingerprint seed string.", + ) + 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) -> 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") + - print(create_id(cli_arguments.length)) # noqa: T201 (print statement) +def _run_check(check_arg: str, target_length: int | None, *, quiet: bool) -> int: + candidates = [line.rstrip("\r\n") for line in sys.stdin] if check_arg == "-" else [check_arg] + if not candidates: + candidates = [""] + 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 + lines: list[str] = [] + for candidate in candidates: + valid = is_cuid(candidate, min_length=min_len, max_length=max_len) + all_valid = all_valid and valid + lines.append(f"{candidate or ''}: {'valid' if valid else 'invalid'}\n") + if not quiet: + _write_output("".join(lines)) + return 0 if all_valid 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) + + 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(create_fingerprint, fingerprint_data=cli_args.fingerprint), + ) + ) + output = "".join(f"{cuid_generator.generate()}\n" for _ in range(count)) + _write_output(output) 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) From a11795a7d22fcc00920292bc02934a6473404548 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 29 Oct 2026 20:07:49 -0400 Subject: [PATCH 2/6] refactor(cli): streams CLI I/O and extracts test_cli.py --- local/tests/test_cli.py | 389 +++++++++++++++++++++++++++++++++++++++ local/tests/test_cuid.py | 362 ------------------------------------ src/cuid2/cli.py | 54 ++++-- 3 files changed, 426 insertions(+), 379 deletions(-) create mode 100644 local/tests/test_cli.py diff --git a/local/tests/test_cli.py b/local/tests/test_cli.py new file mode 100644 index 0000000..613f27b --- /dev/null +++ b/local/tests/test_cli.py @@ -0,0 +1,389 @@ +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 +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", "-v", "--version"]) + def test_cli_version(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None: + """Tests that -V, -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 == "" + + @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 passes fingerprint_data to create_fingerprint and rejects empty strings.""" + spy = mocker.spy(cuid2.cli, "create_fingerprint") + 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) + spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="host-a") + + 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 + spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="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 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("")) + 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 + + 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) + mocker.patch.object(fake_stdout, "write", side_effect=BrokenPipeError) + cli_main(["-n", "5"]) + + 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_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 15e5361..9301d44 100644 --- a/local/tests/test_cuid.py +++ b/local/tests/test_cuid.py @@ -1,11 +1,6 @@ from __future__ import annotations -import importlib -import importlib.metadata -import io import pickle -import runpy -import sys import warnings from functools import partial from math import floor @@ -15,7 +10,6 @@ import pytest -import cuid2.cli import cuid2.generator from cuid2 import ( BIG_LENGTH, @@ -28,7 +22,6 @@ cuid_wrapper, is_cuid, ) -from cuid2.cli import main as cli_main if TYPE_CHECKING: from collections.abc import Callable @@ -654,358 +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 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", "-v", "--version"]) - def test_cli_version(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None: - """Tests that -V, -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 == "" - - @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 passes fingerprint_data to create_fingerprint and rejects empty strings.""" - spy = mocker.spy(cuid2.cli, "create_fingerprint") - 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) - spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="host-a") - - 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 - spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="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 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("")) - 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 - - def test_cli_broken_pipe(self: TestCli, mocker: Mock) -> None: - """Tests that BrokenPipeError on stdout is handled gracefully without raising.""" - mocker.patch.object(sys.stdout, "write", side_effect=BrokenPipeError) - cli_main(["-n", "5"]) - - 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/src/cuid2/cli.py b/src/cuid2/cli.py index 1aa757c..6e67c9d 100644 --- a/src/cuid2/cli.py +++ b/src/cuid2/cli.py @@ -2,8 +2,10 @@ from __future__ import annotations +import os import sys from argparse import Action, ArgumentParser, ArgumentTypeError, RawDescriptionHelpFormatter +from contextlib import suppress from functools import partial from typing import TYPE_CHECKING @@ -14,17 +16,24 @@ if TYPE_CHECKING: from argparse import Namespace - from collections.abc import Sequence + from collections.abc import Iterable, Iterator, Sequence _SLUG_LENGTH: int = 5 -def _write_output(text: str) -> None: +def _write_lines(lines: Iterable[str]) -> None: try: - sys.stdout.write(text) + for line in lines: + sys.stdout.write(line) sys.stdout.flush() except BrokenPipeError: - return + 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): @@ -39,7 +48,7 @@ def __call__( del namespace, values, option_string from importlib.metadata import version # noqa: PLC0415 - _write_output(f"{parser.prog} {version('cuid2')}\n") + _write_lines((f"{parser.prog} {version('cuid2')}\n",)) parser.exit(0) @@ -180,20 +189,32 @@ def _validate_mode_flags(parser: ArgumentParser, cli_args: Namespace) -> None: parser.error("arguments -n/-c/--count and -f/--fingerprint are not allowed with --check/--validate") +def _iter_candidates(check_arg: str) -> Iterator[str]: + if check_arg != "-": + yield check_arg + return + empty = True + for line in sys.stdin: + empty = False + yield line.rstrip("\r\n") + if empty: + yield "" + + def _run_check(check_arg: str, target_length: int | None, *, quiet: bool) -> int: - candidates = [line.rstrip("\r\n") for line in sys.stdin] if check_arg == "-" else [check_arg] - if not candidates: - candidates = [""] 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 - lines: list[str] = [] - for candidate in candidates: - valid = is_cuid(candidate, min_length=min_len, max_length=max_len) - all_valid = all_valid and valid - lines.append(f"{candidate or ''}: {'valid' if valid else 'invalid'}\n") - if not quiet: - _write_output("".join(lines)) + + def _evaluate() -> Iterator[str]: + nonlocal all_valid + 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: + yield f"{candidate or ''}: {'valid' if valid else 'invalid'}\n" + + _write_lines(_evaluate()) return 0 if all_valid else 1 @@ -231,5 +252,4 @@ def main(args: Sequence[str] | None = None) -> None: fingerprint=partial(create_fingerprint, fingerprint_data=cli_args.fingerprint), ) ) - output = "".join(f"{cuid_generator.generate()}\n" for _ in range(count)) - _write_output(output) + _write_lines(f"{cuid_generator.generate()}\n" for _ in range(count)) From 0b628793e04d7ddc8526b9e2d6476aa5f3da2c5a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 30 Oct 2026 08:36:07 -0400 Subject: [PATCH 3/6] fix(cli): hardens --check exit codes and uses -f fingerprint verbatim --- CHANGELOG.md | 4 +-- README.md | 4 +-- local/tests/test_cli.py | 56 ++++++++++++++++++++++++++++++++++------- src/cuid2/cli.py | 40 ++++++++++++++++++++--------- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0cc219..6b0c8d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ 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`/`-v`/`--version`, `-l`/`--length`, `--slug` (5-character short ID preset), `-n`/`-c`/`--count`, `-f`/`--fingerprint`, and `--check`/`--validate` (with `-` `stdin` and `-q`/`--quiet` support), plus `python -m cuid2` module execution. +- CLI flags `-V`/`-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 @@ -21,7 +21,7 @@ This repository continues the `overflowdigital` `cuid2` package (`b02c8ca`); ver ### Fixed -- CLI out-of-range length (`<= 0` or `> 98`), count (`<= 0`), and empty fingerprint (`""`) arguments now exit with code `2` and an `argparse` usage error on `stderr` instead of an uncaught `ValueError` traceback. +- 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 0145095..79ed6a6 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,9 @@ 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 # Generate with a custom host fingerprint +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 +cuid2 -n 5 | cuid2 --check - # Validate newline-delimited IDs from stdin (blank lines are skipped) python -m cuid2 # Run via module execution ``` diff --git a/local/tests/test_cli.py b/local/tests/test_cli.py index 613f27b..92aa339 100644 --- a/local/tests/test_cli.py +++ b/local/tests/test_cli.py @@ -12,6 +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 main as cli_main @@ -213,19 +214,21 @@ def test_cli_custom_fingerprint( mocker: Mock, capsys: pytest.CaptureFixture[str], ) -> None: - """Tests that -f/--fingerprint passes fingerprint_data to create_fingerprint and rejects empty strings.""" - spy = mocker.spy(cuid2.cli, "create_fingerprint") + """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) - spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="host-a") + assert hash_spy.call_count == 2 + assert all(call.args[0].endswith("host-a") for call in hash_spy.call_args_list) - spy.reset_mock() + 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 - spy.assert_called_once_with(random_generator=mocker.ANY, fingerprint_data="host-b") + 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", ""]) @@ -255,6 +258,12 @@ def test_cli_check_direct_argument(self: TestCli, capsys: pytest.CaptureFixture[ 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], @@ -298,11 +307,18 @@ def test_cli_check_stdin_and_quiet( assert exc_info.value.code == 1 assert capsys.readouterr().out == f"{id1}: valid\nBAD_ID: invalid\n" - monkeypatch.setattr("sys.stdin", io.StringIO("")) + 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 == 1 - assert capsys.readouterr().out == ": invalid\n" + 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"]) @@ -344,6 +360,12 @@ 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, @@ -352,8 +374,10 @@ def test_cli_broken_pipe( """Tests that BrokenPipeError on stdout redirects stdout fd to devnull when available.""" fake_stdout = io.StringIO() monkeypatch.setattr("sys.stdout", fake_stdout) - mocker.patch.object(fake_stdout, "write", side_effect=BrokenPipeError) + 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) @@ -364,6 +388,20 @@ def test_cli_broken_pipe( 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, diff --git a/src/cuid2/cli.py b/src/cuid2/cli.py index 6e67c9d..e726b19 100644 --- a/src/cuid2/cli.py +++ b/src/cuid2/cli.py @@ -4,17 +4,18 @@ import os import sys -from argparse import Action, ArgumentParser, ArgumentTypeError, RawDescriptionHelpFormatter +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, create_fingerprint +from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, MAXIMUM_LENGTH, MIN_LENGTH __all__ = ["main"] if TYPE_CHECKING: + from _random import Random from argparse import Namespace from collections.abc import Iterable, Iterator, Sequence @@ -86,6 +87,12 @@ def _parse_fingerprint(raw: str) -> str: 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", @@ -118,7 +125,7 @@ def _build_parser() -> ArgumentParser: "--version", action=_VersionAction, nargs=0, - default="==SUPPRESS==", + default=SUPPRESS, help="Show program's version number and exit.", ) parser.add_argument( @@ -152,7 +159,7 @@ def _build_parser() -> ArgumentParser: metavar="FINGERPRINT", type=_parse_fingerprint, default=None, - help="Custom host/process fingerprint seed string.", + help="Custom fingerprint used verbatim in place of the host fingerprint (IDs remain random).", ) parser.add_argument( "--check", @@ -182,11 +189,13 @@ 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) -> 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]: @@ -195,8 +204,10 @@ def _iter_candidates(check_arg: str) -> Iterator[str]: return empty = True for line in sys.stdin: - empty = False - yield line.rstrip("\r\n") + candidate = line.rstrip("\r\n") + if candidate: + empty = False + yield candidate if empty: yield "" @@ -205,17 +216,22 @@ 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 + 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: - yield f"{candidate or ''}: {'valid' if valid else 'invalid'}\n" + # 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()) - return 0 if all_valid else 1 + # 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: @@ -236,7 +252,7 @@ def main(args: Sequence[str] | None = None) -> None: parser = _build_parser() cli_args = parser.parse_args(args) target_length = _resolve_length(parser, cli_args) - _validate_mode_flags(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) @@ -249,7 +265,7 @@ def main(args: Sequence[str] | None = None) -> None: if cli_args.fingerprint is None else Cuid( length=resolved_length, - fingerprint=partial(create_fingerprint, fingerprint_data=cli_args.fingerprint), + fingerprint=partial(_fixed_fingerprint, fingerprint=cli_args.fingerprint), ) ) _write_lines(f"{cuid_generator.generate()}\n" for _ in range(count)) From 4db849dd9ee6029956545b6e2e823b5ad961c45b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 30 Oct 2026 09:41:46 -0400 Subject: [PATCH 4/6] ci: sets a fixed CodeQL analysis category --- .github/workflows/test_lint_scan.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test_lint_scan.yml b/.github/workflows/test_lint_scan.yml index 644f2fa..78b6d51 100644 --- a/.github/workflows/test_lint_scan.yml +++ b/.github/workflows/test_lint_scan.yml @@ -141,6 +141,9 @@ jobs: languages: python - name: Perform Runtime Code Analysis uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + # Fixed so every caller (CI, release) uploads to one code-scanning configuration. + category: "/language:python" env: CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION: "3.13" - name: Perform Secure Code Analysis (Secrets) From 486bdc1ca93040fe83a30d4966852e6e4fd64d95 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 30 Oct 2026 09:41:46 -0400 Subject: [PATCH 5/6] fix(cli): drops the -v version alias --- CHANGELOG.md | 4 ++-- local/tests/test_cli.py | 13 +++++++++++-- src/cuid2/cli.py | 1 - 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0c8d7..5140e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,12 @@ 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`/`-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`. +- 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 - `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, 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`. +- 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 diff --git a/local/tests/test_cli.py b/local/tests/test_cli.py index 92aa339..08a5c00 100644 --- a/local/tests/test_cli.py +++ b/local/tests/test_cli.py @@ -67,9 +67,9 @@ def test_cli_help(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) assert expected in captured.out assert captured.err == "" - @pytest.mark.parametrize("flag", ["-V", "-v", "--version"]) + @pytest.mark.parametrize("flag", ["-V", "--version"]) def test_cli_version(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str]) -> None: - """Tests that -V, -v, and --version print the package version to stdout and exit 0.""" + """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 @@ -77,6 +77,15 @@ def test_cli_version(self: TestCli, flag: str, capsys: pytest.CaptureFixture[str 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"), [ diff --git a/src/cuid2/cli.py b/src/cuid2/cli.py index e726b19..7353e22 100644 --- a/src/cuid2/cli.py +++ b/src/cuid2/cli.py @@ -121,7 +121,6 @@ def _build_parser() -> ArgumentParser: ) parser.add_argument( "-V", - "-v", "--version", action=_VersionAction, nargs=0, From 913fad563b2af6513d4af856082d376b7bffd375 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 30 Oct 2026 09:44:08 -0400 Subject: [PATCH 6/6] ci: removes redundant CodeQL category comment --- .github/workflows/test_lint_scan.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test_lint_scan.yml b/.github/workflows/test_lint_scan.yml index 78b6d51..424776f 100644 --- a/.github/workflows/test_lint_scan.yml +++ b/.github/workflows/test_lint_scan.yml @@ -142,7 +142,6 @@ jobs: - name: Perform Runtime Code Analysis uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: - # Fixed so every caller (CI, release) uploads to one code-scanning configuration. category: "/language:python" env: CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION: "3.13"