Voice et bot modif
This commit is contained in:
parent
189d56026b
commit
7333a22bcd
10774 changed files with 634644 additions and 933308 deletions
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue