Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ tests-random = [
]
typing = [
"mypy",
"pyrefly",
"pyright",
"pytest",
]
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down
84 changes: 69 additions & 15 deletions src/click/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
from types import TracebackType
from weakref import WeakKeyDictionary

if t.TYPE_CHECKING:
from _typeshed import OpenBinaryMode
from _typeshed import OpenTextMode
else:
OpenBinaryMode = OpenTextMode = str

AnyStr = t.TypeVar("AnyStr", str, bytes)
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
Expand Down Expand Up @@ -355,26 +364,64 @@ 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.IO[t.Any]:
) -> 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.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
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.IO[t.Any], bool]:
) -> tuple[t.IO[bytes], 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.IO[str], bool]: ...


def open_stream(
filename: str | os.PathLike[str],
mode: OpenFileMode = "r",
encoding: str | None = None,
errors: str | None = "strict",
atomic: bool = False,
) -> tuple[t.IO[bytes] | t.IO[str], bool]:
binary = "b" in mode
filename = os.fspath(filename)

Expand Down Expand Up @@ -444,17 +491,25 @@ 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 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.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:
Expand All @@ -465,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:
def __enter__(self) -> _AtomicFile[AnyStr]:
return self

def __exit__(
Expand Down
27 changes: 13 additions & 14 deletions src/click/_winconsole.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -270,16 +270,15 @@ 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())))


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)
):
Expand Down
4 changes: 2 additions & 2 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions src/click/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 3 additions & 3 deletions src/click/shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/click/termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 11 additions & 11 deletions src/click/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading