diff --git a/tests/test_basic.py b/tests/test_basic.py index 3ab9654b1c..9fbb624fbc 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -960,3 +960,27 @@ def cli(): result = runner.invoke(cli, ["--version"], prog_name="mytool") assert result.exit_code == 0 assert result.output == "mytool 1.0\n" + + +@pytest.mark.parametrize( + ("nargs", "multiple", "default", "expect"), + [ + (2, False, None, None), + (2, False, (None, None), (None, None)), + (None, True, None, ()), + (None, True, (None, None), (None, None)), + (2, True, None, ()), + (2, True, [(None, None)], ((None, None),)), + (-1, None, None, ()), + ], +) +def test_cast_multi_default(runner, nargs, multiple, default, expect): + if nargs == -1: + param = click.Argument(["a"], nargs=nargs, default=default) + else: + param = click.Option(["-a"], nargs=nargs, multiple=multiple, default=default) + + cli = click.Command("cli", params=[param], callback=lambda a: a) + result = runner.invoke(cli, standalone_mode=False) + assert result.exception is None + assert result.return_value == expect diff --git a/tests/test_exceptions/__init__.py b/tests/test_exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_exceptions/test_FileError.py b/tests/test_exceptions/test_FileError.py new file mode 100644 index 0000000000..8abc15d1ff --- /dev/null +++ b/tests/test_exceptions/test_FileError.py @@ -0,0 +1,6 @@ +import click + + +def test_file_error_surrogates(): + message = click.FileError(filename="\udcff").format_message() + assert message == "Could not open file '�': unknown error" diff --git a/tests/test_types.py b/tests/test_types.py deleted file mode 100644 index 0d15915189..0000000000 --- a/tests/test_types.py +++ /dev/null @@ -1,438 +0,0 @@ -import datetime -import os.path -import pathlib -import platform -import subprocess -import sys -import tempfile -import typing as t -import uuid - -import pytest - -import click -from click import FileError -from click.types import convert_type -from click.types import FuncParamType - - -@pytest.mark.parametrize( - ("type", "value", "expect"), - [ - (click.IntRange(0, 5), "3", 3), - (click.IntRange(5), "5", 5), - (click.IntRange(5), "100", 100), - (click.IntRange(max=5), "5", 5), - (click.IntRange(max=5), "-100", -100), - (click.IntRange(0, clamp=True), "-1", 0), - (click.IntRange(max=5, clamp=True), "6", 5), - (click.IntRange(0, min_open=True, clamp=True), "0", 1), - (click.IntRange(max=5, max_open=True, clamp=True), "5", 4), - (click.FloatRange(0.5, 1.5), "1.2", 1.2), - (click.FloatRange(0.5, min_open=True), "0.51", 0.51), - (click.FloatRange(max=1.5, max_open=True), "1.49", 1.49), - (click.FloatRange(0.5, clamp=True), "-0.0", 0.5), - (click.FloatRange(max=1.5, clamp=True), "inf", 1.5), - ], -) -def test_range(type, value, expect): - assert type.convert(value, None, None) == expect - - -@pytest.mark.parametrize( - ("type", "value", "expect"), - [ - (click.IntRange(0, 5), "6", "6 is not in the range 0<=x<=5."), - (click.IntRange(5), "4", "4 is not in the range x>=5."), - (click.IntRange(max=5), "6", "6 is not in the range x<=5."), - (click.IntRange(0, 5, min_open=True), 0, "00.5"), - (click.FloatRange(max=1.5, max_open=True), 1.5, "x<1.5"), - ], -) -def test_range_fail(type, value, expect): - with pytest.raises(click.BadParameter) as exc_info: - type.convert(value, None, None) - - assert expect in exc_info.value.message - - -@pytest.mark.parametrize( - ("error_message", "expected"), - [ - ("bad value: nope", "bad value: nope"), - ("", "nope"), - ], -) -def test_func_param_type_uses_value_error_message(error_message, expected): - def parse(value): - raise ValueError(error_message if error_message else "") - - func_type = click.types.FuncParamType(parse) - - with pytest.raises(click.BadParameter) as exc_info: - func_type.convert("nope", None, None) - - assert expected in exc_info.value.message - - -def test_float_range_no_clamp_open(): - with pytest.raises(TypeError): - click.FloatRange(0, 1, max_open=True, clamp=True) - - sneaky = click.FloatRange(0, 1, max_open=True) - sneaky.clamp = True - - with pytest.raises(RuntimeError): - sneaky.convert("1.5", None, None) - - -@pytest.mark.parametrize( - ("nargs", "multiple", "default", "expect"), - [ - (2, False, None, None), - (2, False, (None, None), (None, None)), - (None, True, None, ()), - (None, True, (None, None), (None, None)), - (2, True, None, ()), - (2, True, [(None, None)], ((None, None),)), - (-1, None, None, ()), - ], -) -def test_cast_multi_default(runner, nargs, multiple, default, expect): - if nargs == -1: - param = click.Argument(["a"], nargs=nargs, default=default) - else: - param = click.Option(["-a"], nargs=nargs, multiple=multiple, default=default) - - cli = click.Command("cli", params=[param], callback=lambda a: a) - result = runner.invoke(cli, standalone_mode=False) - assert result.exception is None - assert result.return_value == expect - - -@pytest.mark.parametrize( - ("cls", "expect"), - [ - (None, "a/b/c.txt"), - (str, "a/b/c.txt"), - (bytes, b"a/b/c.txt"), - (pathlib.Path, pathlib.Path("a", "b", "c.txt")), - ], -) -def test_path_type(runner, cls, expect): - cli = click.Command( - "cli", - params=[click.Argument(["p"], type=click.Path(path_type=cls))], - callback=lambda p: p, - ) - result = runner.invoke(cli, ["a/b/c.txt"], standalone_mode=False) - assert result.exception is None - assert result.return_value == expect - - -def test_path_dash_no_byteswarning(): - """Detecting the ``-`` dash sentinel must not compare ``bytes`` against - ``str``, which raises a ``BytesWarning`` under ``python -bb``. - - The warning is only emitted when the interpreter runs with ``-b``, so this - has to be checked in a subprocess. ``-bb`` turns the warning into an error, - so a clean exit means no mismatched comparison happened. - """ - program = ( - "import click\n" - "convert = click.Path(allow_dash=True).convert\n" - "for value in ('-', '', b'-', b''):\n" - " convert(value, None, None)\n" - ) - result = subprocess.run( - [sys.executable, "-bb", "-c", program], - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - assert "BytesWarning" not in result.stderr - - -def _symlinks_supported(): - with tempfile.TemporaryDirectory(prefix="click-pytest-") as tempdir: - target = os.path.join(tempdir, "target") - open(target, "w").close() - link = os.path.join(tempdir, "link") - - try: - os.symlink(target, link) - return True - except OSError: - return False - - -@pytest.mark.skipif( - not _symlinks_supported(), reason="The current OS or FS doesn't support symlinks." -) -def test_path_resolve_symlink(tmp_path, runner): - test_file = tmp_path / "file" - test_file_str = os.fspath(test_file) - test_file.write_text("") - - path_type = click.Path(resolve_path=True) - param = click.Argument(["a"], type=path_type) - ctx = click.Context(click.Command("cli", params=[param])) - - test_dir = tmp_path / "dir" - test_dir.mkdir() - - abs_link = test_dir / "abs" - abs_link.symlink_to(test_file) - abs_rv = path_type.convert(os.fspath(abs_link), param, ctx) - assert abs_rv == test_file_str - - rel_link = test_dir / "rel" - rel_link.symlink_to(pathlib.Path("..") / "file") - rel_rv = path_type.convert(os.fspath(rel_link), param, ctx) - assert rel_rv == test_file_str - - -def _non_utf8_filenames_supported(): - with tempfile.TemporaryDirectory(prefix="click-pytest-") as tempdir: - try: - f = open(os.path.join(tempdir, "\udcff"), "w") - except OSError: - return False - - f.close() - return True - - -@pytest.mark.skipif( - not _non_utf8_filenames_supported(), - reason="The current OS or FS doesn't support non-UTF-8 filenames.", -) -def test_path_surrogates(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - type = click.Path(exists=True) - path = pathlib.Path("\udcff") - - with pytest.raises(click.BadParameter, match="'�' does not exist"): - type.convert(path, None, None) - - type = click.Path(file_okay=False) - path.touch() - - with pytest.raises(click.BadParameter, match="'�' is a file"): - type.convert(path, None, None) - - path.unlink() - type = click.Path(dir_okay=False) - path.mkdir() - - with pytest.raises(click.BadParameter, match="'�' is a directory"): - type.convert(path, None, None) - - path.rmdir() - - def no_access(*args, **kwargs): - """Test environments may be running as root, so we have to fake the result of - the access tests that use os.access - """ - p = args[0] - assert p == path, f"unexpected os.access call on file not under test: {p!r}" - return False - - path.touch() - type = click.Path(readable=True) - - with pytest.raises(click.BadParameter, match="'�' is not readable"): - with monkeypatch.context() as m: - m.setattr(os, "access", no_access) - type.convert(path, None, None) - - type = click.Path(readable=False, writable=True) - - with pytest.raises(click.BadParameter, match="'�' is not writable"): - with monkeypatch.context() as m: - m.setattr(os, "access", no_access) - type.convert(path, None, None) - - type = click.Path(readable=False, executable=True) - - with pytest.raises(click.BadParameter, match="'�' is not executable"): - with monkeypatch.context() as m: - m.setattr(os, "access", no_access) - type.convert(path, None, None) - - path.unlink() - - -@pytest.mark.parametrize( - "type", - [ - click.File(mode="r"), - click.File(mode="r", lazy=True), - ], -) -def test_file_surrogates(type, tmp_path): - """Ensures that the error handling in ``click.File`` is robust. - - ``EILSEQ`` shows up with rootless Podman (FUSE-backed paths) and on filesystems - that reject non-UTF-8 names, like ZFS with ``utf8only=on``. - - See: https://github.com/pallets/click/issues/2634 - """ - path = tmp_path / "\udcff" - match = ( - # Common case: �': No such file or directory. - r"(�': No such file or directory" - # BSD/macOS libc special case (EILSEQ). - r"|Illegal byte sequence" - # glibc special case (EILSEQ). - r"|Invalid or incomplete multibyte or wide character)" - ) - with pytest.raises(click.BadParameter, match=match): - type.convert(path, None, None) - - -def test_file_error_surrogates(): - message = FileError(filename="\udcff").format_message() - assert message == "Could not open file '�': unknown error" - - -@pytest.mark.skipif( - platform.system() == "Windows", reason="Filepath syntax differences." -) -def test_invalid_path_with_esc_sequence(): - with pytest.raises(click.BadParameter) as exc_info: - with tempfile.TemporaryDirectory(prefix="my\ndir") as tempdir: - click.Path(dir_okay=False).convert(tempdir, None, None) - - assert "my\\ndir" in exc_info.value.message - - -def test_choice_get_invalid_choice_message(): - choice = click.Choice(["a", "b", "c"]) - message = choice.get_invalid_choice_message("d", ctx=None) - assert message == "'d' is not one of 'a', 'b', 'c'." - - -def test_param_type_input_parameter_defaults_at_runtime(): - """Omitting the input type parameter works at runtime on every - supported Python. The ``Any`` default is native (PEP 696) on Python - 3.13+, and backfilled by ``ParamType.__class_getitem__`` before - that.""" - assert t.get_args(click.ParamType[int]) == (int, t.Any) - assert t.get_args(click.ParamType[int, str]) == (int, str) - - -def test_param_type_subclass_omitting_input_parameter(): - class DoublingType(click.ParamType[int]): - name = "doubling" - - def convert(self, value, param, ctx): - return int(value) * 2 - - doubling = DoublingType() - assert doubling("21") == 42 - assert doubling(None) is None - - -@pytest.mark.parametrize( - "default", - [ - # Empty sequences fall back to STRING because ``_guess_type`` returns None. - [], - (), - # Container types are guessed from the default but are not natively - # supported, so they reach STRING through a different code path. - set(), - {1, 2}, - frozenset(), - frozenset(["git"]), - {}, - {"a": 1}, - ], -) -def test_convert_type_from_container_default(default): - """Container defaults are not natively supported and fall back to STRING. - - Refs: https://github.com/pallets/click/issues/3036 - """ - assert convert_type(None, default) is click.STRING - - -@pytest.mark.parametrize( - ("container_type", "value", "expected"), - [ - (set, "a,b,c", {"a", ",", "b", "c"}), - (frozenset, "abc", frozenset({"a", "b", "c"})), - ], -) -def test_explicit_container_type_splits_string(container_type, value, expected): - """An explicit ``set`` or ``frozenset`` type wraps the builtin in a - ``FuncParamType``, which splits CLI strings character-wise. - - Refs: https://github.com/pallets/click/issues/3036 - """ - param_type = convert_type(container_type) - assert isinstance(param_type, FuncParamType) - assert param_type.convert(value, None, None) == expected - - -def test_explicit_dict_type_rejects_string(): - """An explicit ``dict`` type cannot convert a plain CLI string at all. - - Refs: https://github.com/pallets/click/issues/3036 - """ - param_type = convert_type(dict) - assert isinstance(param_type, FuncParamType) - with pytest.raises(click.BadParameter): - param_type.convert("abc", None, None) - - -@pytest.mark.parametrize( - ("default", "expected"), - [ - # Recognized scalars. - (None, click.STRING), - ("git", click.STRING), - (5, click.INT), - (1.5, click.FLOAT), - (True, click.BOOL), - # An empty sequence gives no item to read. - ([], click.STRING), - ((), click.STRING), - # A sequence gives the type of its first item only. - ([1, 2], click.INT), - ((1, 2), click.INT), - ([1.5, 2.5], click.FLOAT), - ([1, "git"], click.INT), - # Everything else falls back to STRING, including types Click ships. - ({1, 2}, click.STRING), - (frozenset({1, 2}), click.STRING), - ({"a": 1}, click.STRING), - (uuid.UUID(int=0), click.STRING), - (datetime.datetime(2026, 1, 1), click.STRING), - (b"git", click.STRING), - (object(), click.STRING), - ], -) -def test_type_inferred_from_default(default, expected): - """Every row of the type-inference table in ``docs/parameter-types.md``. - - Keep the two in step: a change here is a change to documented behavior. - - Refs: https://github.com/pallets/click/issues/3036 - """ - assert convert_type(None, default) is expected - - -def test_type_inferred_from_nested_sequence_default(): - """A sequence of sequences gives a composite ``Tuple`` of the inner types. - - This is the one table row that is not a singleton ``ParamType``. - - Refs: https://github.com/pallets/click/issues/3036 - """ - param_type = convert_type(None, [(1, "git")]) - assert isinstance(param_type, click.Tuple) - assert param_type.types == [click.INT, click.STRING] diff --git a/tests/test_types/__init__.py b/tests/test_types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_types/test_Choice.py b/tests/test_types/test_Choice.py new file mode 100644 index 0000000000..c34f516166 --- /dev/null +++ b/tests/test_types/test_Choice.py @@ -0,0 +1,7 @@ +import click + + +def test_choice_get_invalid_choice_message(): + choice = click.Choice(["a", "b", "c"]) + message = choice.get_invalid_choice_message("d", ctx=None) + assert message == "'d' is not one of 'a', 'b', 'c'." diff --git a/tests/test_types/test_File.py b/tests/test_types/test_File.py new file mode 100644 index 0000000000..4c67ed1331 --- /dev/null +++ b/tests/test_types/test_File.py @@ -0,0 +1,31 @@ +import pytest + +import click + + +@pytest.mark.parametrize( + "type", + [ + click.File(mode="r"), + click.File(mode="r", lazy=True), + ], +) +def test_file_surrogates(type, tmp_path): + """Ensures that the error handling in ``click.File`` is robust. + + ``EILSEQ`` shows up with rootless Podman (FUSE-backed paths) and on filesystems + that reject non-UTF-8 names, like ZFS with ``utf8only=on``. + + See: https://github.com/pallets/click/issues/2634 + """ + path = tmp_path / "\udcff" + match = ( + # Common case: �': No such file or directory. + r"(�': No such file or directory" + # BSD/macOS libc special case (EILSEQ). + r"|Illegal byte sequence" + # glibc special case (EILSEQ). + r"|Invalid or incomplete multibyte or wide character)" + ) + with pytest.raises(click.BadParameter, match=match): + type.convert(path, None, None) diff --git a/tests/test_types/test_FloatRange.py b/tests/test_types/test_FloatRange.py new file mode 100644 index 0000000000..53fd429faf --- /dev/null +++ b/tests/test_types/test_FloatRange.py @@ -0,0 +1,42 @@ +import pytest + +import click + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.FloatRange(0.5, 1.5), "1.2", 1.2), + (click.FloatRange(0.5, min_open=True), "0.51", 0.51), + (click.FloatRange(max=1.5, max_open=True), "1.49", 1.49), + (click.FloatRange(0.5, clamp=True), "-0.0", 0.5), + (click.FloatRange(max=1.5, clamp=True), "inf", 1.5), + ], +) +def test_range(type, value, expect): + assert type.convert(value, None, None) == expect + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.FloatRange(0.5, min_open=True), 0.5, "x>0.5"), + (click.FloatRange(max=1.5, max_open=True), 1.5, "x<1.5"), + ], +) +def test_range_fail(type, value, expect): + with pytest.raises(click.BadParameter) as exc_info: + type.convert(value, None, None) + + assert expect in exc_info.value.message + + +def test_float_range_no_clamp_open(): + with pytest.raises(TypeError): + click.FloatRange(0, 1, max_open=True, clamp=True) + + sneaky = click.FloatRange(0, 1, max_open=True) + sneaky.clamp = True + + with pytest.raises(RuntimeError): + sneaky.convert("1.5", None, None) diff --git a/tests/test_types/test_FuncParamType.py b/tests/test_types/test_FuncParamType.py new file mode 100644 index 0000000000..4d787e9a21 --- /dev/null +++ b/tests/test_types/test_FuncParamType.py @@ -0,0 +1,22 @@ +import pytest + +import click + + +@pytest.mark.parametrize( + ("error_message", "expected"), + [ + ("bad value: nope", "bad value: nope"), + ("", "nope"), + ], +) +def test_func_param_type_uses_value_error_message(error_message, expected): + def parse(value): + raise ValueError(error_message if error_message else "") + + func_type = click.types.FuncParamType(parse) + + with pytest.raises(click.BadParameter) as exc_info: + func_type.convert("nope", None, None) + + assert expected in exc_info.value.message diff --git a/tests/test_types/test_IntRange.py b/tests/test_types/test_IntRange.py new file mode 100644 index 0000000000..7950a23ea3 --- /dev/null +++ b/tests/test_types/test_IntRange.py @@ -0,0 +1,38 @@ +import pytest + +import click + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.IntRange(0, 5), "3", 3), + (click.IntRange(5), "5", 5), + (click.IntRange(5), "100", 100), + (click.IntRange(max=5), "5", 5), + (click.IntRange(max=5), "-100", -100), + (click.IntRange(0, clamp=True), "-1", 0), + (click.IntRange(max=5, clamp=True), "6", 5), + (click.IntRange(0, min_open=True, clamp=True), "0", 1), + (click.IntRange(max=5, max_open=True, clamp=True), "5", 4), + ], +) +def test_range(type, value, expect): + assert type.convert(value, None, None) == expect + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.IntRange(0, 5), "6", "6 is not in the range 0<=x<=5."), + (click.IntRange(5), "4", "4 is not in the range x>=5."), + (click.IntRange(max=5), "6", "6 is not in the range x<=5."), + (click.IntRange(0, 5, min_open=True), 0, "0