From c1b419d4201c08ea76642ffb1d486aa2e6ab893d Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Fri, 8 May 2026 23:08:18 +0100 Subject: [PATCH 1/8] Add pyrefly type checking. --- .github/workflows/tests.yaml | 15 +++++++++++++++ pyproject.toml | 10 ++++++++++ uv.lock | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7f712eca9..c101579e7 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -59,3 +59,18 @@ jobs: path: ./.mypy_cache key: mypy|${{ hashFiles('pyproject.toml') }} - run: uv run --locked --no-default-groups --group dev tox run -e typing + pyrefly: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + prune-cache: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version-file: pyproject.toml + - run: uv run --locked --no-default-groups --group dev tox run -e pyrefly -- --output-format=github diff --git a/pyproject.toml b/pyproject.toml index 5a0e37d04..252127320 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ tests-random = [ ] typing = [ "mypy", + "pyrefly", "pyright", "pytest", ] @@ -125,6 +126,10 @@ pythonVersion = "3.10" include = ["src", "tests/typing"] typeCheckingMode = "basic" +[tool.pyrefly] +python-version = "3.10" +project-includes = ["src", "tests/typing"] + [tool.ruff] extend-exclude = ["examples/"] src = ["src"] @@ -203,6 +208,11 @@ commands = [ ["pyright", "--ignoreexternal", "--verifytypes", "click"], ] +[tool.tox.env.pyrefly] +description = "run pyrefly type checker" +dependency_groups = ["typing"] +commands = [["pyrefly", "check", {replace = "posargs", default = [], extend = true}]] + [tool.tox.env.docs] description = "build docs" dependency_groups = ["docs"] diff --git a/uv.lock b/uv.lock index 278506184..29abe6d01 100644 --- a/uv.lock +++ b/uv.lock @@ -216,6 +216,7 @@ tests-random = [ ] typing = [ { name = "mypy" }, + { name = "pyrefly" }, { name = "pyright" }, { name = "pytest" }, ] @@ -250,6 +251,7 @@ tests-random = [ ] typing = [ { name = "mypy" }, + { name = "pyrefly" }, { name = "pyright" }, { name = "pytest" }, ] @@ -869,6 +871,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, ] +[[package]] +name = "pyrefly" +version = "0.64.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/13/7f824fa240a7c6b5595defc749d7e0e41c1c7fa2a889f55d5bd7d5cca28c/pyrefly-0.64.1.tar.gz", hash = "sha256:6303095afeedf4a93c7cf5e273ad0ada3d76f3a66b8769e06a6b96f0b2b22a39", size = 5678602, upload-time = "2026-05-08T03:37:53.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/1a/2a26e0b87942830bb3cdf21e89a6772efadd07ebf1d4f25a05ffc00ab71a/pyrefly-0.64.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:df5c1a8edc83483b8fcc662a188ac09a129e1d051c676794ca9ac1005373ae0a", size = 13112175, upload-time = "2026-05-08T03:37:30.448Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c8/625ad5ef423d425498eef4f350fe0a788d3d5403960dac6ff660b5a8edad/pyrefly-0.64.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0388c486816043c745744c7cc7f1215005c3c2d9506c5ee89ebaee013cd62f3", size = 12593929, upload-time = "2026-05-08T03:37:33.23Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9e/0b11aa0d0310f274f41394884d57775d5ac7d36cdb57ba8f030b7dcfae50/pyrefly-0.64.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da4d384a6466c59ba6bfa46b20f9a9f6b73922aeee43d96c9f89480d5edd3ad9", size = 12986439, upload-time = "2026-05-08T03:37:35.779Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/3f774b450415fffe6ef13d0fcf4e22c733bad7c3d826a54693e17c9ab302/pyrefly-0.64.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52fad756a47c9fc1d912f8564f2afdb816c4d057dfd8f90f07adce52314a5b56", size = 13936843, upload-time = "2026-05-08T03:37:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ee/e551c0ce512f01ddc09ed4258856ed4513bf49f3749c8c814a5b94ae59a3/pyrefly-0.64.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970a888b37eea53136a364b43e1466179ff8bb383e894c0afec3a3055e15cd9e", size = 13915094, upload-time = "2026-05-08T03:37:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/48/c3/3aed3ff905f82833de7c48d8f9788e8ce9fed57c95bf876eef3cd02c4d29/pyrefly-0.64.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92ac7078f174a79d489a0d0c43d858523f8e5b95f4981269f2f75c4aaa66be3d", size = 13459840, upload-time = "2026-05-08T03:37:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ed/aa1dc8b913d416a3c8bffc5485b1efe23c6039ead0dc5a9ad5505bf161ce/pyrefly-0.64.1-py3-none-win32.whl", hash = "sha256:3d16c90365af2f424c6e6a5c87d85ca94e8618ebcb4bf4c910634fd65d32b2e6", size = 12208564, upload-time = "2026-05-08T03:37:45.988Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0c/29dfe1cbcf3b1907558e67a3e4b304af3992933f05e22a596333489d5950/pyrefly-0.64.1-py3-none-win_amd64.whl", hash = "sha256:1fda307ecf414274445108a5df5b58c6fa1702a51b4ee6c1df87efeeb97dec59", size = 13134553, upload-time = "2026-05-08T03:37:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7c/449407653fe95e3f3a65dd8a54d8729ac0451247489d79d4e07808d73917/pyrefly-0.64.1-py3-none-win_arm64.whl", hash = "sha256:8f83a74c1463842d486d6578a000feccf47cd54d6d7d6628ffe73b1055ca9dce", size = 12528438, upload-time = "2026-05-08T03:37:50.923Z" }, +] + [[package]] name = "pyright" version = "1.1.408" From e1f1797c75c28c5d426c2f38048d25991dfb3fbf Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 00:28:45 +0100 Subject: [PATCH 2/8] Improve winconsole pyrefly typing --- src/click/_winconsole.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index d25178d66..1d522ca9d 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -70,19 +70,18 @@ MAX_BYTES_WRITTEN = 32767 if t.TYPE_CHECKING: - try: - # Using `typing_extensions.Buffer` instead of `collections.abc` - # on Windows for some reason does not have `Sized` implemented. - from collections.abc import Buffer # type: ignore - except ImportError: - from typing_extensions import Buffer + from typing_extensions import Buffer + + class _MSVCRTModule(t.Protocol): + def get_osfhandle(self, fd: int) -> int: ... try: from ctypes import pythonapi except ImportError: # On PyPy we cannot get buffers so our ability to operate here is # severely limited. - get_buffer = None + def get_buffer(obj: Buffer, writable: bool = False) -> Array[c_char]: + raise TypeError("PyPy does not support the buffer API used by Click") else: class Py_buffer(Structure): @@ -130,7 +129,8 @@ def readable(self) -> t.Literal[True]: return True def readinto(self, b: Buffer) -> int: - bytes_to_be_read = len(b) + bytes_to_be_read = memoryview(b).nbytes + if not bytes_to_be_read: return 0 elif bytes_to_be_read % 2: @@ -173,7 +173,7 @@ def _get_error_message(errno: int) -> str: return _("Windows error: {error}").format(error=errno) def write(self, b: Buffer) -> int: - bytes_to_be_written = len(b) + bytes_to_be_written = memoryview(b).nbytes buf = get_buffer(b) code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2 code_units_written = c_ulong() @@ -201,7 +201,7 @@ def __init__(self, text_stream: t.TextIO, byte_stream: t.BinaryIO) -> None: def name(self) -> str: return self.buffer.name - def write(self, x: t.AnyStr) -> int: + def write(self, x: str | bytes) -> int: if isinstance(x, str): return self._text_stream.write(x) try: @@ -210,7 +210,7 @@ def write(self, x: t.AnyStr) -> int: pass return self.buffer.write(x) - def writelines(self, lines: cabc.Iterable[t.AnyStr]) -> None: + def writelines(self, lines: cabc.Iterable[str] | cabc.Iterable[bytes]) -> None: for line in lines: self.write(line) @@ -270,7 +270,7 @@ def _is_console(f: t.TextIO) -> bool: except (OSError, io.UnsupportedOperation): return False - handle = msvcrt.get_osfhandle(fileno) + handle = t.cast(_MSVCRTModule, msvcrt).get_osfhandle(fileno) return bool(GetConsoleMode(handle, byref(DWORD()))) @@ -278,8 +278,7 @@ def _get_windows_console_stream( f: t.TextIO, encoding: str | None, errors: str | None ) -> t.TextIO | None: if ( - get_buffer is None - or encoding not in {"utf-16-le", None} + encoding not in {"utf-16-le", None} or errors not in {"strict", None} or not _is_console(f) ): From 2203c59f91fddde5fa1c4169f465b97c2aec1a2a Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 00:50:10 +0100 Subject: [PATCH 3/8] Improve help option cache typing --- src/click/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index c5cab15c0..f0e4d5e5d 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1001,7 +1001,7 @@ def __init__( self.options_metavar = options_metavar self.short_help = short_help self.add_help_option = add_help_option - self._help_option = None + self._help_option: Option | None = None self.no_args_is_help = no_args_is_help self.hidden = hidden self.deprecated = deprecated @@ -1104,7 +1104,7 @@ def get_help_option(self, ctx: Context) -> Option | None: # Apply help_option decorator and pop resulting option help_option(*help_option_names)(self) - self._help_option = self.params.pop() # type: ignore[assignment] + self._help_option = t.cast(Option, self.params.pop()) return self._help_option From 513f72c5a37c1c62ca59db97b27f3aa0c9701bfa Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 00:50:37 +0100 Subject: [PATCH 4/8] Improve shell completion class typing --- src/click/shell_completion.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 5a89b64ec..abe8bb73c 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -436,7 +436,7 @@ def format_completion(self, item: CompletionItem) -> str: return f"{item.type}\n{value}\n{help_escaped}" -ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]") +ShellCompleteType = t.TypeVar("ShellCompleteType", bound="ShellComplete") _available_shells: dict[str, type[ShellComplete]] = { @@ -447,8 +447,8 @@ def format_completion(self, item: CompletionItem) -> str: def add_completion_class( - cls: ShellCompleteType, name: str | None = None -) -> ShellCompleteType: + cls: type[ShellCompleteType], name: str | None = None +) -> type[ShellCompleteType]: """Register a :class:`ShellComplete` subclass under the given name. The name will be provided by the completion instruction environment variable during completion. From 2f16bf1e51bd38474c15e195865935c31e85579c Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 00:51:04 +0100 Subject: [PATCH 5/8] Improve usage error context typing --- src/click/exceptions.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 4914d9cfe..1f3bbceb8 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -302,12 +302,24 @@ class BadArgumentUsage(UsageError): class NoArgsIsHelpError(UsageError): + """ + Raised when no arguments are passed and help is requested via + ``no_arg_is_help`` field in ``Command`` or ``Group``. + + .. versionchanged:: 8.4.0 + ``ctx`` is now optional to be in line with ``UsageError``. + """ + def __init__(self, ctx: Context) -> None: - self.ctx: Context super().__init__(ctx.get_help(), ctx=ctx) def show(self, file: t.IO[t.Any] | None = None) -> None: - echo(self.format_message(), file=file, err=True, color=self.ctx.color) + echo( + self.format_message(), + file=file, + err=True, + color=self.ctx.color if self.ctx is not None else None, + ) class FileError(ClickException): From a70336168f34d62d4546e8c8fd2bb0648e6e3917 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 00:52:23 +0100 Subject: [PATCH 6/8] Improve terminal and testing typing --- src/click/termui.py | 2 +- src/click/testing.py | 22 +++++++++++----------- src/click/utils.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/click/termui.py b/src/click/termui.py index 08d732895..00687322f 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -696,7 +696,7 @@ def unstyle(text: str) -> str: def secho( message: t.Any | None = None, - file: t.IO[t.AnyStr] | None = None, + file: t.TextIO | t.BinaryIO | None = None, nl: bool = True, err: bool = False, color: bool | None = None, diff --git a/src/click/testing.py b/src/click/testing.py index 04e7f1d92..55af59b96 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -163,21 +163,21 @@ def mode(self) -> str: def make_input_stream( input: str | bytes | t.IO[t.Any] | None, charset: str ) -> t.BinaryIO: - # Is already an input stream. - if hasattr(input, "read"): - rv = _find_binary_reader(t.cast("t.IO[t.Any]", input)) + if input is None: + return io.BytesIO(b"") - if rv is not None: - return rv + if isinstance(input, str): + return io.BytesIO(input.encode(charset)) - raise TypeError("Could not find binary reader for input stream.") + if isinstance(input, bytes): + return io.BytesIO(input) - if input is None: - input = b"" - elif isinstance(input, str): - input = input.encode(charset) + rv = _find_binary_reader(input) + + if rv is not None: + return rv - return io.BytesIO(input) + raise TypeError("Could not find binary reader for input stream.") class Result: diff --git a/src/click/utils.py b/src/click/utils.py index e2e3fe5f1..0e72f848d 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -193,7 +193,7 @@ def __exit__( ) -> None: self.close_intelligently() - def __iter__(self) -> cabc.Iterator[t.AnyStr]: + def __iter__(self) -> cabc.Iterator[str] | cabc.Iterator[bytes]: self.open() return iter(self._f) # type: ignore @@ -219,7 +219,7 @@ def __exit__( def __repr__(self) -> str: return repr(self._file) - def __iter__(self) -> cabc.Iterator[t.AnyStr]: + def __iter__(self) -> cabc.Iterator[str] | cabc.Iterator[bytes]: return iter(self._file) From b4ca68add7db7be4f8d589aecd5c6e8cd01e6f3a Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 02:03:57 +0100 Subject: [PATCH 7/8] Add proper AnyStr usage in File types. --- src/click/_compat.py | 71 +++++++++++++++++++++---- src/click/types.py | 42 ++++++++++++--- src/click/utils.py | 103 +++++++++++++++++++++++++++++------- tests/typing/typing_file.py | 33 ++++++++++++ 4 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 tests/typing/typing_file.py diff --git a/src/click/_compat.py b/src/click/_compat.py index 134c4f389..c1e843790 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -10,6 +10,14 @@ from types import TracebackType from weakref import WeakKeyDictionary +if t.TYPE_CHECKING: + from _typeshed import OpenBinaryMode + from _typeshed import OpenTextMode +else: + OpenBinaryMode = OpenTextMode = str + +OpenFileMode = OpenTextMode | OpenBinaryMode + CYGWIN = sys.platform.startswith("cygwin") WIN = sys.platform.startswith("win") auto_wrap_for_ansi: t.Callable[[t.TextIO], t.TextIO] | None = None @@ -355,11 +363,29 @@ def get_text_stderr(encoding: str | None = None, errors: str | None = None) -> t return _force_correct_text_writer(sys.stderr, encoding, errors, force_writable=True) +@t.overload def _wrap_io_open( file: str | os.PathLike[str] | int, - mode: str, + mode: OpenBinaryMode, encoding: str | None, errors: str | None, +) -> t.BinaryIO: ... + + +@t.overload +def _wrap_io_open( + file: str | os.PathLike[str] | int, + mode: OpenTextMode = "r", + encoding: str | None = None, + errors: str | None = "strict", +) -> t.TextIO: ... + + +def _wrap_io_open( + file: str | os.PathLike[str] | int, + mode: OpenFileMode = "r", + encoding: str | None = None, + errors: str | None = "strict", ) -> t.IO[t.Any]: """Handles not passing ``encoding`` and ``errors`` in binary mode.""" if "b" in mode: @@ -368,9 +394,29 @@ def _wrap_io_open( return open(file, mode, encoding=encoding, errors=errors) +@t.overload def open_stream( filename: str | os.PathLike[str], - mode: str = "r", + mode: OpenBinaryMode, + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, +) -> tuple[t.BinaryIO, bool]: ... + + +@t.overload +def open_stream( + filename: str | os.PathLike[str], + mode: OpenTextMode = "r", + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, +) -> tuple[t.TextIO, bool]: ... + + +def open_stream( + filename: str | os.PathLike[str], + mode: OpenFileMode = "r", encoding: str | None = None, errors: str | None = "strict", atomic: bool = False, @@ -444,14 +490,21 @@ def open_stream( if perm is not None: os.chmod(tmp_filename, perm) # in case perm includes bits in umask - f = _wrap_io_open(fd, mode, encoding, errors) - af = _AtomicFile(f, tmp_filename, os.path.realpath(filename)) - return t.cast(t.IO[t.Any], af), True + if binary: + binary_f = _wrap_io_open(fd, t.cast("OpenBinaryMode", mode), encoding, errors) + binary_af = _AtomicFile(binary_f, tmp_filename, os.path.realpath(filename)) + return t.cast(t.IO[t.Any], binary_af), True + + text_f = _wrap_io_open(fd, t.cast("OpenTextMode", mode), encoding, errors) + text_af = _AtomicFile(text_f, tmp_filename, os.path.realpath(filename)) + return t.cast(t.IO[t.Any], text_af), True -class _AtomicFile: - def __init__(self, f: t.IO[t.Any], tmp_filename: str, real_filename: str) -> None: - self._f = f +class _AtomicFile(t.Generic[t.AnyStr]): + def __init__( + self, f: t.IO[t.AnyStr], tmp_filename: str, real_filename: str + ) -> None: + self._f: t.IO[t.AnyStr] = f self._tmp_filename = tmp_filename self._real_filename = real_filename self.closed = False @@ -470,7 +523,7 @@ def close(self, delete: bool = False) -> None: def __getattr__(self, name: str) -> t.Any: return getattr(self._f, name) - def __enter__(self) -> _AtomicFile: + def __enter__(self) -> _AtomicFile[t.AnyStr]: return self def __exit__( diff --git a/src/click/types.py b/src/click/types.py index 556f20f2b..dc4467886 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -20,11 +20,17 @@ from .utils import safecall if t.TYPE_CHECKING: - import typing_extensions as te - + from _typeshed import OpenBinaryMode + from _typeshed import OpenTextMode from .core import Context from .core import Parameter from .shell_completion import CompletionItem + import typing_extensions as te + +else: + OpenBinaryMode = OpenTextMode = str + +OpenFileMode = OpenTextMode | OpenBinaryMode ParamTypeValue = t.TypeVar("ParamTypeValue") @@ -787,7 +793,7 @@ class FileInfoDict(ParamTypeInfoDict): encoding: str | None -class File(ParamType[t.IO[t.Any]]): +class File(ParamType[t.IO[t.AnyStr]], t.Generic[t.AnyStr]): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command finished working). @@ -820,9 +826,29 @@ class File(ParamType[t.IO[t.Any]]): name = "filename" envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + @t.overload + def __init__( + self: File[bytes], + mode: OpenBinaryMode, + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool | None = None, + atomic: bool = False, + ) -> None: ... + + @t.overload + def __init__( + self: File[str], + mode: OpenTextMode = "r", + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool | None = None, + atomic: bool = False, + ) -> None: ... + def __init__( self, - mode: str = "r", + mode: OpenFileMode = "r", encoding: str | None = None, errors: str | None = "strict", lazy: bool | None = None, @@ -855,9 +881,9 @@ def convert( value: str | os.PathLike[str] | t.IO[t.Any], param: Parameter | None, ctx: Context | None, - ) -> t.IO[t.Any]: + ) -> t.IO[t.AnyStr]: if _is_file_like(value): - return value + return t.cast("t.IO[t.AnyStr]", value) value = t.cast("str | os.PathLike[str]", value) @@ -872,7 +898,7 @@ def convert( if ctx is not None: ctx.call_on_close(lf.close_intelligently) - return t.cast("t.IO[t.Any]", lf) + return t.cast("t.IO[t.AnyStr]", lf) f, should_close = open_stream( value, self.mode, self.encoding, self.errors, atomic=self.atomic @@ -889,7 +915,7 @@ def convert( else: ctx.call_on_close(safecall(f.flush)) - return f + return t.cast("t.IO[t.AnyStr]", f) except OSError as e: self.fail( f"'{format_filename(value)}': {e.strerror}", diff --git a/src/click/utils.py b/src/click/utils.py index 0e72f848d..d3a657137 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -23,12 +23,19 @@ from .globals import resolve_color_default if t.TYPE_CHECKING: + from _typeshed import OpenBinaryMode + from _typeshed import OpenTextMode import typing_extensions as te P = te.ParamSpec("P") +else: + OpenBinaryMode = OpenTextMode = str + R = t.TypeVar("R") +OpenFileMode = OpenTextMode | OpenBinaryMode + def _posixify(name: str) -> str: return "-".join(name.split()).lower() @@ -110,31 +117,52 @@ def make_default_short_help(help: str, max_length: int = 45) -> str: return " ".join(words[:i]) + "..." -class LazyFile: +class LazyFile(t.Generic[t.AnyStr]): """A lazy file works like a regular file but it does not fully open the file but it does perform some basic checks early to see if the filename parameter does make sense. This is useful for safely opening files for writing. """ + @t.overload + def __init__( + self: LazyFile[bytes], + filename: str | os.PathLike[str], + mode: OpenBinaryMode, + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, + ) -> None: ... + + @t.overload + def __init__( + self: LazyFile[str], + filename: str | os.PathLike[str], + mode: OpenTextMode = "r", + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, + ) -> None: ... + def __init__( self, filename: str | os.PathLike[str], - mode: str = "r", + mode: OpenFileMode = "r", encoding: str | None = None, errors: str | None = "strict", atomic: bool = False, - ): + ) -> None: self.name: str = os.fspath(filename) self.mode = mode self.encoding = encoding self.errors = errors self.atomic = atomic - self._f: t.IO[t.Any] | None + self._f: t.IO[t.AnyStr] | None self.should_close: bool if self.name == "-": - self._f, self.should_close = open_stream(filename, mode, encoding, errors) + rv, self.should_close = open_stream(filename, mode, encoding, errors) + self._f = t.cast("t.IO[t.AnyStr]", rv) else: if "r" in mode: # Open and close the file in case we're opening it for @@ -152,7 +180,7 @@ def __repr__(self) -> str: return repr(self._f) return f"" - def open(self) -> t.IO[t.Any]: + def open(self) -> t.IO[t.AnyStr]: """Opens the file if it's not yet open. This call might fail with a :exc:`FileError`. Not handling this error will produce an error that Click shows. @@ -167,8 +195,8 @@ def open(self) -> t.IO[t.Any]: from .exceptions import FileError raise FileError(self.name, hint=e.strerror) from e - self._f = rv - return rv + self._f = t.cast("t.IO[t.AnyStr]", rv) + return self._f def close(self) -> None: """Closes the underlying file, no matter what.""" @@ -182,7 +210,7 @@ def close_intelligently(self) -> None: if self.should_close: self.close() - def __enter__(self) -> LazyFile: + def __enter__(self) -> LazyFile[t.AnyStr]: return self def __exit__( @@ -193,19 +221,25 @@ def __exit__( ) -> None: self.close_intelligently() - def __iter__(self) -> cabc.Iterator[str] | cabc.Iterator[bytes]: + def __iter__(self) -> cabc.Iterator[t.AnyStr]: self.open() return iter(self._f) # type: ignore -class KeepOpenFile: +class KeepOpenFile(t.Generic[t.AnyStr]): + @t.overload + def __init__(self: KeepOpenFile[bytes], file: t.BinaryIO) -> None: ... + + @t.overload + def __init__(self: KeepOpenFile[str], file: t.TextIO) -> None: ... + def __init__(self, file: t.IO[t.Any]) -> None: - self._file: t.IO[t.Any] = file + self._file: t.IO[t.AnyStr] = t.cast("t.IO[t.AnyStr]", file) def __getattr__(self, name: str) -> t.Any: return getattr(self._file, name) - def __enter__(self) -> KeepOpenFile: + def __enter__(self) -> KeepOpenFile[t.AnyStr]: return self def __exit__( @@ -219,7 +253,7 @@ def __exit__( def __repr__(self) -> str: return repr(self._file) - def __iter__(self) -> cabc.Iterator[str] | cabc.Iterator[bytes]: + def __iter__(self) -> cabc.Iterator[t.AnyStr]: return iter(self._file) @@ -359,9 +393,42 @@ def get_text_stream( return opener(encoding, errors) +@t.overload def open_file( filename: str | os.PathLike[str], - mode: str = "r", + mode: OpenBinaryMode, + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO[bytes]: ... + + +@t.overload +def open_file( + filename: str | os.PathLike[str], + mode: OpenTextMode = "r", + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO[str]: ... + + +@t.overload +def open_file( + filename: str | os.PathLike[str], + mode: OpenFileMode = "r", + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO[str] | t.IO[bytes]: ... + + +def open_file( + filename: str | os.PathLike[str], + mode: OpenFileMode = "r", encoding: str | None = None, errors: str | None = "strict", lazy: bool = False, @@ -396,14 +463,12 @@ def open_file( .. versionadded:: 3.0 """ if lazy: - return t.cast( - "t.IO[t.Any]", LazyFile(filename, mode, encoding, errors, atomic=atomic) - ) + return t.cast("t.IO[t.Any]", LazyFile(filename, mode, encoding, errors, atomic)) f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) if not should_close: - f = t.cast("t.IO[t.Any]", KeepOpenFile(f)) + return t.cast("t.IO[t.Any]", KeepOpenFile(f)) return f diff --git a/tests/typing/typing_file.py b/tests/typing/typing_file.py new file mode 100644 index 000000000..dfe389621 --- /dev/null +++ b/tests/typing/typing_file.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import typing as t + +from typing_extensions import assert_type + +import click + + +text_file = click.File() +assert_type(text_file, click.File[str]) + +explicit_text_file = click.File("r") +assert_type(explicit_text_file, click.File[str]) + +binary_file = click.File("rb") +assert_type(binary_file, click.File[bytes]) + + +with click.open_file("test.txt") as text_stream: + assert_type(text_stream, t.IO[str]) + +with click.open_file("test.txt", "r") as explicit_text_stream: + assert_type(explicit_text_stream, t.IO[str]) + +with click.open_file("test.bin", "rb") as binary_stream: + assert_type(binary_stream, t.IO[bytes]) + +with click.open_file("test.txt", lazy=True) as lazy_text_stream: + assert_type(lazy_text_stream, t.IO[str]) + +with click.open_file("test.bin", "wb", atomic=True) as atomic_binary_stream: + assert_type(atomic_binary_stream, t.IO[bytes]) From e6cfbbf0aa9f770db120fac5ab55499711e73395 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sat, 9 May 2026 19:41:41 +0100 Subject: [PATCH 8/8] Temporary improvements: AnyStr and OpenFileMode --- src/click/_compat.py | 31 ++++++++++++------------- src/click/types.py | 28 ++++++++++++----------- src/click/utils.py | 45 +++++++++++++++++++------------------ tests/typing/typing_file.py | 8 ++++--- 4 files changed, 59 insertions(+), 53 deletions(-) diff --git a/src/click/_compat.py b/src/click/_compat.py index c1e843790..e0a5d85c5 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -16,6 +16,7 @@ else: OpenBinaryMode = OpenTextMode = str +AnyStr = t.TypeVar("AnyStr", str, bytes) OpenFileMode = OpenTextMode | OpenBinaryMode CYGWIN = sys.platform.startswith("cygwin") @@ -386,12 +387,12 @@ def _wrap_io_open( mode: OpenFileMode = "r", encoding: str | None = None, errors: str | None = "strict", -) -> t.IO[t.Any]: +) -> t.BinaryIO | t.TextIO: """Handles not passing ``encoding`` and ``errors`` in binary mode.""" if "b" in mode: - return open(file, mode) + return t.cast(t.BinaryIO, open(file, mode)) - return open(file, mode, encoding=encoding, errors=errors) + return t.cast(t.TextIO, open(file, mode, encoding=encoding, errors=errors)) @t.overload @@ -401,7 +402,7 @@ def open_stream( encoding: str | None = None, errors: str | None = "strict", atomic: bool = False, -) -> tuple[t.BinaryIO, bool]: ... +) -> tuple[t.IO[bytes], bool]: ... @t.overload @@ -411,7 +412,7 @@ def open_stream( encoding: str | None = None, errors: str | None = "strict", atomic: bool = False, -) -> tuple[t.TextIO, bool]: ... +) -> tuple[t.IO[str], bool]: ... def open_stream( @@ -420,7 +421,7 @@ def open_stream( encoding: str | None = None, errors: str | None = "strict", atomic: bool = False, -) -> tuple[t.IO[t.Any], bool]: +) -> tuple[t.IO[bytes] | t.IO[str], bool]: binary = "b" in mode filename = os.fspath(filename) @@ -493,21 +494,22 @@ def open_stream( if binary: binary_f = _wrap_io_open(fd, t.cast("OpenBinaryMode", mode), encoding, errors) binary_af = _AtomicFile(binary_f, tmp_filename, os.path.realpath(filename)) - return t.cast(t.IO[t.Any], binary_af), True + return binary_af, True text_f = _wrap_io_open(fd, t.cast("OpenTextMode", mode), encoding, errors) text_af = _AtomicFile(text_f, tmp_filename, os.path.realpath(filename)) return t.cast(t.IO[t.Any], text_af), True -class _AtomicFile(t.Generic[t.AnyStr]): - def __init__( - self, f: t.IO[t.AnyStr], tmp_filename: str, real_filename: str - ) -> None: - self._f: t.IO[t.AnyStr] = f +class _AtomicFile(t.IO[AnyStr], t.Generic[AnyStr]): + def __init__(self, f: t.IO[AnyStr], tmp_filename: str, real_filename: str) -> None: + self._f: t.IO[AnyStr] = f self._tmp_filename = tmp_filename self._real_filename = real_filename - self.closed = False + + @property + def closed(self) -> bool: + return self._f.closed @property def name(self) -> str: @@ -518,12 +520,11 @@ def close(self, delete: bool = False) -> None: return self._f.close() os.replace(self._tmp_filename, self._real_filename) - self.closed = True def __getattr__(self, name: str) -> t.Any: return getattr(self._f, name) - def __enter__(self) -> _AtomicFile[t.AnyStr]: + def __enter__(self) -> _AtomicFile[AnyStr]: return self def __exit__( diff --git a/src/click/types.py b/src/click/types.py index dc4467886..9877e58d0 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -30,7 +30,8 @@ else: OpenBinaryMode = OpenTextMode = str -OpenFileMode = OpenTextMode | OpenBinaryMode +AnyStr = t.TypeVar("AnyStr", str, bytes) +OpenFileMode = t.TypeVar("OpenFileMode", OpenTextMode, OpenBinaryMode) ParamTypeValue = t.TypeVar("ParamTypeValue") @@ -793,7 +794,7 @@ class FileInfoDict(ParamTypeInfoDict): encoding: str | None -class File(ParamType[t.IO[t.AnyStr]], t.Generic[t.AnyStr]): +class File(ParamType[t.IO[AnyStr]], t.Generic[AnyStr, OpenFileMode]): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command finished working). @@ -828,7 +829,7 @@ class File(ParamType[t.IO[t.AnyStr]], t.Generic[t.AnyStr]): @t.overload def __init__( - self: File[bytes], + self: File[bytes, OpenBinaryMode], mode: OpenBinaryMode, encoding: str | None = None, errors: str | None = "strict", @@ -838,7 +839,7 @@ def __init__( @t.overload def __init__( - self: File[str], + self: File[str, OpenTextMode], mode: OpenTextMode = "r", encoding: str | None = None, errors: str | None = "strict", @@ -848,13 +849,13 @@ def __init__( def __init__( self, - mode: OpenFileMode = "r", + mode: OpenBinaryMode | OpenTextMode = "r", encoding: str | None = None, errors: str | None = "strict", lazy: bool | None = None, atomic: bool = False, ) -> None: - self.mode = mode + self.mode: OpenFileMode = mode self.encoding = encoding self.errors = errors self.lazy = lazy @@ -878,14 +879,15 @@ def resolve_lazy_flag(self, value: str | os.PathLike[str]) -> bool: def convert( self, - value: str | os.PathLike[str] | t.IO[t.Any], + value: str | os.PathLike[str] | t.IO[AnyStr], param: Parameter | None, ctx: Context | None, - ) -> t.IO[t.AnyStr]: + ) -> t.IO[AnyStr]: if _is_file_like(value): - return t.cast("t.IO[t.AnyStr]", value) + return value + # return t.cast("t.IO[AnyStr]", value) - value = t.cast("str | os.PathLike[str]", value) + # value = t.cast("str | os.PathLike[str]", value) try: lazy = self.resolve_lazy_flag(value) @@ -898,7 +900,7 @@ def convert( if ctx is not None: ctx.call_on_close(lf.close_intelligently) - return t.cast("t.IO[t.AnyStr]", lf) + return t.cast("t.IO[AnyStr]", lf) f, should_close = open_stream( value, self.mode, self.encoding, self.errors, atomic=self.atomic @@ -915,7 +917,7 @@ def convert( else: ctx.call_on_close(safecall(f.flush)) - return t.cast("t.IO[t.AnyStr]", f) + return t.cast("t.IO[AnyStr]", f) except OSError as e: self.fail( f"'{format_filename(value)}': {e.strerror}", @@ -940,7 +942,7 @@ def shell_complete( return [CompletionItem(incomplete, type="file")] -def _is_file_like(value: t.Any) -> te.TypeGuard[t.IO[t.Any]]: +def _is_file_like(value: t.IO[AnyStr] | t.Any) -> te.TypeIs[t.IO[AnyStr]]: return hasattr(value, "read") or hasattr(value, "write") diff --git a/src/click/utils.py b/src/click/utils.py index d3a657137..e6805b862 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -33,8 +33,8 @@ OpenBinaryMode = OpenTextMode = str R = t.TypeVar("R") - -OpenFileMode = OpenTextMode | OpenBinaryMode +AnyStr = t.TypeVar("AnyStr", str, bytes) +OpenFileMode = t.TypeVar("OpenFileMode", OpenTextMode, OpenBinaryMode) def _posixify(name: str) -> str: @@ -117,7 +117,7 @@ def make_default_short_help(help: str, max_length: int = 45) -> str: return " ".join(words[:i]) + "..." -class LazyFile(t.Generic[t.AnyStr]): +class LazyFile(t.Generic[AnyStr, OpenFileMode]): """A lazy file works like a regular file but it does not fully open the file but it does perform some basic checks early to see if the filename parameter does make sense. This is useful for safely opening @@ -126,7 +126,7 @@ class LazyFile(t.Generic[t.AnyStr]): @t.overload def __init__( - self: LazyFile[bytes], + self: LazyFile[bytes, OpenBinaryMode], filename: str | os.PathLike[str], mode: OpenBinaryMode, encoding: str | None = None, @@ -136,7 +136,7 @@ def __init__( @t.overload def __init__( - self: LazyFile[str], + self: LazyFile[str, OpenTextMode], filename: str | os.PathLike[str], mode: OpenTextMode = "r", encoding: str | None = None, @@ -147,17 +147,17 @@ def __init__( def __init__( self, filename: str | os.PathLike[str], - mode: OpenFileMode = "r", + mode: OpenBinaryMode | OpenTextMode = "r", encoding: str | None = None, errors: str | None = "strict", atomic: bool = False, ) -> None: self.name: str = os.fspath(filename) - self.mode = mode + self.mode: OpenFileMode = mode self.encoding = encoding self.errors = errors self.atomic = atomic - self._f: t.IO[t.AnyStr] | None + self._f: t.IO[AnyStr] | None self.should_close: bool if self.name == "-": @@ -180,7 +180,7 @@ def __repr__(self) -> str: return repr(self._f) return f"" - def open(self) -> t.IO[t.AnyStr]: + def open(self) -> t.IO[AnyStr]: """Opens the file if it's not yet open. This call might fail with a :exc:`FileError`. Not handling this error will produce an error that Click shows. @@ -195,7 +195,7 @@ def open(self) -> t.IO[t.AnyStr]: from .exceptions import FileError raise FileError(self.name, hint=e.strerror) from e - self._f = t.cast("t.IO[t.AnyStr]", rv) + self._f = t.cast("t.IO[AnyStr]", rv) return self._f def close(self) -> None: @@ -210,7 +210,7 @@ def close_intelligently(self) -> None: if self.should_close: self.close() - def __enter__(self) -> LazyFile[t.AnyStr]: + def __enter__(self) -> LazyFile[AnyStr]: return self def __exit__( @@ -221,25 +221,26 @@ def __exit__( ) -> None: self.close_intelligently() - def __iter__(self) -> cabc.Iterator[t.AnyStr]: + def __iter__(self) -> cabc.Iterator[AnyStr]: self.open() return iter(self._f) # type: ignore -class KeepOpenFile(t.Generic[t.AnyStr]): - @t.overload - def __init__(self: KeepOpenFile[bytes], file: t.BinaryIO) -> None: ... - - @t.overload - def __init__(self: KeepOpenFile[str], file: t.TextIO) -> None: ... +class KeepOpenFile(t.Generic[AnyStr]): + # @t.overload + # def __init__(self: KeepOpenFile[bytes], file: t.BinaryIO) -> None: ... + # + # @t.overload + # def __init__(self: KeepOpenFile[str], file: t.TextIO) -> None: ... - def __init__(self, file: t.IO[t.Any]) -> None: - self._file: t.IO[t.AnyStr] = t.cast("t.IO[t.AnyStr]", file) + def __init__(self, file: t.IO[AnyStr]) -> None: + # self._file: t.IO[t.AnyStr] = t.cast("t.IO[t.AnyStr]", file) + self._file = file def __getattr__(self, name: str) -> t.Any: return getattr(self._file, name) - def __enter__(self) -> KeepOpenFile[t.AnyStr]: + def __enter__(self) -> KeepOpenFile[AnyStr]: return self def __exit__( @@ -253,7 +254,7 @@ def __exit__( def __repr__(self) -> str: return repr(self._file) - def __iter__(self) -> cabc.Iterator[t.AnyStr]: + def __iter__(self) -> cabc.Iterator[AnyStr]: return iter(self._file) diff --git a/tests/typing/typing_file.py b/tests/typing/typing_file.py index dfe389621..58f27bf68 100644 --- a/tests/typing/typing_file.py +++ b/tests/typing/typing_file.py @@ -6,15 +6,17 @@ import click +import _typeshed as ts + text_file = click.File() -assert_type(text_file, click.File[str]) +assert_type(text_file, click.File[str, ts.OpenTextMode]) explicit_text_file = click.File("r") -assert_type(explicit_text_file, click.File[str]) +assert_type(explicit_text_file, click.File[str, ts.OpenTextMode]) binary_file = click.File("rb") -assert_type(binary_file, click.File[bytes]) +assert_type(binary_file, click.File[bytes, ts.OpenBinaryMode]) with click.open_file("test.txt") as text_stream: