Voice et bot modif

This commit is contained in:
pi 2026-06-16 17:09:34 +00:00
parent 189d56026b
commit 7333a22bcd
10774 changed files with 634644 additions and 933308 deletions

View file

@ -14,6 +14,7 @@ from .core import Context as Context
from .core import Group as Group
from .core import Option as Option
from .core import Parameter as Parameter
from .core import ParameterSource as ParameterSource
from .decorators import argument as argument
from .decorators import command as command
from .decorators import confirmation_option as confirmation_option
@ -32,6 +33,7 @@ from .exceptions import BadParameter as BadParameter
from .exceptions import ClickException as ClickException
from .exceptions import FileError as FileError
from .exceptions import MissingParameter as MissingParameter
from .exceptions import NoSuchCommand as NoSuchCommand
from .exceptions import NoSuchOption as NoSuchOption
from .exceptions import UsageError as UsageError
from .formatting import HelpFormatter as HelpFormatter
@ -41,6 +43,7 @@ from .termui import clear as clear
from .termui import confirm as confirm
from .termui import echo_via_pager as echo_via_pager
from .termui import edit as edit
from .termui import get_pager_file as get_pager_file
from .termui import getchar as getchar
from .termui import launch as launch
from .termui import pause as pause

View file

@ -12,6 +12,7 @@ from weakref import WeakKeyDictionary
CYGWIN = sys.platform.startswith("cygwin")
WIN = sys.platform.startswith("win")
MAC = sys.platform == "darwin"
auto_wrap_for_ansi: t.Callable[[t.TextIO], t.TextIO] | None = None
_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]")
@ -502,6 +503,10 @@ def should_strip_ansi(
if color is None:
if stream is None:
stream = sys.stdin
elif hasattr(stream, "color"):
# ._termui_impl.MaybeStripAnsi handles stripping ansi itself,
# so we don't need to strip it here
return False
return not isatty(stream) and not _is_jupyter_kernel_output(stream)
return not color

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import collections.abc as cabc
import contextlib
import io
import math
import os
import shlex
@ -23,7 +24,6 @@ from ._compat import _default_text_stdout
from ._compat import CYGWIN
from ._compat import get_best_encoding
from ._compat import isatty
from ._compat import open_stream
from ._compat import strip_ansi
from ._compat import term_len
from ._compat import WIN
@ -32,6 +32,19 @@ from .utils import echo
V = t.TypeVar("V")
class _BufferedTextPagerStream(t.Protocol):
buffer: t.BinaryIO
def _has_binary_buffer(
stream: t.BinaryIO | t.TextIO,
) -> t.TypeGuard[_BufferedTextPagerStream]:
# TextIO is wider than TextIOWrapper; text-only streams such as StringIO
# are valid TextIO values but do not expose a binary buffer to wrap.
return getattr(stream, "buffer", None) is not None
if os.name == "nt":
BEFORE_BAR = "\r"
AFTER_BAR = "\n"
@ -173,7 +186,13 @@ class ProgressBar(t.Generic[V]):
hours = t % 24
t //= 24
if t > 0:
return f"{t}d {hours:02}:{minutes:02}:{seconds:02}"
return "{d}{day_label} {h:02}:{m:02}:{s:02}".format(
d=t,
day_label=_("d"),
h=hours,
m=minutes,
s=seconds,
)
else:
return f"{hours:02}:{minutes:02}:{seconds:02}"
return ""
@ -366,7 +385,20 @@ class ProgressBar(t.Generic[V]):
self.render_progress()
def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
class MaybeStripAnsi(io.TextIOWrapper):
def __init__(self, stream: t.IO[bytes], *, color: bool, **kwargs: t.Any):
super().__init__(stream, **kwargs)
self.color = color
def write(self, text: str) -> int:
if not self.color:
text = strip_ansi(text)
return super().write(text)
def _pager_contextmanager(
color: bool | None = None,
) -> t.ContextManager[tuple[t.BinaryIO | t.TextIO, str, bool]]:
"""Decide what method to use for paging through text."""
stdout = _default_text_stdout()
@ -376,50 +408,69 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
stdout = StringIO()
if not isatty(sys.stdin) or not isatty(stdout):
return _nullpager(stdout, generator, color)
return _nullpager(stdout, color)
# Split and normalize the pager command into parts.
pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""), posix=False)
# Split using POSIX mode (the default) so that quote characters are
# stripped from tokens and quoted Windows paths are preserved.
# Non-POSIX mode retains quotes in tokens, and wrapping tokens
# with shlex.quote re-introduces quoting issues on Windows.
pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""))
if pager_cmd_parts:
if WIN:
if _tempfilepager(generator, pager_cmd_parts, color):
return
elif _pipepager(generator, pager_cmd_parts, color):
return
return _tempfilepager(pager_cmd_parts, color)
return _pipepager(pager_cmd_parts, color)
if os.environ.get("TERM") in ("dumb", "emacs"):
return _nullpager(stdout, generator, color)
if (WIN or sys.platform.startswith("os2")) and _tempfilepager(
generator, ["more"], color
):
return
if _pipepager(generator, ["less"], color):
return
import tempfile
fd, filename = tempfile.mkstemp()
os.close(fd)
try:
if _pipepager(generator, ["more"], color):
return
return _nullpager(stdout, generator, color)
finally:
os.unlink(filename)
return _nullpager(stdout, color)
if WIN or sys.platform.startswith("os2"):
return _tempfilepager(["more"], color)
return _pipepager(["less"], color)
@contextlib.contextmanager
def get_pager_file(color: bool | None = None) -> t.Generator[t.TextIO, None, None]:
"""Context manager.
Yields a writable file-like object which can be used as an output pager.
.. versionadded:: 8.4
:param color: controls if the pager supports ANSI colors or not. The
default is autodetection.
"""
with _pager_contextmanager(color=color) as (stream, encoding, color):
# Split streams by capabilities rather than the abstract TextIO /
# BinaryIO annotations: buffered text streams can be unwrapped to bytes,
# while text-only streams are yielded as-is.
if _has_binary_buffer(stream):
# Text stream backed by a binary buffer.
stream = MaybeStripAnsi(stream.buffer, color=color, encoding=encoding)
elif isinstance(stream, t.BinaryIO):
# Binary stream
stream = MaybeStripAnsi(stream, color=color, encoding=encoding)
try:
yield stream
finally:
stream.flush()
@contextlib.contextmanager
def _pipepager(
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
) -> bool:
"""Page through text by feeding it to another program. Invoking a
pager through this might support colors.
cmd_parts: list[str], color: bool | None = None
) -> t.Iterator[tuple[t.BinaryIO | t.TextIO, str, bool]]:
"""Page through text by feeding it to another program.
Returns `True` if the command was found, `False` otherwise and thus another
pager should be attempted.
Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list
produced by :func:`shlex.split`. The command is resolved to an absolute
path with :func:`shutil.which` as recommended by the
:mod:`subprocess` docs for Windows compatibility.
Invoking a pager through this might support colors: if piping to
``less`` and the user hasn't decided on colors, ``LESS=-R`` is set
automatically.
"""
# Split the command into the invoked CLI and its parameters.
if not cmd_parts:
return False
stdout = _default_text_stdout() or StringIO()
yield stdout, "utf-8", False
return
import shutil
@ -428,7 +479,9 @@ def _pipepager(
cmd_filepath = shutil.which(cmd)
if not cmd_filepath:
return False
stdout = _default_text_stdout() or StringIO()
yield stdout, "utf-8", False
return
# Produces a normalized absolute path string.
# multi-call binaries such as busybox derive their identity from the symlink
@ -451,6 +504,9 @@ def _pipepager(
elif "r" in less_flags or "R" in less_flags:
color = True
if color is None:
color = False
c = subprocess.Popen(
[str(cmd_path)] + cmd_params,
shell=False,
@ -459,13 +515,10 @@ def _pipepager(
errors="replace",
text=True,
)
assert c.stdin is not None
stdin = t.cast(t.BinaryIO, c.stdin)
encoding = get_best_encoding(stdin)
try:
for text in generator:
if not color:
text = strip_ansi(text)
c.stdin.write(text)
yield stdin, encoding, color
except BrokenPipeError:
# In case the pager exited unexpectedly, ignore the broken pipe error.
pass
@ -479,7 +532,7 @@ def _pipepager(
finally:
# We must close stdin and wait for the pager to exit before we continue
try:
c.stdin.close()
stdin.close()
# Close implies flush, so it might throw a BrokenPipeError if the pager
# process exited already.
except BrokenPipeError:
@ -501,64 +554,87 @@ def _pipepager(
else:
break
return True
@contextlib.contextmanager
def _tempfilepager(
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
) -> bool:
cmd_parts: list[str], color: bool | None = None
) -> t.Iterator[tuple[t.BinaryIO | t.TextIO, str, bool]]:
"""Page through text by invoking a program on a temporary file.
Returns `True` if the command was found, `False` otherwise and thus another
pager should be attempted.
Used as the primary pager strategy on Windows (where piping to
``more`` adds spurious ``\\r\\n``), and as a fallback on other
platforms. The command is resolved to an absolute path with
:func:`shutil.which`.
"""
# Split the command into the invoked CLI and its parameters.
if not cmd_parts:
return False
stdout = _default_text_stdout() or StringIO()
yield stdout, "utf-8", False
return
import shutil
import subprocess
cmd = cmd_parts[0]
cmd_filepath = shutil.which(cmd)
if not cmd_filepath:
return False
stdout = _default_text_stdout() or StringIO()
yield stdout, "utf-8", False
return
# Produces a normalized absolute path string.
# multi-call binaries such as busybox derive their identity from the symlink
# less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox)
cmd_path = Path(cmd_filepath).absolute()
import subprocess
import tempfile
fd, filename = tempfile.mkstemp()
# TODO: This never terminates if the passed generator never terminates.
text = "".join(generator)
if not color:
text = strip_ansi(text)
encoding = get_best_encoding(sys.stdout)
with open_stream(filename, "wb")[0] as f:
f.write(text.encode(encoding))
if color is None:
color = False
# On Windows, NamedTemporaryFile cannot be opened by another process
# while Python still has it open, so we use delete=False and clean up manually
# rather than using a contextmanager here.
f = tempfile.NamedTemporaryFile(mode="wb", delete=False)
try:
subprocess.call([str(cmd_path), filename])
except OSError:
# Command not found
pass
yield t.cast(t.BinaryIO, f), encoding, color
f.flush()
f.close()
subprocess.call([str(cmd_path), f.name])
finally:
os.close(fd)
os.unlink(filename)
return True
os.unlink(f.name)
class _SkipClose:
def __init__(self, stream: t.IO[t.Any]) -> None:
self.stream = stream
def __getattr__(self, name: str) -> t.Any:
return getattr(self.stream, name)
@property
def buffer(self) -> t.BinaryIO:
return _SkipClose(self.stream.buffer) # type: ignore[attr-defined, return-value]
def close(self) -> None:
pass
@contextlib.contextmanager
def _nullpager(
stream: t.TextIO, generator: cabc.Iterable[str], color: bool | None
) -> None:
"""Simply print unformatted text. This is the ultimate fallback."""
for text in generator:
if not color:
text = strip_ansi(text)
stream.write(text)
stream: t.TextIO, color: bool | None = None
) -> t.Iterator[tuple[t.TextIO, str, bool]]:
"""Simply print unformatted text. This is the ultimate fallback. Don't close the
output stream in this case, since it's coming from elsewhere rather than our
internal helpers.
"""
encoding = get_best_encoding(stream)
if color is None:
color = False
yield _SkipClose(stream), encoding, color # type: ignore[misc]
class Editor:
@ -592,6 +668,8 @@ class Editor:
return "vi"
def edit_files(self, filenames: cabc.Iterable[str]) -> None:
"""Open files in the user's editor."""
import shlex
import subprocess
editor = self.get_editor()
@ -601,11 +679,13 @@ class Editor:
environ = os.environ.copy()
environ.update(self.env)
exc_filename = " ".join(f'"{filename}"' for filename in filenames)
try:
# Split in POSIX mode (the default) for the same reasons as
# in pager(): strips quotes from tokens and preserves quoted
# Windows paths.
c = subprocess.Popen(
args=f"{editor} {exc_filename}", env=environ, shell=True
args=shlex.split(editor) + list(filenames),
env=environ,
)
exit_code = c.wait()
if exit_code != 0:
@ -699,18 +779,17 @@ def open_url(url: str, wait: bool = False, locate: bool = False) -> int:
elif WIN:
if locate:
url = _unquote_file(url)
args = ["explorer", f"/select,{url}"]
args = ["explorer", "/select,", url]
try:
return subprocess.call(args)
except OSError:
return 127
else:
args = ["start"]
if wait:
args.append("/WAIT")
args.append("")
args.append(url)
try:
return subprocess.call(args)
except OSError:
# Command not found
return 127
try:
os.startfile(url) # type: ignore[attr-defined]
except OSError:
return 127
return 0
elif CYGWIN:
if locate:
url = _unquote_file(url)
@ -754,8 +833,6 @@ def _translate_ch_to_exc(ch: str) -> None:
if ch == "\x1a" and WIN: # Windows, Ctrl+Z
raise EOFError()
return None
if sys.platform == "win32":
import msvcrt

View file

@ -4,8 +4,47 @@ import collections.abc as cabc
import textwrap
from contextlib import contextmanager
from ._compat import _ansi_re
from ._compat import term_len
def _truncate_visible(text: str, n: int) -> str:
"""Return the longest prefix of ``text`` containing at most ``n`` visible
characters.
ANSI escape sequences inside the prefix are kept intact and do not count
toward the visible width. A cut is never placed inside an escape sequence.
"""
if n <= 0:
return ""
visible = 0
i = 0
cut = 0
end = len(text)
while i < end:
m = _ansi_re.match(text, i)
if m is not None:
i = m.end()
continue
visible += 1
i += 1
cut = i
if visible >= n:
break
return text[:cut]
class TextWrapper(textwrap.TextWrapper):
"""``textwrap.TextWrapper`` variant that measures widths by visible
character count.
ANSI escape sequences embedded in chunks, indents, or the placeholder are
excluded from the width budget. Without this, styled help text (a styled
``Usage:`` prefix, a colorized option name, ...) would be wrapped earlier
than its visible length warrants and tokens would split mid-word.
"""
def _handle_long_word(
self,
reversed_chunks: list[str],
@ -17,13 +56,111 @@ class TextWrapper(textwrap.TextWrapper):
if self.break_long_words:
last = reversed_chunks[-1]
cut = last[:space_left]
res = last[space_left:]
cut = _truncate_visible(last, space_left)
res = last[len(cut) :]
cur_line.append(cut)
reversed_chunks[-1] = res
elif not cur_line:
cur_line.append(reversed_chunks.pop())
def _wrap_chunks(self, chunks: list[str]) -> list[str]:
"""Wrap chunks counting widths in visible characters.
Mirrors the algorithm of :meth:`textwrap.TextWrapper._wrap_chunks`
with every width measurement routed through
:func:`click._compat.term_len` instead of :func:`len`, so ANSI escape
bytes in chunks, indents, or the placeholder do not inflate the count.
.. seealso::
:class:`textwrap.TextWrapper` in the Python standard library documentation:
https://docs.python.org/3/library/textwrap.html#textwrap.TextWrapper
Reference implementation in CPython:
https://github.com/python/cpython/blob/main/Lib/textwrap.py
"""
lines: list[str] = []
if self.width <= 0:
raise ValueError(f"invalid width {self.width!r} (must be > 0)")
if self.max_lines is not None:
if self.max_lines > 1:
indent = self.subsequent_indent
else:
indent = self.initial_indent
if term_len(indent) + term_len(self.placeholder.lstrip()) > self.width:
raise ValueError("placeholder too large for max width")
chunks.reverse()
while chunks:
cur_line: list[str] = []
cur_len = 0
if lines:
indent = self.subsequent_indent
else:
indent = self.initial_indent
width = self.width - term_len(indent)
if self.drop_whitespace and chunks[-1].strip() == "" and lines:
del chunks[-1]
while chunks:
n = term_len(chunks[-1])
if cur_len + n <= width:
cur_line.append(chunks.pop())
cur_len += n
else:
break
if chunks and term_len(chunks[-1]) > width:
self._handle_long_word(chunks, cur_line, cur_len, width)
cur_len = sum(map(term_len, cur_line))
if self.drop_whitespace and cur_line and cur_line[-1].strip() == "":
cur_len -= term_len(cur_line[-1])
del cur_line[-1]
if cur_line:
if (
self.max_lines is None
or len(lines) + 1 < self.max_lines
or (
not chunks
or self.drop_whitespace
and len(chunks) == 1
and not chunks[0].strip()
)
and cur_len <= width
):
lines.append(indent + "".join(cur_line))
else:
while cur_line:
if (
cur_line[-1].strip()
and cur_len + term_len(self.placeholder) <= width
):
cur_line.append(self.placeholder)
lines.append(indent + "".join(cur_line))
break
cur_len -= term_len(cur_line[-1])
del cur_line[-1]
else:
if lines:
prev_line = lines[-1].rstrip()
if (
term_len(prev_line) + term_len(self.placeholder)
<= self.width
):
lines[-1] = prev_line + self.placeholder
break
lines.append(indent + self.placeholder.lstrip())
break
return lines
@contextmanager
def extra_indent(self, indent: str) -> cabc.Iterator[None]:
old_initial_indent = self.initial_indent

View file

@ -19,18 +19,18 @@ class Sentinel(enum.Enum):
return f"{self.__class__.__name__}.{self.name}"
UNSET = Sentinel.UNSET
UNSET: t.Literal[Sentinel.UNSET] = Sentinel.UNSET
"""Sentinel used to indicate that a value is not set."""
FLAG_NEEDS_VALUE = Sentinel.FLAG_NEEDS_VALUE
FLAG_NEEDS_VALUE: t.Literal[Sentinel.FLAG_NEEDS_VALUE] = Sentinel.FLAG_NEEDS_VALUE
"""Sentinel used to indicate an option was passed as a flag without a
value but is not a flag option.
``Option.consume_value`` uses this to prompt or use the ``flag_value``.
"""
T_UNSET = t.Literal[UNSET] # type: ignore[valid-type]
T_UNSET: t.TypeAlias = t.Literal[Sentinel.UNSET]
"""Type hint for the :data:`UNSET` sentinel value."""
T_FLAG_NEEDS_VALUE = t.Literal[FLAG_NEEDS_VALUE] # type: ignore[valid-type]
T_FLAG_NEEDS_VALUE: t.TypeAlias = t.Literal[Sentinel.FLAG_NEEDS_VALUE]
"""Type hint for the :data:`FLAG_NEEDS_VALUE` sentinel value."""

View file

@ -28,6 +28,7 @@ from ctypes.wintypes import DWORD
from ctypes.wintypes import HANDLE
from ctypes.wintypes import LPCWSTR
from ctypes.wintypes import LPWSTR
from gettext import gettext as _
from ._compat import _NonClosingTextIOWrapper
@ -152,7 +153,7 @@ class _WindowsConsoleReader(_WindowsConsoleRawIOBase):
# wait for KeyboardInterrupt
time.sleep(0.1)
if not rv:
raise OSError(f"Windows error: {GetLastError()}")
raise OSError(_("Windows error: {error}").format(error=GetLastError()))
if buffer[0] == EOF:
return 0
@ -169,7 +170,7 @@ class _WindowsConsoleWriter(_WindowsConsoleRawIOBase):
return "ERROR_SUCCESS"
elif errno == ERROR_NOT_ENOUGH_MEMORY:
return "ERROR_NOT_ENOUGH_MEMORY"
return f"Windows error {errno}"
return _("Windows error: {error}").format(error=errno)
def write(self, b: Buffer) -> int:
bytes_to_be_written = len(b)

View file

@ -7,6 +7,8 @@ import inspect
import os
import sys
import typing as t
from abc import ABC
from abc import abstractmethod
from collections import abc
from collections import Counter
from contextlib import AbstractContextManager
@ -27,6 +29,7 @@ from .exceptions import ClickException
from .exceptions import Exit
from .exceptions import MissingParameter
from .exceptions import NoArgsIsHelpError
from .exceptions import NoSuchCommand
from .exceptions import UsageError
from .formatting import HelpFormatter
from .formatting import join_options
@ -90,6 +93,23 @@ def _check_nested_chain(
raise RuntimeError(message)
def _format_deprecated_label(deprecated: bool | str) -> str:
"""Return the parenthesized deprecation label shown in help text."""
label = _("deprecated").upper()
if isinstance(deprecated, str):
return f"({label}: {deprecated})"
return f"({label})"
def _format_deprecated_suffix(deprecated: bool | str) -> str:
"""Return the trailing reason for a ``DeprecationWarning`` message,
prefixed with a space, or an empty string when no reason was given.
"""
if isinstance(deprecated, str):
return f" {deprecated}"
return ""
def batch(iterable: cabc.Iterable[V], batch_size: int) -> list[tuple[V, ...]]:
return list(zip(*repeat(iter(iterable), batch_size), strict=False))
@ -140,13 +160,26 @@ def iter_params_for_processing(
return sorted(declaration_order, key=sort_key)
class ParameterSource(enum.Enum):
"""This is an :class:`~enum.Enum` that indicates the source of a
class ParameterSource(enum.IntEnum):
"""This is an :class:`~enum.IntEnum` that indicates the source of a
parameter's value.
Use :meth:`click.Context.get_parameter_source` to get the
source for a parameter by name.
Members are ordered from most explicit to least explicit source.
This allows comparison to check if a value was explicitly provided:
.. code-block:: python
source = ctx.get_parameter_source("port")
if source < click.ParameterSource.DEFAULT_MAP:
... # value was explicitly set
.. versionchanged:: 8.3.3
Use :class:`~enum.IntEnum` and reorder members from most to
least explicit. Supports comparison operators.
.. versionchanged:: 8.0
Use :class:`~enum.Enum` and drop the ``validate`` method.
@ -154,16 +187,16 @@ class ParameterSource(enum.Enum):
Added the ``PROMPT`` value.
"""
PROMPT = enum.auto()
"""Used a prompt to confirm a default or provide a value."""
COMMANDLINE = enum.auto()
"""The value was provided by the command line args."""
ENVIRONMENT = enum.auto()
"""The value was provided with an environment variable."""
DEFAULT = enum.auto()
"""Used the default specified by the parameter."""
DEFAULT_MAP = enum.auto()
"""Used a default provided by :attr:`Context.default_map`."""
PROMPT = enum.auto()
"""Used a prompt to confirm a default or provide a value."""
DEFAULT = enum.auto()
"""Used the default specified by the parameter."""
class Context:
@ -183,7 +216,7 @@ class Context:
:param info_name: the info name for this invocation. Generally this
is the most descriptive name for the script or
command. For the toplevel script it is usually
the name of the script, for commands below it it's
the name of the script, for commands below that it's
the name of the script.
:param obj: an arbitrary object of user data.
:param auto_envvar_prefix: the prefix to use for automatic environment
@ -438,6 +471,12 @@ class Context:
self._close_callbacks: list[t.Callable[[], t.Any]] = []
self._depth = 0
self._parameter_source: dict[str, ParameterSource] = {}
# Tracks whether the option that currently owns each parameter slot in
# :attr:`params` had its ``default`` set explicitly by the user. Used
# to tie-break feature-switch groups where multiple options share a
# parameter name and both fall back to their default value.
# Refs: https://github.com/pallets/click/issues/3403
self._param_default_explicit: dict[str, bool] = {}
self._exit_stack = ExitStack()
@property
@ -685,6 +724,20 @@ class Context:
self.obj = rv = object_type()
return rv
def _default_map_has(self, name: str | None) -> bool:
"""Check if :attr:`default_map` contains a real value for ``name``.
Returns ``False`` when the key is absent, the map is ``None``,
``name`` is ``None``, or the stored value is the internal
:data:`UNSET` sentinel.
"""
return (
name is not None
and self.default_map is not None
and name in self.default_map
and self.default_map[name] is not UNSET
)
@t.overload
def lookup_default(
self, name: str, call: t.Literal[True] = True
@ -705,15 +758,17 @@ class Context:
.. versionchanged:: 8.0
Added the ``call`` parameter.
"""
if self.default_map is not None:
value = self.default_map.get(name, UNSET)
if not self._default_map_has(name):
return None
if call and callable(value):
return value()
# Assert to make the type checker happy.
assert self.default_map is not None
value = self.default_map[name]
return value
if call and callable(value):
return value()
return UNSET
return value
def fail(self, message: str) -> t.NoReturn:
"""Aborts the execution of the program with a specific error
@ -809,9 +864,7 @@ class Context:
# https://github.com/pallets/click/pull/3068
if default_value is UNSET:
default_value = None
kwargs[param.name] = param.type_cast_value( # type: ignore
ctx, default_value
)
kwargs[param.name] = param.type_cast_value(ctx, default_value)
# Track all kwargs as params, so that forward() will pass
# them on in subsequent calls.
@ -1066,7 +1119,7 @@ class Command:
# Cache the help option object in private _help_option attribute to
# avoid creating it multiple times. Not doing this will break the
# callback odering by iter_params_for_processing(), which relies on
# callback ordering by iter_params_for_processing(), which relies on
# object comparison.
if self._help_option is None:
# Avoid circular import.
@ -1106,14 +1159,7 @@ class Command:
text = ""
if self.deprecated:
deprecated_message = (
f"(DEPRECATED: {self.deprecated})"
if isinstance(self.deprecated, str)
else "(DEPRECATED)"
)
text = _("{text} {deprecated_message}").format(
text=text, deprecated_message=deprecated_message
)
text = f"{_(text)} {_format_deprecated_label(self.deprecated)}"
return text.strip()
@ -1143,14 +1189,7 @@ class Command:
text = ""
if self.deprecated:
deprecated_message = (
f"(DEPRECATED: {self.deprecated})"
if isinstance(self.deprecated, str)
else "(DEPRECATED)"
)
text = _("{text} {deprecated_message}").format(
text=text, deprecated_message=deprecated_message
)
text = f"{_(text)} {_format_deprecated_label(self.deprecated)}"
if text:
formatter.write_paragraph()
@ -1257,12 +1296,12 @@ class Command:
in the right way.
"""
if self.deprecated:
extra_message = (
f" {self.deprecated}" if isinstance(self.deprecated, str) else ""
)
message = _(
"DeprecationWarning: The command {name!r} is deprecated.{extra_message}"
).format(name=self.name, extra_message=extra_message)
).format(
name=self.name,
extra_message=_format_deprecated_suffix(self.deprecated),
)
echo(style(message, fg="red"), err=True)
if self.callback is not None:
@ -1291,7 +1330,7 @@ class Command:
or param.hidden
or (
not param.multiple
and ctx.get_parameter_source(param.name) # type: ignore
and ctx.get_parameter_source(param.name)
is ParameterSource.COMMANDLINE
)
):
@ -1908,7 +1947,6 @@ class Group(Command):
self, ctx: Context, args: list[str]
) -> tuple[str | None, Command | None, list[str]]:
cmd_name = make_str(args[0])
original_cmd_name = cmd_name
# Get the command
cmd = self.get_command(ctx, cmd_name)
@ -1928,7 +1966,7 @@ class Group(Command):
if cmd is None and not ctx.resilient_parsing:
if _split_opt(cmd_name)[0]:
self.parse_args(ctx, args)
ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name))
raise NoSuchCommand(cmd_name, possibilities=self.commands, ctx=ctx)
return cmd_name if cmd else None, cmd, args[1:]
def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]:
@ -2024,7 +2062,7 @@ def _check_iter(value: t.Any) -> cabc.Iterator[t.Any]:
return iter(value)
class Parameter:
class Parameter(ABC):
r"""A parameter to a command comes in two versions: they are either
:class:`Option`\s or :class:`Argument`\s. Other subclasses are currently
not supported by design as some of the internals for parsing are
@ -2120,7 +2158,7 @@ class Parameter:
def __init__(
self,
param_decls: cabc.Sequence[str] | None = None,
type: types.ParamType | t.Any | None = None,
type: types.ParamType[t.Any] | t.Any | None = None,
required: bool = False,
# XXX The default historically embed two concepts:
# - the declaration of a Parameter object carrying the default (handy to
@ -2146,13 +2184,13 @@ class Parameter:
| None = None,
deprecated: bool | str = False,
) -> None:
self.name: str | None
self.name: str
self.opts: list[str]
self.secondary_opts: list[str]
self.name, self.opts, self.secondary_opts = self._parse_decls(
param_decls or (), expose_value
)
self.type: types.ParamType = types.convert_type(type, default)
self.type: types.ParamType[t.Any] = types.convert_type(type, default)
# Default nargs to what the type tells us if we have that
# information available.
@ -2168,6 +2206,12 @@ class Parameter:
self.multiple = multiple
self.expose_value = expose_value
self.default: t.Any | t.Callable[[], t.Any] | None = default
# Whether the user passed ``default`` explicitly to the constructor.
# Captured before any auto-derived default (like ``False`` for boolean
# flags in :class:`Option`) replaces the :data:`UNSET` sentinel, so it
# remains ``False`` when the default was inferred rather than chosen.
# Refs: https://github.com/pallets/click/issues/3403
self._default_explicit: bool = default is not UNSET
self.is_eager = is_eager
self.metavar = metavar
self.envvar = envvar
@ -2219,17 +2263,17 @@ class Parameter:
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.name}>"
@abstractmethod
def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
) -> tuple[str | None, list[str], list[str]]:
raise NotImplementedError()
) -> tuple[str, list[str], list[str]]: ...
@property
def human_readable_name(self) -> str:
"""Returns the human readable name of this parameter. This is the
same as the name for options, but the metavar for arguments.
"""
return self.name # type: ignore
return self.name
def make_metavar(self, ctx: Context) -> str:
if self.metavar is not None:
@ -2278,9 +2322,9 @@ class Parameter:
.. versionchanged:: 8.0
Added the ``call`` parameter.
"""
value = ctx.lookup_default(self.name, call=False) # type: ignore
value = ctx.lookup_default(self.name, call=False)
if value is UNSET:
if value is None and not ctx._default_map_has(self.name):
value = self.default
if call and callable(value):
@ -2288,8 +2332,8 @@ class Parameter:
return value
def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None:
raise NotImplementedError()
@abstractmethod
def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: ...
def consume_value(
self, ctx: Context, opts: cabc.Mapping[str, t.Any]
@ -2305,7 +2349,7 @@ class Parameter:
:meta private:
"""
# Collect from the parse the value passed by the user to the CLI.
value = opts.get(self.name, UNSET) # type: ignore
value = opts.get(self.name, UNSET)
# If the value is set, it means it was sourced from the command line by the
# parser, otherwise it left unset by default.
source = (
@ -2321,11 +2365,16 @@ class Parameter:
source = ParameterSource.ENVIRONMENT
if value is UNSET:
default_map_value = ctx.lookup_default(self.name) # type: ignore
if default_map_value is not UNSET:
default_map_value = ctx.lookup_default(self.name)
if default_map_value is not None or ctx._default_map_has(self.name):
value = default_map_value
source = ParameterSource.DEFAULT_MAP
# A string from default_map must be split for multi-value
# parameters, matching value_from_envvar behavior.
if isinstance(value, str) and self.nargs != 1:
value = self.type.split_envvar_value(value)
if value is UNSET:
default_value = self.get_default(ctx)
if default_value is not UNSET:
@ -2549,27 +2598,35 @@ class Parameter:
:meta private:
"""
# Capture the slot's existing state before we mutate
# ``_parameter_source`` so the write decision below can compare our
# incoming source against the source of the option that already wrote
# the slot (if any).
existing_value = ctx.params.get(self.name, UNSET)
existing_source = ctx.get_parameter_source(self.name)
existing_default_explicit = ctx._param_default_explicit.get(self.name, False)
with augment_usage_errors(ctx, param=self):
value, source = self.consume_value(ctx, opts)
ctx.set_parameter_source(self.name, source) # type: ignore
# Record the source before processing so eager callbacks and type
# conversion can inspect it. Restored after arbitration if this
# option loses a feature-switch group.
ctx.set_parameter_source(self.name, source)
# Display a deprecation warning if necessary.
if (
self.deprecated
and value is not UNSET
and source not in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP)
and source < ParameterSource.DEFAULT_MAP
):
extra_message = (
f" {self.deprecated}" if isinstance(self.deprecated, str) else ""
)
message = _(
"DeprecationWarning: The {param_type} {name!r} is deprecated."
"{extra_message}"
).format(
param_type=self.param_type_name,
name=self.human_readable_name,
extra_message=extra_message,
extra_message=_format_deprecated_suffix(self.deprecated),
)
echo(style(message, fg="red"), err=True)
@ -2584,32 +2641,49 @@ class Parameter:
# to UNSET, which will be interpreted as a missing value.
value = UNSET
# Add parameter's value to the context.
if (
self.expose_value
# We skip adding the value if it was previously set by another parameter
# targeting the same variable name. This prevents parameters competing for
# the same name to override each other.
and (self.name not in ctx.params or ctx.params[self.name] is UNSET)
):
# Click is logically enforcing that the name is None if the parameter is
# not to be exposed. We still assert it here to please the type checker.
assert self.name is not None, (
f"{self!r} parameter's name should not be None when exposing value."
)
ctx.params[self.name] = value
# Arbitrate the slot when several parameters target the same variable
# name (feature-switch groups). See: https://github.com/pallets/click/issues/3403
slot_empty = existing_value is UNSET
more_explicit = existing_source is not None and source < existing_source
same_source = existing_source is not None and source == existing_source
auto_would_downgrade_explicit = (
same_source
and source == ParameterSource.DEFAULT
and existing_default_explicit
and not self._default_explicit
)
is_winner = (
slot_empty
or more_explicit
or (same_source and not auto_would_downgrade_explicit)
)
if is_winner:
if self.expose_value:
ctx.params[self.name] = value
ctx._param_default_explicit[self.name] = self._default_explicit
elif existing_source is not None:
# Lost arbitration; restore the winning option's source.
ctx.set_parameter_source(self.name, existing_source)
# else: ctx.params[self.name] was populated by code that bypassed
# handle_parse_result (from another option's callback for example). Keep
# the provisional source recorded before process_value so downstream
# lookups don't return ``None``.
return value, args
def get_help_record(self, ctx: Context) -> tuple[str, str] | None:
pass
return None
def get_usage_pieces(self, ctx: Context) -> list[str]:
return []
def get_error_hint(self, ctx: Context) -> str:
def get_error_hint(self, ctx: Context | None) -> str:
"""Get a stringified version of the param for use in error messages to
indicate which param caused the error.
.. versionchanged:: 8.4.0
``ctx`` can be ``None``.
"""
hint_list = self.opts or [self.human_readable_name]
return " / ".join(f"'{x}'" for x in hint_list)
@ -2618,7 +2692,7 @@ class Parameter:
"""Return a list of completions for the incomplete value. If a
``shell_complete`` function was given during init, it is used.
Otherwise, the :attr:`type`
:meth:`~click.types.ParamType.shell_complete` function is used.
:meth:`~click.types.ParamType[t.Any].shell_complete` function is used.
:param ctx: Invocation context for this command.
:param incomplete: Value being completed. May be empty.
@ -2684,6 +2758,11 @@ class Option(Parameter):
:param hidden: hide this option from help outputs.
:param attrs: Other command arguments described in :class:`Parameter`.
.. versionchanged:: 8.4.0
Non-basic ``flag_value`` types (not ``str``, ``int``, ``float``, or
``bool``) are passed through unchanged instead of being stringified.
Previously, ``type=click.UNPROCESSED`` was required to preserve them.
.. versionchanged:: 8.2
``envvar`` used with ``flag_value`` will always use the ``flag_value``,
previously it would use the value of the environment variable.
@ -2701,7 +2780,8 @@ class Option(Parameter):
default value is ``False``.
.. versionchanged:: 8.0.1
``type`` is detected from ``flag_value`` if given.
``type`` is detected from ``flag_value`` if given, for basic Python
types (``str``, ``int``, ``float``, ``bool``).
"""
param_type_name = "option"
@ -2719,7 +2799,7 @@ class Option(Parameter):
multiple: bool = False,
count: bool = False,
allow_from_autoenv: bool = True,
type: types.ParamType | t.Any | None = None,
type: types.ParamType[t.Any] | t.Any | None = None,
help: str | None = None,
hidden: bool = False,
show_choices: bool = True,
@ -2735,7 +2815,7 @@ class Option(Parameter):
)
if prompt is True:
if self.name is None:
if not self.name:
raise TypeError("'name' is required with 'prompt=True'.")
prompt_text: str | None = self.name.replace("_", " ").capitalize()
@ -2745,12 +2825,8 @@ class Option(Parameter):
prompt_text = prompt
if deprecated:
deprecated_message = (
f"(DEPRECATED: {deprecated})"
if isinstance(deprecated, str)
else "(DEPRECATED)"
)
help = help + deprecated_message if help is not None else deprecated_message
label = _format_deprecated_label(deprecated)
help = f"{help} {label}" if help is not None else label
self.prompt = prompt_text
self.confirmation_prompt = confirmation_prompt
@ -2777,10 +2853,13 @@ class Option(Parameter):
# Implicitly a flag because secondary options names were given.
elif self.secondary_opts:
is_flag = True
# The option is explicitly not a flag. But we do not know yet if it needs a
# value or not. So we look at the default value to determine it.
# The option is explicitly not a flag, but to determine whether or not it needs
# value, we need to check if `flag_value` or `default` was set. Either one is
# sufficient.
# Ref: https://github.com/pallets/click/issues/3084
elif is_flag is False and not self._flag_needs_value:
self._flag_needs_value = self.default is UNSET
self._flag_needs_value = flag_value is not UNSET or self.default is UNSET
if is_flag:
# Set missing default for flags if not explicitly required or prompted.
@ -2792,13 +2871,26 @@ class Option(Parameter):
if type is None:
# A flag without a flag_value is a boolean flag.
if flag_value is UNSET:
self.type: types.ParamType = types.BoolParamType()
self.type: types.ParamType[t.Any] = types.BoolParamType()
# If the flag value is a boolean, use BoolParamType.
elif isinstance(flag_value, bool):
self.type = types.BoolParamType()
# Otherwise, guess the type from the flag value.
else:
self.type = types.convert_type(None, flag_value)
guessed = types.convert_type(None, flag_value)
if (
isinstance(guessed, types.StringParamType)
and not isinstance(flag_value, str)
and flag_value is not None
):
# The flag_value type couldn't be auto-detected
# (not str, int, float, or bool). Since flag_value
# is a programmer-provided Python object, not CLI
# input, pass it through unchanged instead of
# stringifying it.
self.type = types.UNPROCESSED
else:
self.type = guessed
self.is_flag: bool = bool(is_flag)
self.is_bool_flag: bool = bool(
@ -2811,14 +2903,12 @@ class Option(Parameter):
if self.default is UNSET and not self.required:
self.default = False
# Support the special case of aligning the default value with the flag_value
# for flags whose default is explicitly set to True. Note that as long as we
# have this condition, there is no way a flag can have a default set to True,
# and a flag_value set to something else. Refs:
# The alignment of default to the flag_value is resolved lazily in
# get_default() to prevent callable flag_values (like classes) from
# being instantiated. Refs:
# https://github.com/pallets/click/issues/3121
# https://github.com/pallets/click/issues/3024#issuecomment-3146199461
# https://github.com/pallets/click/pull/3030/commits/06847da
if self.default is True and self.flag_value is not UNSET:
self.default = self.flag_value
# Set the default flag_value if it is not set.
if self.flag_value is UNSET:
@ -2882,7 +2972,40 @@ class Option(Parameter):
)
return info_dict
def get_error_hint(self, ctx: Context) -> str:
def get_default(
self, ctx: Context, call: bool = True
) -> t.Any | t.Callable[[], t.Any] | None:
"""Return the default value for this option.
For non-boolean flag options, ``default=True`` is treated as a sentinel
meaning "activate this flag by default" and is resolved to
:attr:`flag_value`. For example, with ``--upper/--lower`` feature
switches where ``flag_value="upper"`` and ``default=True``, the default
resolves to ``"upper"``.
.. caution::
This substitution only applies to non-boolean flags
(:attr:`is_bool_flag` is ``False``). For boolean flags, ``True`` is
a legitimate Python value and ``default=True`` is returned as-is.
.. versionchanged:: 8.3.3
``default=True`` is no longer substituted with ``flag_value`` for
boolean flags, fixing negative boolean flags like
``flag_value=False, default=True``.
"""
value = super().get_default(ctx, call=False)
# Resolve default=True to flag_value lazily (here instead of
# __init__) to prevent callable flag_values (like classes) from
# being instantiated by the callable check below.
if value is True and self.is_flag and not self.is_bool_flag:
value = self.flag_value
elif call and callable(value):
value = value()
return value
def get_error_hint(self, ctx: Context | None) -> str:
result = super().get_error_hint(ctx)
if self.show_envvar and self.envvar is not None:
result += f" (env var: '{self.envvar}')"
@ -2890,7 +3013,7 @@ class Option(Parameter):
def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
) -> tuple[str | None, list[str], list[str]]:
) -> tuple[str, list[str], list[str]]:
opts = []
secondary_opts = []
name = None
@ -2899,7 +3022,7 @@ class Option(Parameter):
for decl in decls:
if decl.isidentifier():
if name is not None:
raise TypeError(f"Name '{name}' defined twice")
raise TypeError(_("Name '{name}' defined twice").format(name=name))
name = decl
else:
split_char = ";" if decl[:1] == "/" else "/"
@ -2914,8 +3037,10 @@ class Option(Parameter):
secondary_opts.append(second.lstrip())
if first == second:
raise ValueError(
f"Boolean option {decl!r} cannot use the"
" same flag for true/false."
_(
"Boolean option {decl!r} cannot use the"
" same flag for true/false."
).format(decl=decl)
)
else:
possible_names.append(_split_opt(decl))
@ -2929,16 +3054,20 @@ class Option(Parameter):
if name is None:
if not expose_value:
return None, opts, secondary_opts
return "", opts, secondary_opts
raise TypeError(
f"Could not determine name for option with declarations {decls!r}"
_(
"Could not determine name for option with declarations {decls!r}"
).format(decls=decls)
)
if not opts and not secondary_opts:
raise TypeError(
f"No options defined but a name was passed ({name})."
" Did you mean to declare an argument instead? Did"
f" you mean to pass '--{name}'?"
_(
"No options defined but a name was passed ({name})."
" Did you mean to declare an argument instead? Did"
" you mean to pass '--{name}'?"
).format(name=name)
)
return name, opts, secondary_opts
@ -3037,7 +3166,7 @@ class Option(Parameter):
if (
self.allow_from_autoenv
and ctx.auto_envvar_prefix is not None
and self.name is not None
and self.name
):
envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}"
@ -3088,7 +3217,7 @@ class Option(Parameter):
)[1]
elif self.is_bool_flag and not self.secondary_opts and not default_value:
default_string = ""
elif default_value == "":
elif isinstance(default_value, str) and default_value == "":
default_string = '""'
else:
default_string = str(default_value)
@ -3142,10 +3271,10 @@ class Option(Parameter):
default = bool(default)
return confirm(self.prompt, default)
# If show_default is set to True/False, provide this to `prompt` as well. For
# non-bool values of `show_default`, we use `prompt`'s default behavior
# If show_default is given, provide this to `prompt` as well,
# otherwise we use `prompt`'s default behavior
prompt_kwargs: t.Any = {}
if isinstance(self.show_default, bool):
if self.show_default is not None:
prompt_kwargs["show_default"] = self.show_default
return prompt(
@ -3176,11 +3305,7 @@ class Option(Parameter):
if rv is not None:
return rv
if (
self.allow_from_autoenv
and ctx.auto_envvar_prefix is not None
and self.name is not None
):
if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None and self.name:
envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}"
rv = os.environ.get(envvar)
@ -3263,7 +3388,7 @@ class Option(Parameter):
self.is_flag
and value is True
and not self.is_bool_flag
and source not in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP)
and source < ParameterSource.DEFAULT_MAP
):
value = self.flag_value
@ -3273,7 +3398,8 @@ class Option(Parameter):
elif (
self.multiple
and value is not UNSET
and source not in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP)
and isinstance(value, cabc.Iterable)
and source < ParameterSource.DEFAULT_MAP
and any(v is FLAG_NEEDS_VALUE for v in value)
):
value = [self.flag_value if v is FLAG_NEEDS_VALUE else v for v in value]
@ -3282,10 +3408,7 @@ class Option(Parameter):
# The value wasn't set, or used the param's default, prompt for one to the user
# if prompting is enabled.
elif (
(
value is UNSET
or source in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP)
)
(value is UNSET or source >= ParameterSource.DEFAULT_MAP)
and self.prompt is not None
and (self.required or self.prompt_required)
and not ctx.resilient_parsing
@ -3348,14 +3471,14 @@ class Argument(Parameter):
def human_readable_name(self) -> str:
if self.metavar is not None:
return self.metavar
return self.name.upper() # type: ignore
return self.name.upper()
def make_metavar(self, ctx: Context) -> str:
if self.metavar is not None:
return self.metavar
var = self.type.get_metavar(param=self, ctx=ctx)
if not var:
var = self.name.upper() # type: ignore
var = self.name.upper()
if self.deprecated:
var += "!"
if not self.required:
@ -3366,26 +3489,30 @@ class Argument(Parameter):
def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
) -> tuple[str | None, list[str], list[str]]:
) -> tuple[str, list[str], list[str]]:
if not decls:
if not expose_value:
return None, [], []
return "", [], []
raise TypeError("Argument is marked as exposed, but does not have a name.")
if len(decls) == 1:
name = arg = decls[0]
name = name.replace("-", "_").lower()
else:
raise TypeError(
"Arguments take exactly one parameter declaration, got"
f" {len(decls)}: {decls}."
_(
"Arguments take exactly one parameter declaration, got"
" {length}: {decls}."
).format(length=len(decls), decls=decls)
)
return name, [arg], []
def get_usage_pieces(self, ctx: Context) -> list[str]:
return [self.make_metavar(ctx)]
def get_error_hint(self, ctx: Context) -> str:
return f"'{self.make_metavar(ctx)}'"
def get_error_hint(self, ctx: Context | None) -> str:
if ctx is not None:
return f"'{self.make_metavar(ctx)}'"
return f"'{self.human_readable_name}'"
def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None:
parser.add_argument(dest=self.name, nargs=self.nargs, obj=self)

View file

@ -396,8 +396,8 @@ def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC],
kwargs.setdefault("is_flag", True)
kwargs.setdefault("callback", callback)
kwargs.setdefault("expose_value", False)
kwargs.setdefault("prompt", "Do you want to continue?")
kwargs.setdefault("help", "Confirm the action without prompting.")
kwargs.setdefault("prompt", _("Do you want to continue?"))
kwargs.setdefault("help", _("Confirm the action without prompting."))
return option(*param_decls, **kwargs)

View file

@ -23,6 +23,15 @@ def _join_param_hints(param_hint: cabc.Sequence[str] | str | None) -> str | None
return param_hint
def _format_possibilities(possibilities: list[str]) -> str:
possibility_str = ", ".join(repr(p) for p in sorted(possibilities))
return ngettext(
"Did you mean {possibility}?",
"(Did you mean one of: {possibilities}?)",
len(possibilities),
).format(possibility=possibility_str, possibilities=possibility_str)
class ClickException(Exception):
"""An exception that Click can handle and show to the user."""
@ -78,8 +87,12 @@ class UsageError(ClickException):
self.ctx is not None
and self.ctx.command.get_help_option(self.ctx) is not None
):
help_names = self.ctx.command.get_help_option_names(self.ctx)
# Pick the longest name (like ``--help`` over ``-h``) for
# readability in error messages.
hint = _("Try '{command} {option}' for help.").format(
command=self.ctx.command_path, option=self.ctx.help_option_names[0]
command=self.ctx.command_path,
option=max(help_names, key=len),
)
hint = f"{hint}\n"
if self.ctx is not None:
@ -125,7 +138,7 @@ class BadParameter(UsageError):
if self.param_hint is not None:
param_hint = self.param_hint
elif self.param is not None:
param_hint = self.param.get_error_hint(self.ctx) # type: ignore
param_hint = self.param.get_error_hint(self.ctx)
else:
return _("Invalid value: {message}").format(message=self.message)
@ -161,7 +174,7 @@ class MissingParameter(BadParameter):
if self.param_hint is not None:
param_hint: cabc.Sequence[str] | str | None = self.param_hint
elif self.param is not None:
param_hint = self.param.get_error_hint(self.ctx) # type: ignore
param_hint = self.param.get_error_hint(self.ctx)
else:
param_hint = None
@ -206,8 +219,7 @@ class MissingParameter(BadParameter):
class NoSuchOption(UsageError):
"""Raised if click attempted to handle an option that does not
exist.
"""Raised if Click attempted to handle an option that does not exist.
.. versionadded:: 4.0
"""
@ -216,27 +228,54 @@ class NoSuchOption(UsageError):
self,
option_name: str,
message: str | None = None,
possibilities: cabc.Sequence[str] | None = None,
possibilities: cabc.Iterable[str] | None = None,
ctx: Context | None = None,
) -> None:
if message is None:
message = _("No such option: {name}").format(name=option_name)
message = _("No such option {name!r}.").format(name=option_name)
super().__init__(message, ctx)
self.option_name = option_name
self.possibilities = possibilities
self.possibilities: list[str] | None = None
if possibilities:
from difflib import get_close_matches
self.possibilities = get_close_matches(option_name, possibilities)
def format_message(self) -> str:
if not self.possibilities:
return self.message
return f"{self.message} {_format_possibilities(self.possibilities)}"
possibility_str = ", ".join(sorted(self.possibilities))
suggest = ngettext(
"Did you mean {possibility}?",
"(Possible options: {possibilities})",
len(self.possibilities),
).format(possibility=possibility_str, possibilities=possibility_str)
return f"{self.message} {suggest}"
class NoSuchCommand(UsageError):
"""Raised if Click attempted to handle a command that does not exist.
.. versionadded:: 8.4.0
"""
def __init__(
self,
command_name: str,
message: str | None = None,
possibilities: cabc.Iterable[str] | None = None,
ctx: Context | None = None,
) -> None:
if message is None:
message = _("No such command {name!r}.").format(name=command_name)
super().__init__(message, ctx)
self.command_name = command_name
self.possibilities: list[str] | None = None
if possibilities:
from difflib import get_close_matches
self.possibilities = get_close_matches(command_name, possibilities)
def format_message(self) -> str:
if not self.possibilities:
return self.message
return f"{self.message} {_format_possibilities(self.possibilities)}"
class BadOptionUsage(UsageError):

View file

@ -52,6 +52,12 @@ def wrap_text(
each consecutive line.
:param preserve_paragraphs: if this flag is set then the wrapping will
intelligently handle paragraphs.
.. versionchanged:: 8.4.0
Width is measured in visible characters. ANSI escape sequences in
``text``, ``initial_indent``, or ``subsequent_indent`` no longer
count toward the width budget, so styled input wraps based on what
the user sees instead of raw byte length.
"""
from ._textwrap import TextWrapper
@ -153,11 +159,19 @@ class HelpFormatter:
``"Usage: "``.
"""
if prefix is None:
prefix = f"{_('Usage:')} "
prefix = "{usage} ".format(usage=_("Usage:"))
usage_prefix = f"{prefix:>{self.current_indent}}{prog} "
text_width = self.width - self.current_indent
if not args:
# Without args, the prefix's trailing space and the wrap_text
# call that would normally place args on the line are both
# unnecessary. Emit just the prefix line.
self.write(usage_prefix.rstrip(" "))
self.write("\n")
return
if text_width >= (term_len(usage_prefix) + 20):
# The arguments will fit to the right of the prefix.
indent = " " * term_len(usage_prefix)

View file

@ -50,7 +50,7 @@ V = t.TypeVar("V")
def _unpack_args(
args: cabc.Sequence[str], nargs_spec: cabc.Sequence[int]
) -> tuple[cabc.Sequence[str | cabc.Sequence[str | None] | None], list[str]]:
) -> tuple[cabc.Sequence[str | cabc.Sequence[str | T_UNSET] | T_UNSET], list[str]]:
"""Given an iterable of arguments and an iterable of nargs specifications,
it returns a tuple with all the unpacked arguments at the first index
and all remaining arguments as the second.
@ -65,7 +65,7 @@ def _unpack_args(
rv: list[str | tuple[str | T_UNSET, ...] | T_UNSET] = []
spos: int | None = None
def _fetch(c: deque[V]) -> V | T_UNSET:
def _fetch(c: deque[str]) -> str | T_UNSET:
try:
if spos is None:
return c.popleft()
@ -75,15 +75,15 @@ def _unpack_args(
return UNSET
while nargs_spec:
nargs = _fetch(nargs_spec)
if nargs is None:
continue
if spos is None:
nargs = nargs_spec.popleft()
else:
nargs = nargs_spec.pop()
if nargs == 1:
rv.append(_fetch(args)) # type: ignore[arg-type]
rv.append(_fetch(args))
elif nargs > 1:
x = [_fetch(args) for _ in range(nargs)]
x: list[str | T_UNSET] = [_fetch(args) for _ in range(nargs)]
# If we're reversed, we're pulling in the arguments in reverse,
# so we need to turn them around.
@ -141,7 +141,11 @@ class _Option:
for opt in opts:
prefix, value = _split_opt(opt)
if not prefix:
raise ValueError(f"Invalid start character for option ({opt})")
raise ValueError(
_("Invalid start character for option ({option})").format(
option=opt
)
)
self.prefixes.add(prefix[0])
if len(prefix) == 1 and len(value) == 1:
self._short_opts.append(opt)
@ -186,12 +190,12 @@ class _Argument:
def process(
self,
value: str | cabc.Sequence[str | None] | None | T_UNSET,
value: str | cabc.Sequence[str | T_UNSET] | T_UNSET,
state: _ParsingState,
) -> None:
if self.nargs > 1:
assert isinstance(value, cabc.Sequence)
holes = sum(1 for x in value if x is UNSET)
holes = sum(x is UNSET for x in value)
if holes == len(value):
value = UNSET
elif holes != 0:
@ -360,10 +364,7 @@ class _OptionParser:
self, opt: str, explicit_value: str | None, state: _ParsingState
) -> None:
if opt not in self._long_opt:
from difflib import get_close_matches
possibilities = get_close_matches(opt, self._long_opt)
raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx)
raise NoSuchOption(opt, possibilities=self._long_opt, ctx=self.ctx)
option = self._long_opt[opt]
if option.takes_value:
@ -421,17 +422,17 @@ class _OptionParser:
# If we got any unknown options we recombine the string of the
# remaining options and re-attach the prefix, then report that
# to the state as new larg. This way there is basic combinatorics
# to the state as new large. This way there is basic combinatorics
# that can be achieved while still ignoring unknown arguments.
if self.ignore_unknown_options and unknown_options:
state.largs.append(f"{prefix}{''.join(unknown_options)}")
def _get_value_from_state(
self, option_name: str, option: _Option, state: _ParsingState
) -> str | cabc.Sequence[str] | T_FLAG_NEEDS_VALUE:
) -> str | cabc.Sequence[str] | T_UNSET | T_FLAG_NEEDS_VALUE:
nargs = option.nargs
value: str | cabc.Sequence[str] | T_FLAG_NEEDS_VALUE
value: str | cabc.Sequence[str] | T_UNSET | T_FLAG_NEEDS_VALUE
if len(state.rargs) < nargs:
if option.obj._flag_needs_value:

View file

@ -43,12 +43,13 @@ def shell_complete(
comp = comp_cls(cli, ctx_args, prog_name, complete_var)
# Write bytes, otherwise Windows text stdout translates LF to CRLF and breaks.
if instruction == "source":
echo(comp.source())
echo(comp.source().encode(), nl=False)
return 0
if instruction == "complete":
echo(comp.complete())
echo(comp.complete().encode())
return 0
return 1
@ -180,14 +181,18 @@ function %(complete_func)s;
COMP_CWORD=(commandline -t) %(prog_name)s);
for completion in $response;
set -l metadata (string split "," $completion);
set -l metadata (string split \n $completion);
if test $metadata[1] = "dir";
__fish_complete_directories $metadata[2];
else if test $metadata[1] = "file";
__fish_complete_path $metadata[2];
else if test $metadata[1] = "plain";
echo $metadata[2];
if test $metadata[3] != "_";
echo $metadata[2]\t$metadata[3];
else;
echo $metadata[2];
end;
end;
end;
end;
@ -417,10 +422,20 @@ class FishComplete(ShellComplete):
return args, incomplete
def format_completion(self, item: CompletionItem) -> str:
if item.help:
return f"{item.type},{item.value}\t{item.help}"
return f"{item.type},{item.value}"
"""
.. versionchanged:: 8.4.0
Escape newlines in value and help to fix completion errors with
multi-line help strings.
"""
# The fish completion script splits each response line on literal
# newlines, so any newline in the value or help would corrupt the
# frame. Replace them with the two-character escape "\n" so the text
# round-trips through fish without breaking the format. The "_"
# sentinel for missing help mirrors :class:`ZshComplete`.
help_ = item.help or "_"
value = item.value.replace("\n", r"\n")
help_escaped = help_.replace("\n", r"\n")
return f"{item.type}\n{value}\n{help_escaped}"
ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]")
@ -511,8 +526,6 @@ def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool:
if not isinstance(param, Argument):
return False
assert param.name is not None
# Will be None if expose_value is False.
value = ctx.params.get(param.name)
return (
param.nargs == -1

View file

@ -4,13 +4,16 @@ import collections.abc as cabc
import inspect
import io
import itertools
import re
import sys
import typing as t
from contextlib import AbstractContextManager
from contextlib import redirect_stdout
from gettext import gettext as _
from ._compat import isatty
from ._compat import strip_ansi
from ._compat import WIN
from .exceptions import Abort
from .exceptions import UsageError
from .globals import resolve_color_default
@ -51,23 +54,66 @@ _ansi_colors = {
_ansi_reset_all = "\033[0m"
_HIDDEN_INPUT_MASK = "'***'"
def _mask_hidden_input(message: str, value: str) -> str:
"""Replace occurrences of ``value`` in ``message`` with a fixed mask.
Both ``repr(value)`` (the form built-in :class:`ParamType` errors use
via ``{value!r}``) and the raw value are masked. The raw-value pass
uses word-boundary lookarounds so a substring like ``"1"`` does not
match inside ``"10"``, and ``"ent"`` does not match inside
``"Authentication"``. The empty string is skipped to avoid matching
at every boundary.
"""
message = message.replace(repr(value), _HIDDEN_INPUT_MASK)
if value:
message = re.sub(
rf"(?<!\w){re.escape(value)}(?!\w)", _HIDDEN_INPUT_MASK, message
)
return message
def hidden_prompt_func(prompt: str) -> str:
import getpass
return getpass.getpass(prompt)
def _readline_prompt(func: t.Callable[[str], str], text: str, err: bool) -> str:
"""Call a prompt function, passing the full prompt on non-Windows so
readline can handle line editing and cursor positioning correctly.
On Windows the prompt is written separately via :func:`echo` for
colorama support, with only the last character passed to *func*.
"""
if WIN:
# Write the prompt separately so that we get nice coloring
# through colorama on Windows.
echo(text[:-1], nl=False, err=err)
# Echo the last character to stdout to work around an issue
# where readline causes backspace to clear the whole line.
return func(text[-1:])
if err:
with redirect_stdout(sys.stderr):
return func(text)
return func(text)
def _build_prompt(
text: str,
suffix: str,
show_default: bool = False,
show_default: bool | str = False,
default: t.Any | None = None,
show_choices: bool = True,
type: ParamType | None = None,
type: ParamType[t.Any] | None = None,
) -> str:
prompt = text
if type is not None and show_choices and isinstance(type, Choice):
prompt += f" ({', '.join(map(str, type.choices))})"
if isinstance(show_default, str):
default = f"({show_default})"
if default is not None and show_default:
prompt = f"{prompt} [{_format_default(default)}]"
return f"{prompt}{suffix}"
@ -85,10 +131,10 @@ def prompt(
default: t.Any | None = None,
hide_input: bool = False,
confirmation_prompt: bool | str = False,
type: ParamType | t.Any | None = None,
type: ParamType[t.Any] | t.Any | None = None,
value_proc: t.Callable[[str], t.Any] | None = None,
prompt_suffix: str = ": ",
show_default: bool = True,
show_default: bool | str = True,
err: bool = False,
show_choices: bool = True,
) -> t.Any:
@ -112,6 +158,8 @@ def prompt(
convert a value.
:param prompt_suffix: a suffix that should be added to the prompt.
:param show_default: shows or hides the default value in the prompt.
If this value is a string, it shows that string
in parentheses instead of the actual value.
:param err: if set to true the file defaults to ``stderr`` instead of
``stdout``, the same as with echo.
:param show_choices: Show or hide choices if the passed type is a Choice.
@ -119,6 +167,10 @@ def prompt(
show_choices is true and text is "Group by" then the
prompt will be "Group by (day, week): ".
.. versionchanged:: 8.3.3
``show_default`` can be a string to show a custom value instead
of the actual default, matching the help text behavior.
.. versionchanged:: 8.3.1
A space is no longer appended to the prompt.
@ -139,12 +191,7 @@ def prompt(
def prompt_func(text: str) -> str:
f = hidden_prompt_func if hide_input else visible_prompt_func
try:
# Write the prompt separately so that we get nice
# coloring through colorama on Windows
echo(text[:-1], nl=False, err=err)
# Echo the last character to stdout to work around an issue where
# readline causes backspace to clear the whole line.
return f(text[-1:])
return _readline_prompt(f, text, err)
except (KeyboardInterrupt, EOFError):
# getpass doesn't print a newline if the user aborts input with ^C.
# Allegedly this behavior is inherited from getpass(3).
@ -177,10 +224,8 @@ def prompt(
try:
result = value_proc(value)
except UsageError as e:
if hide_input:
echo(_("Error: The value you entered was invalid."), err=err)
else:
echo(_("Error: {e.message}").format(e=e), err=err)
message = _mask_hidden_input(e.message, value) if hide_input else e.message
echo(_("Error: {message}").format(message=message), err=err)
continue
if not confirmation_prompt:
return result
@ -235,12 +280,7 @@ def confirm(
while True:
try:
# Write the prompt separately so that we get nice
# coloring through colorama on Windows
echo(prompt[:-1], nl=False, err=err)
# Echo the last character to stdout to work around an issue where
# readline causes backspace to clear the whole line.
value = visible_prompt_func(prompt[-1:]).lower().strip()
value = _readline_prompt(visible_prompt_func, prompt, err).lower().strip()
except (KeyboardInterrupt, EOFError):
raise Abort() from None
if value in ("y", "yes"):
@ -258,6 +298,25 @@ def confirm(
return rv
def get_pager_file(
color: bool | None = None,
) -> t.ContextManager[t.TextIO]:
"""Context manager.
Yields a writable file-like object which can be used as an output pager.
.. versionadded:: 8.4.0
:param color: controls if the pager supports ANSI colors or not. The
default is autodetection.
"""
from ._termui_impl import get_pager_file
color = resolve_color_default(color)
return get_pager_file(color=color)
def echo_via_pager(
text_or_generator: cabc.Iterable[str] | t.Callable[[], cabc.Iterable[str]] | str,
color: bool | None = None,
@ -273,7 +332,6 @@ def echo_via_pager(
:param color: controls if the pager supports ANSI colors or not. The
default is autodetection.
"""
color = resolve_color_default(color)
if inspect.isgeneratorfunction(text_or_generator):
i = t.cast("t.Callable[[], cabc.Iterable[str]]", text_or_generator)()
@ -285,9 +343,9 @@ def echo_via_pager(
# convert every element of i to a text type if necessary
text_generator = (el if isinstance(el, str) else str(el) for el in i)
from ._termui_impl import pager
return pager(itertools.chain(text_generator, "\n"), color)
with get_pager_file(color=color) as pager:
for text in itertools.chain(text_generator, "\n"):
pager.write(text)
@t.overload
@ -614,13 +672,13 @@ def style(
try:
bits.append(f"\033[{_interpret_color(fg)}m")
except KeyError:
raise TypeError(f"Unknown color {fg!r}") from None
raise TypeError(_("Unknown color {colour!r}").format(colour=fg)) from None
if bg:
try:
bits.append(f"\033[{_interpret_color(bg, 10)}m")
except KeyError:
raise TypeError(f"Unknown color {bg!r}") from None
raise TypeError(_("Unknown color {colour!r}").format(colour=bg)) from None
if bold is not None:
bits.append(f"\033[{1 if bold else 22}m")

View file

@ -4,6 +4,7 @@ import collections.abc as cabc
import contextlib
import io
import os
import pdb
import shlex
import sys
import tempfile
@ -21,6 +22,8 @@ if t.TYPE_CHECKING:
from .core import Command
CaptureMode = t.Literal["sys", "fd"]
class EchoingStdin:
def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None:
@ -66,6 +69,39 @@ def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]:
stream._paused = False
class _FDCapture:
"""Redirect a file descriptor to a temporary file for capture.
Saves the current target of *targetfd* via :func:`os.dup`, then
redirects it to a temporary file via :func:`os.dup2`. On
:meth:`stop`, restores the original ``fd`` and returns the captured
bytes. Inspired by Pytest's ``FDCapture``.
.. versionadded:: 8.4.0
"""
def __init__(self, targetfd: int) -> None:
self._targetfd = targetfd
self.saved_fd: int = -1
self._tmpfile: t.BinaryIO | None = None
def start(self) -> None:
self.saved_fd = os.dup(self._targetfd)
self._tmpfile = tempfile.TemporaryFile(buffering=0)
os.dup2(self._tmpfile.fileno(), self._targetfd)
def stop(self) -> bytes:
assert self._tmpfile is not None, "_FDCapture.start() was not called"
os.dup2(self.saved_fd, self._targetfd)
os.close(self.saved_fd)
self.saved_fd = -1
self._tmpfile.seek(0)
data = self._tmpfile.read()
self._tmpfile.close()
self._tmpfile = None
return data
class BytesIOCopy(io.BytesIO):
"""Patch ``io.BytesIO`` to let the written stream be copied to another.
@ -98,26 +134,48 @@ class StreamMixer:
self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output)
self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output)
def __del__(self) -> None:
"""
Guarantee that embedded file-like objects are closed in a
predictable order, protecting against races between
self.output being closed and other streams being flushed on close
.. versionadded:: 8.2.2
"""
self.stderr.close()
self.stdout.close()
self.output.close()
class _NamedTextIOWrapper(io.TextIOWrapper):
"""A :class:`~io.TextIOWrapper` with custom ``name`` and ``mode``
that does not close its underlying buffer.
When ``CliRunner`` runs in ``fd`` mode, ``_original_fd`` is patched to
point at the saved (pre-redirection) ``fd``, so C-level consumers that call
:meth:`fileno` (like ``faulthandler`` or ``subprocess``) keep working. In
the default ``sys`` mode ``_original_fd`` stays at ``-1`` and
:meth:`fileno` raises :exc:`io.UnsupportedOperation`, matching the
pre-``8.3.3`` behavior.
"""
def __init__(
self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any
self,
buffer: t.BinaryIO,
name: str,
mode: str,
**kwargs: t.Any,
) -> None:
super().__init__(buffer, **kwargs)
self._name = name
self._mode = mode
self._original_fd: int = -1
def close(self) -> None:
"""The buffer this object contains belongs to some other object,
so prevent the default ``__del__`` implementation from closing
that buffer.
.. versionadded:: 8.3.2
"""
def fileno(self) -> int:
"""Return the file descriptor of the saved original stream when
``CliRunner`` runs in ``fd`` mode. Otherwise delegate to
:class:`~io.TextIOWrapper`, which raises
:exc:`io.UnsupportedOperation` for a ``BytesIO``-backed buffer.
"""
if self._original_fd >= 0:
return self._original_fd
return super().fileno()
@property
def name(self) -> str:
@ -240,6 +298,21 @@ class CliRunner:
will automatically echo the input.
:param catch_exceptions: Whether to catch any exceptions other than
``SystemExit`` when running :meth:`~CliRunner.invoke`.
:param capture: Selects the output capture strategy. ``sys`` (default)
captures Python-level writes only and leaves
:meth:`sys.stdout.fileno` raising :exc:`io.UnsupportedOperation`, so
user code that calls :func:`os.dup2` on ``sys.stdout.fileno()`` cannot
clobber the host runner's stdout. ``fd`` redirects file descriptors
``1`` and ``2`` via :func:`os.dup2` to a temporary file, also catching
output from stale stream references, C extensions, and subprocesses.
``fd`` is not supported on Windows.
.. versionchanged:: 8.4.0
Added the ``capture`` parameter. The default ``sys`` mode no longer
exposes the original fd through :meth:`fileno`, reverting the change
introduced in ``8.3.3`` that broke Pytest's ``fd``-level capture
teardown. Use ``capture="fd"`` to restore that behavior with proper
isolation. :issue:`3384`
.. versionchanged:: 8.2
Added the ``catch_exceptions`` parameter.
@ -254,11 +327,21 @@ class CliRunner:
env: cabc.Mapping[str, str | None] | None = None,
echo_stdin: bool = False,
catch_exceptions: bool = True,
capture: CaptureMode = "sys",
) -> None:
if capture not in {"sys", "fd"}:
raise ValueError(
f"capture={capture!r} is not valid. Choose from 'sys' or 'fd'."
)
if capture == "fd" and sys.platform == "win32":
raise ValueError(
f"capture={capture!r} is not supported on Windows. Use 'sys'."
)
self.charset = charset
self.env: cabc.Mapping[str, str | None] = env or {}
self.echo_stdin = echo_stdin
self.catch_exceptions = catch_exceptions
self.capture: CaptureMode = capture
def get_default_prog_name(self, cli: Command) -> str:
"""Given a command object it will return the default program name
@ -338,7 +421,10 @@ class CliRunner:
text_input._CHUNK_SIZE = 1 # type: ignore
sys.stdout = _NamedTextIOWrapper(
stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w"
stream_mixer.stdout,
encoding=self.charset,
name="<stdout>",
mode="w",
)
sys.stderr = _NamedTextIOWrapper(
@ -393,12 +479,52 @@ class CliRunner:
old__getchar_func = termui._getchar
old_should_strip_ansi = utils.should_strip_ansi # type: ignore
old__compat_should_strip_ansi = _compat.should_strip_ansi
old_pdb_init = pdb.Pdb.__init__
termui.visible_prompt_func = visible_input
termui.hidden_prompt_func = hidden_input
termui._getchar = _getchar
utils.should_strip_ansi = should_strip_ansi # type: ignore
_compat.should_strip_ansi = should_strip_ansi
def _patched_pdb_init(
self: pdb.Pdb,
completekey: str = "tab",
stdin: t.IO[str] | None = None,
stdout: t.IO[str] | None = None,
**kwargs: t.Any,
) -> None:
"""Default ``pdb.Pdb`` to real terminal streams during
``CliRunner`` isolation.
Without this patch, ``pdb.Pdb.__init__`` inherits from
``cmd.Cmd`` which falls back to ``sys.stdin``/``sys.stdout``
when no explicit streams are provided. During isolation
those are ``BytesIO``-backed wrappers, so the debugger
reads from an empty buffer and writes to captured output,
making interactive debugging impossible.
By defaulting to ``sys.__stdin__``/``sys.__stdout__`` (the
original terminal streams Python preserves regardless of
redirection), debuggers can interact with the user while
``click.echo`` output is still captured normally.
This covers ``pdb.set_trace()``, ``breakpoint()``,
``pdb.post_mortem()``, and debuggers that subclass
``pdb.Pdb`` (ipdb, pdbpp). Explicit ``stdin``/``stdout``
arguments are honored and not overridden. Debuggers that
do not subclass ``pdb.Pdb`` (pudb, debugpy) are not
covered.
"""
if stdin is None:
stdin = sys.__stdin__
if stdout is None:
stdout = sys.__stdout__
old_pdb_init(
self, completekey=completekey, stdin=stdin, stdout=stdout, **kwargs
)
pdb.Pdb.__init__ = _patched_pdb_init # type: ignore[assignment]
old_env = {}
try:
for key, value in env.items():
@ -429,6 +555,7 @@ class CliRunner:
utils.should_strip_ansi = old_should_strip_ansi # type: ignore
_compat.should_strip_ansi = old__compat_should_strip_ansi
formatting.FORCED_WIDTH = old_forced_width
pdb.Pdb.__init__ = old_pdb_init # type: ignore[method-assign]
def invoke(
self,
@ -487,7 +614,27 @@ class CliRunner:
if catch_exceptions is None:
catch_exceptions = self.catch_exceptions
# Set up fd capture before isolation replaces sys.stdout and sys.stderr.
cap_out: _FDCapture | None = None
cap_err: _FDCapture | None = None
if self.capture == "fd":
cap_out = _FDCapture(1)
cap_err = _FDCapture(2)
try:
cap_out.start()
cap_err.start()
except OSError:
cap_out = cap_err = None
with self.isolation(input=input, env=env, color=color) as outstreams:
# Point the captured streams' fileno() at the saved (original)
# fd so that C-level consumers like faulthandler keep working
# while fd 1/2 are redirected to the capture tmpfile.
if cap_out is not None and cap_err is not None:
sys.stdout._original_fd = cap_out.saved_fd # type: ignore[union-attr]
sys.stderr._original_fd = cap_err.saved_fd # type: ignore[union-attr]
return_value = None
exception: BaseException | None = None
exit_code = 0
@ -528,6 +675,18 @@ class CliRunner:
finally:
sys.stdout.flush()
sys.stderr.flush()
# Stop fd capture and merge the captured bytes into
# the stdout/stderr BytesIO streams. BytesIOCopy mirrors
# those writes into outstreams[2] automatically.
if cap_out is not None and cap_err is not None:
fd_out = cap_out.stop()
fd_err = cap_err.stop()
if fd_out:
outstreams[0].write(fd_out)
if fd_err:
outstreams[1].write(fd_err)
stdout = outstreams[0].getvalue()
stderr = outstreams[1].getvalue()
output = outstreams[2].getvalue()

View file

@ -1,11 +1,13 @@
from __future__ import annotations
import abc
import collections.abc as cabc
import enum
import os
import stat
import sys
import typing as t
import uuid
from datetime import datetime
from gettext import gettext as _
from gettext import ngettext
@ -27,7 +29,12 @@ if t.TYPE_CHECKING:
ParamTypeValue = t.TypeVar("ParamTypeValue")
class ParamType:
class ParamTypeInfoDict(t.TypedDict):
param_type: str
name: str
class ParamType(t.Generic[ParamTypeValue], abc.ABC):
"""Represents the type of a parameter. Validates and converts values
from the command line or Python into the correct type.
@ -43,6 +50,12 @@ class ParamType:
- It must be able to convert a value if the ``ctx`` and ``param``
arguments are ``None``. This can occur when converting prompt
input.
.. versionchanged:: 8.4.0
Now a generic abstract base class. Parameterize with the
converted value type (``ParamType[int]`` for an integer-returning
type) so that :meth:`convert` and downstream consumers carry the
narrowed return type.
"""
is_composite: t.ClassVar[bool] = False
@ -59,7 +72,7 @@ class ParamType:
#: Windows).
envvar_list_splitter: t.ClassVar[str | None] = None
def to_info_dict(self) -> dict[str, t.Any]:
def to_info_dict(self) -> ParamTypeInfoDict:
"""Gather information that could be useful for a tool generating
user-facing documentation.
@ -85,9 +98,10 @@ class ParamType:
value: t.Any,
param: Parameter | None = None,
ctx: Context | None = None,
) -> t.Any:
) -> ParamTypeValue | None:
if value is not None:
return self.convert(value, param, ctx)
return None
def get_metavar(self, param: Parameter, ctx: Context) -> str | None:
"""Returns the metavar default for this param if it provides one."""
@ -101,7 +115,7 @@ class ParamType:
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> ParamTypeValue:
"""Convert the value to the correct type. This is not called if
the value is ``None`` (the missing value).
@ -121,7 +135,9 @@ class ParamType:
:param ctx: The current context that arrived at this value. May
be ``None``.
"""
return value
# The default returns the value as-is so subclasses that only customize
# metadata are not forced to redeclare ``convert``.
return t.cast("ParamTypeValue", value)
def split_envvar_value(self, rv: str) -> cabc.Sequence[str]:
"""Given a value from an environment variable this splits it up
@ -160,39 +176,44 @@ class ParamType:
return []
class CompositeParamType(ParamType):
class CompositeParamType(ParamType[ParamTypeValue]):
is_composite = True
@property
def arity(self) -> int: # type: ignore
raise NotImplementedError()
@abc.abstractmethod
def arity(self) -> int: ... # type: ignore[override]
class FuncParamType(ParamType):
def __init__(self, func: t.Callable[[t.Any], t.Any]) -> None:
class FuncParamTypeInfoDict(ParamTypeInfoDict):
func: t.Callable[[t.Any], t.Any]
class FuncParamType(ParamType[ParamTypeValue]):
def __init__(self, func: t.Callable[[t.Any], ParamTypeValue]) -> None:
self.name: str = func.__name__
self.func = func
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict["func"] = self.func
return info_dict
def to_info_dict(self) -> FuncParamTypeInfoDict:
return {"func": self.func, **super().to_info_dict()}
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> ParamTypeValue:
try:
return self.func(value)
except ValueError:
try:
value = str(value)
except UnicodeError:
value = value.decode("utf-8", "replace")
except ValueError as exc:
message = str(exc)
self.fail(value, param, ctx)
if not message:
try:
message = str(value)
except UnicodeError:
message = value.decode("utf-8", "replace")
self.fail(message, param, ctx)
class UnprocessedParamType(ParamType):
class UnprocessedParamType(ParamType[t.Any]):
name = "text"
def convert(
@ -204,12 +225,12 @@ class UnprocessedParamType(ParamType):
return "UNPROCESSED"
class StringParamType(ParamType):
class StringParamType(ParamType[str]):
name = "text"
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> str:
if isinstance(value, bytes):
enc = _get_argv_encoding()
try:
@ -223,14 +244,19 @@ class StringParamType(ParamType):
value = value.decode("utf-8", "replace")
else:
value = value.decode("utf-8", "replace")
return value
return value # type: ignore[no-any-return]
return str(value)
def __repr__(self) -> str:
return "STRING"
class Choice(ParamType, t.Generic[ParamTypeValue]):
class ChoiceInfoDict(ParamTypeInfoDict):
choices: cabc.Sequence[t.Any]
case_sensitive: bool
class Choice(ParamType[ParamTypeValue], t.Generic[ParamTypeValue]):
"""The choice type allows a value to be checked against a fixed set
of supported values.
@ -244,6 +270,11 @@ class Choice(ParamType, t.Generic[ParamTypeValue]):
:param case_sensitive: Set to false to make choices case
insensitive. Defaults to true.
.. versionchanged:: 8.4.0
Now generic in the choice value type. Parameterize with the type of
the choice values (``Choice[HashType]`` for an enum, ``Choice[str]``
for plain strings) to enable type-checked consumers.
.. versionchanged:: 8.2.0
Non-``str`` ``choices`` are now supported. It can additionally be any
iterable. Before you were not recommended to pass anything but a list or
@ -261,11 +292,12 @@ class Choice(ParamType, t.Generic[ParamTypeValue]):
self.choices: cabc.Sequence[ParamTypeValue] = tuple(choices)
self.case_sensitive = case_sensitive
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict["choices"] = self.choices
info_dict["case_sensitive"] = self.case_sensitive
return info_dict
def to_info_dict(self) -> ChoiceInfoDict:
return {
"choices": self.choices,
"case_sensitive": self.case_sensitive,
**super().to_info_dict(),
}
def _normalized_mapping(
self, ctx: Context | None = None
@ -372,7 +404,7 @@ class Choice(ParamType, t.Generic[ParamTypeValue]):
).format(value=value, choice=choices_str, choices=choices_str)
def __repr__(self) -> str:
return f"Choice({list(self.choices)})"
return _("Choice({choices})").format(choices=list(self.choices))
def shell_complete(
self, ctx: Context, param: Parameter, incomplete: str
@ -387,8 +419,7 @@ class Choice(ParamType, t.Generic[ParamTypeValue]):
"""
from click.shell_completion import CompletionItem
str_choices = map(str, self.choices)
str_choices = [self.normalize_choice(choice, ctx) for choice in self.choices]
if self.case_sensitive:
matched = (c for c in str_choices if c.startswith(incomplete))
else:
@ -398,7 +429,11 @@ class Choice(ParamType, t.Generic[ParamTypeValue]):
return [CompletionItem(c) for c in matched]
class DateTime(ParamType):
class DateTimeInfoDict(ParamTypeInfoDict):
formats: cabc.Sequence[str]
class DateTime(ParamType[datetime]):
"""The DateTime type converts date strings into `datetime` objects.
The format strings which are checked are configurable, but default to some
@ -428,10 +463,8 @@ class DateTime(ParamType):
"%Y-%m-%d %H:%M:%S",
]
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict["formats"] = self.formats
return info_dict
def to_info_dict(self) -> DateTimeInfoDict:
return {"formats": self.formats, **super().to_info_dict()}
def get_metavar(self, param: Parameter, ctx: Context) -> str | None:
return f"[{'|'.join(self.formats)}]"
@ -444,7 +477,7 @@ class DateTime(ParamType):
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> datetime:
if isinstance(value, datetime):
return value
@ -469,12 +502,12 @@ class DateTime(ParamType):
return "DateTime"
class _NumberParamTypeBase(ParamType):
_number_class: t.ClassVar[type[t.Any]]
class _NumberParamTypeBase(ParamType[ParamTypeValue]):
_number_class: t.Callable[[t.Any], ParamTypeValue]
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> ParamTypeValue:
try:
return self._number_class(value)
except ValueError:
@ -487,7 +520,15 @@ class _NumberParamTypeBase(ParamType):
)
class _NumberRangeBase(_NumberParamTypeBase):
class NumberRangeInfoDict(ParamTypeInfoDict):
min: float | None
max: float | None
min_open: bool
max_open: bool
clamp: bool
class _NumberRangeBase(_NumberParamTypeBase[ParamTypeValue]):
def __init__(
self,
min: float | None = None,
@ -502,36 +543,37 @@ class _NumberRangeBase(_NumberParamTypeBase):
self.max_open = max_open
self.clamp = clamp
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict.update(
min=self.min,
max=self.max,
min_open=self.min_open,
max_open=self.max_open,
clamp=self.clamp,
)
return info_dict
def to_info_dict(self) -> NumberRangeInfoDict:
return {
"min": self.min,
"max": self.max,
"min_open": self.min_open,
"max_open": self.max_open,
"clamp": self.clamp,
**super().to_info_dict(),
}
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> ParamTypeValue:
import operator
rv = super().convert(value, param, ctx)
lt_min: bool = self.min is not None and (
min = self.min
max = self.max
lt_min: bool = min is not None and (
operator.le if self.min_open else operator.lt
)(rv, self.min)
gt_max: bool = self.max is not None and (
)(rv, min) # type: ignore[arg-type]
gt_max: bool = max is not None and (
operator.ge if self.max_open else operator.gt
)(rv, self.max)
)(rv, max) # type: ignore[arg-type]
if self.clamp:
if lt_min:
return self._clamp(self.min, 1, self.min_open) # type: ignore
if min is not None and lt_min:
return self._clamp(min, 1, self.min_open) # type: ignore[arg-type]
if gt_max:
return self._clamp(self.max, -1, self.max_open) # type: ignore
if max is not None and gt_max:
return self._clamp(max, -1, self.max_open) # type: ignore[arg-type]
if lt_min or gt_max:
self.fail(
@ -544,7 +586,10 @@ class _NumberRangeBase(_NumberParamTypeBase):
return rv
def _clamp(self, bound: float, dir: t.Literal[1, -1], open: bool) -> float:
@abc.abstractmethod
def _clamp(
self, bound: ParamTypeValue, dir: t.Literal[1, -1], open: bool
) -> ParamTypeValue:
"""Find the valid value to clamp to bound in the given
direction.
@ -552,7 +597,7 @@ class _NumberRangeBase(_NumberParamTypeBase):
:param dir: 1 or -1 indicating the direction to move.
:param open: If true, the range does not include the bound.
"""
raise NotImplementedError
...
def _describe_range(self) -> str:
"""Describe the range for use in help text."""
@ -573,7 +618,7 @@ class _NumberRangeBase(_NumberParamTypeBase):
return f"<{type(self).__name__} {self._describe_range()}{clamp}>"
class IntParamType(_NumberParamTypeBase):
class IntParamType(_NumberParamTypeBase[int]):
name = "integer"
_number_class = int
@ -581,7 +626,7 @@ class IntParamType(_NumberParamTypeBase):
return "INT"
class IntRange(_NumberRangeBase, IntParamType):
class IntRange(_NumberRangeBase[int], IntParamType):
"""Restrict an :data:`click.INT` value to a range of accepted
values. See :ref:`ranges`.
@ -598,16 +643,14 @@ class IntRange(_NumberRangeBase, IntParamType):
name = "integer range"
def _clamp( # type: ignore
self, bound: int, dir: t.Literal[1, -1], open: bool
) -> int:
def _clamp(self, bound: int, dir: t.Literal[1, -1], open: bool) -> int:
if not open:
return bound
return bound + dir
class FloatParamType(_NumberParamTypeBase):
class FloatParamType(_NumberParamTypeBase[float]):
name = "float"
_number_class = float
@ -615,7 +658,7 @@ class FloatParamType(_NumberParamTypeBase):
return "FLOAT"
class FloatRange(_NumberRangeBase, FloatParamType):
class FloatRange(_NumberRangeBase[float], FloatParamType):
"""Restrict a :data:`click.FLOAT` value to a range of accepted
values. See :ref:`ranges`.
@ -658,7 +701,7 @@ class FloatRange(_NumberRangeBase, FloatParamType):
raise RuntimeError("Clamping is not supported for open bounds.")
class BoolParamType(ParamType):
class BoolParamType(ParamType[bool]):
name = "boolean"
bool_states: dict[str, bool] = {
@ -727,14 +770,12 @@ class BoolParamType(ParamType):
return "BOOL"
class UUIDParameterType(ParamType):
class UUIDParameterType(ParamType[uuid.UUID]):
name = "uuid"
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
import uuid
) -> uuid.UUID:
if isinstance(value, uuid.UUID):
return value
@ -751,7 +792,12 @@ class UUIDParameterType(ParamType):
return "UUID"
class File(ParamType):
class FileInfoDict(ParamTypeInfoDict):
mode: str
encoding: str | None
class File(ParamType[t.IO[t.Any]]):
"""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).
@ -798,10 +844,12 @@ class File(ParamType):
self.lazy = lazy
self.atomic = atomic
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict.update(mode=self.mode, encoding=self.encoding)
return info_dict
def to_info_dict(self) -> FileInfoDict:
return {
"mode": self.mode,
"encoding": self.encoding,
**super().to_info_dict(),
}
def resolve_lazy_flag(self, value: str | os.PathLike[str]) -> bool:
if self.lazy is not None:
@ -853,7 +901,11 @@ class File(ParamType):
return f
except OSError as e:
self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx)
self.fail(
f"'{format_filename(value)}': {e.strerror}",
param,
ctx,
)
def shell_complete(
self, ctx: Context, param: Parameter, incomplete: str
@ -876,7 +928,16 @@ def _is_file_like(value: t.Any) -> te.TypeGuard[t.IO[t.Any]]:
return hasattr(value, "read") or hasattr(value, "write")
class Path(ParamType):
class PathInfoDict(ParamTypeInfoDict):
exists: bool
file_okay: bool
dir_okay: bool
writable: bool
readable: bool
allow_dash: bool
class Path(ParamType[str | bytes | os.PathLike[str]]):
"""The ``Path`` type is similar to the :class:`File` type, but
returns the filename instead of an open file. Various checks can be
enabled to validate the type of file and permissions.
@ -940,17 +1001,16 @@ class Path(ParamType):
else:
self.name = _("path")
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict.update(
exists=self.exists,
file_okay=self.file_okay,
dir_okay=self.dir_okay,
writable=self.writable,
readable=self.readable,
allow_dash=self.allow_dash,
)
return info_dict
def to_info_dict(self) -> PathInfoDict:
return {
"exists": self.exists,
"file_okay": self.file_okay,
"dir_okay": self.dir_okay,
"writable": self.writable,
"readable": self.readable,
"allow_dash": self.allow_dash,
**super().to_info_dict(),
}
def coerce_path_result(
self, value: str | os.PathLike[str]
@ -1057,7 +1117,11 @@ class Path(ParamType):
return [CompletionItem(incomplete, type=type)]
class Tuple(CompositeParamType):
class TupleInfoDict(ParamTypeInfoDict):
types: cabc.Sequence[ParamTypeInfoDict]
class Tuple(CompositeParamType[tuple[t.Any, ...]]):
"""The default behavior of Click is to apply a type on a value directly.
This works well in most cases, except for when `nargs` is set to a fixed
count and different types should be used for different items. In this
@ -1071,25 +1135,26 @@ class Tuple(CompositeParamType):
:param types: a list of types that should be used for the tuple items.
"""
def __init__(self, types: cabc.Sequence[type[t.Any] | ParamType]) -> None:
self.types: cabc.Sequence[ParamType] = [convert_type(ty) for ty in types]
def __init__(self, types: cabc.Sequence[type[t.Any] | ParamType[t.Any]]) -> None:
self.types: cabc.Sequence[ParamType[t.Any]] = [convert_type(ty) for ty in types]
def to_info_dict(self) -> dict[str, t.Any]:
info_dict = super().to_info_dict()
info_dict["types"] = [t.to_info_dict() for t in self.types]
return info_dict
def to_info_dict(self) -> TupleInfoDict:
return {
"types": [ty.to_info_dict() for ty in self.types],
**super().to_info_dict(),
}
@property
def name(self) -> str: # type: ignore
def name(self) -> str: # type: ignore[override]
return f"<{' '.join(ty.name for ty in self.types)}>"
@property
def arity(self) -> int: # type: ignore
def arity(self) -> int: # type: ignore[override]
return len(self.types)
def convert(
self, value: t.Any, param: Parameter | None, ctx: Context | None
) -> t.Any:
) -> tuple[t.Any, ...]:
len_type = len(self.types)
len_value = len(value)
@ -1109,64 +1174,99 @@ class Tuple(CompositeParamType):
)
def convert_type(ty: t.Any | None, default: t.Any | None = None) -> ParamType:
def _guess_type(
ty: type[t.Any] | ParamType[t.Any] | None,
default: t.Any | None,
) -> type[t.Any] | tuple[type[t.Any], ...] | ParamType[t.Any] | None:
"""Infer a type from *ty* or *default*.
Returns *ty* unchanged when it is not ``None``. Otherwise inspects
*default* to produce a ``type``, a ``tuple`` of types (for tuple
defaults), or ``None``.
"""
if ty is not None:
return ty
if default is None:
return None
if not isinstance(default, (tuple, list)):
return type(default)
# If the default is empty, return None so convert_type falls
# through to STRING.
if not default:
return None
item = default[0]
# A sequence of iterables needs to detect the inner types.
# Can't call convert_type recursively because that would
# incorrectly unwind the tuple to a single type.
if isinstance(item, (tuple, list)):
return tuple(map(type, item))
return type(item)
@t.overload
def convert_type(ty: None, default: None = None) -> StringParamType: ...
@t.overload
def convert_type(
ty: type[t.Any] | ParamType[t.Any], default: t.Any | None = None
) -> ParamType[t.Any]: ...
@t.overload
def convert_type(
ty: t.Any | None, default: t.Any | None = None
) -> ParamType[t.Any]: ...
def convert_type(
ty: t.Any | None = None, default: t.Any | None = None
) -> ParamType[t.Any]:
"""Find the most appropriate :class:`ParamType` for the given Python
type. If the type isn't provided, it can be inferred from a default
value.
"""
guessed_type = False
guessed = _guess_type(ty, default)
is_guessed = guessed is not ty
if ty is None and default is not None:
if isinstance(default, (tuple, list)):
# If the default is empty, ty will remain None and will
# return STRING.
if default:
item = default[0]
if isinstance(guessed, tuple):
return Tuple(guessed)
# A tuple of tuples needs to detect the inner types.
# Can't call convert recursively because that would
# incorrectly unwind the tuple to a single type.
if isinstance(item, (tuple, list)):
ty = tuple(map(type, item))
else:
ty = type(item)
else:
ty = type(default)
if isinstance(guessed, ParamType):
return guessed
guessed_type = True
if isinstance(ty, tuple):
return Tuple(ty)
if isinstance(ty, ParamType):
return ty
if ty is str or ty is None:
if guessed is str or guessed is None:
return STRING
if ty is int:
if guessed is int:
return INT
if ty is float:
if guessed is float:
return FLOAT
if ty is bool:
if guessed is bool:
return BOOL
if guessed_type:
if is_guessed:
return STRING
if __debug__:
try:
if issubclass(ty, ParamType):
if issubclass(guessed, ParamType):
raise AssertionError(
f"Attempted to use an uninstantiated parameter type ({ty})."
f"Attempted to use an uninstantiated parameter type ({guessed})."
)
except TypeError:
# ty is an instance (correct), so issubclass fails.
# guessed is an instance (correct), so issubclass fails.
pass
return FuncParamType(ty)
return FuncParamType(guessed)
#: A dummy parameter type that just does nothing. From a user's
@ -1177,7 +1277,7 @@ def convert_type(ty: t.Any | None, default: t.Any | None = None) -> ParamType:
#:
#: For path related uses the :class:`Path` type is a better choice but
#: there are situations where an unprocessed type is useful which is why
#: it is is provided.
#: it is provided.
#:
#: .. versionadded:: 4.0
UNPROCESSED = UnprocessedParamType()

View file

@ -6,6 +6,7 @@ import re
import sys
import typing as t
from functools import update_wrapper
from gettext import gettext as _
from types import ModuleType
from types import TracebackType
@ -57,7 +58,10 @@ def make_str(value: t.Any) -> str:
def make_default_short_help(help: str, max_length: int = 45) -> str:
"""Returns a condensed version of help string."""
"""Returns a condensed version of help string.
:meta private:
"""
# Consider only the first paragraph.
paragraph_end = help.find("\n\n")
@ -113,6 +117,14 @@ class LazyFile:
files for writing.
"""
name: str
mode: str
encoding: str | None
errors: str | None
atomic: bool
_f: t.IO[t.Any] | None
should_close: bool
def __init__(
self,
filename: str | os.PathLike[str],
@ -120,14 +132,12 @@ class LazyFile:
encoding: str | None = None,
errors: str | None = "strict",
atomic: bool = False,
):
self.name: str = os.fspath(filename)
) -> None:
self.name = os.fspath(filename)
self.mode = mode
self.encoding = encoding
self.errors = errors
self.atomic = atomic
self._f: t.IO[t.Any] | None
self.should_close: bool
if self.name == "-":
self._f, self.should_close = open_stream(filename, mode, encoding, errors)
@ -195,8 +205,10 @@ class LazyFile:
class KeepOpenFile:
_file: t.IO[t.Any]
def __init__(self, file: t.IO[t.Any]) -> None:
self._file: t.IO[t.Any] = file
self._file = file
def __getattr__(self, name: str) -> t.Any:
return getattr(self._file, name)
@ -220,7 +232,7 @@ class KeepOpenFile:
def echo(
message: t.Any | None = None,
message: object = None,
file: t.IO[t.Any] | None = None,
nl: bool = True,
err: bool = False,
@ -275,14 +287,15 @@ def echo(
if file is None:
return
# Convert non bytes/text into the native string type.
if message is not None and not isinstance(message, (str, bytes, bytearray)):
out: str | bytes | bytearray | None = str(message)
else:
out = message
match message:
case str() | bytes() | bytearray():
out = message
case None:
out = ""
case _:
out = str(message)
if nl:
out = out or ""
if isinstance(out, str):
out += "\n"
else:
@ -298,7 +311,6 @@ def echo(
# would expect. Eg: you can write to StringIO for other cases.
if isinstance(out, (bytes, bytearray)):
binary_file = _find_binary_writer(file)
if binary_file is not None:
file.flush()
binary_file.write(out)
@ -330,7 +342,7 @@ def get_binary_stream(name: t.Literal["stdin", "stdout", "stderr"]) -> t.BinaryI
"""
opener = binary_streams.get(name)
if opener is None:
raise TypeError(f"Unknown standard stream '{name}'")
raise TypeError(_("Unknown standard stream '{name}'").format(name=name))
return opener()
@ -351,7 +363,7 @@ def get_text_stream(
"""
opener = text_streams.get(name)
if opener is None:
raise TypeError(f"Unknown standard stream '{name}'")
raise TypeError(_("Unknown standard stream '{name}'").format(name=name))
return opener(encoding, errors)
@ -504,6 +516,8 @@ class PacifyFlushWrapper:
pipe, all calls and attributes are proxied.
"""
wrapped: t.IO[t.Any]
def __init__(self, wrapped: t.IO[t.Any]) -> None:
self.wrapped = wrapped