python-click/src/click/_termui_impl.py

719 lines
23 KiB
Python
Raw Normal View History

2014-10-16 20:40:34 +02:00
"""
2019-01-07 17:51:19 +01:00
This module contains implementations for the termui module. To keep the
import time of Click down, some infrequently used functionality is
placed in this module and only imported as needed.
2014-10-16 20:40:34 +02:00
"""
2020-07-21 08:23:42 +02:00
import contextlib
import math
2014-10-16 20:40:34 +02:00
import os
import sys
import time
2021-10-10 03:31:57 +02:00
import typing as t
from gettext import gettext as _
2014-10-16 20:40:34 +02:00
2020-07-21 08:23:42 +02:00
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
from .exceptions import ClickException
from .utils import echo
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
V = t.TypeVar("V")
2020-07-21 08:23:42 +02:00
if os.name == "nt":
BEFORE_BAR = "\r"
AFTER_BAR = "\n"
2014-10-16 20:40:34 +02:00
else:
2020-07-21 08:23:42 +02:00
BEFORE_BAR = "\r\033[?25l"
AFTER_BAR = "\033[?25h\n"
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
class ProgressBar(t.Generic[V]):
2020-07-21 08:23:42 +02:00
def __init__(
self,
2021-10-10 03:31:57 +02:00
iterable: t.Optional[t.Iterable[V]],
length: t.Optional[int] = None,
fill_char: str = "#",
empty_char: str = " ",
bar_template: str = "%(bar)s",
info_sep: str = " ",
show_eta: bool = True,
show_percent: t.Optional[bool] = None,
show_pos: bool = False,
item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None,
label: t.Optional[str] = None,
file: t.Optional[t.TextIO] = None,
color: t.Optional[bool] = None,
update_min_steps: int = 1,
width: int = 30,
) -> None:
2014-10-16 20:40:34 +02:00
self.fill_char = fill_char
self.empty_char = empty_char
self.bar_template = bar_template
self.info_sep = info_sep
self.show_eta = show_eta
self.show_percent = show_percent
self.show_pos = show_pos
self.item_show_func = item_show_func
2020-07-21 08:23:42 +02:00
self.label = label or ""
2014-10-16 20:40:34 +02:00
if file is None:
file = _default_text_stdout()
self.file = file
2015-07-16 14:26:14 +02:00
self.color = color
2021-10-10 03:31:57 +02:00
self.update_min_steps = update_min_steps
self._completed_intervals = 0
2014-10-16 20:40:34 +02:00
self.width = width
self.autowidth = width == 0
if length is None:
2021-10-10 03:31:57 +02:00
from operator import length_hint
length = length_hint(iterable, -1)
if length == -1:
length = None
2014-10-16 20:40:34 +02:00
if iterable is None:
if length is None:
2020-07-21 08:23:42 +02:00
raise TypeError("iterable or length is required")
2021-10-10 03:31:57 +02:00
iterable = t.cast(t.Iterable[V], range(length))
2014-10-16 20:40:34 +02:00
self.iter = iter(iterable)
self.length = length
self.pos = 0
2021-10-10 03:31:57 +02:00
self.avg: t.List[float] = []
2014-10-16 20:40:34 +02:00
self.start = self.last_eta = time.time()
self.eta_known = False
self.finished = False
2021-10-10 03:31:57 +02:00
self.max_width: t.Optional[int] = None
2014-10-16 20:40:34 +02:00
self.entered = False
2021-10-10 03:31:57 +02:00
self.current_item: t.Optional[V] = None
2014-10-16 20:40:34 +02:00
self.is_hidden = not isatty(self.file)
2021-10-10 03:31:57 +02:00
self._last_line: t.Optional[str] = None
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def __enter__(self) -> "ProgressBar":
2014-10-16 20:40:34 +02:00
self.entered = True
self.render_progress()
return self
2021-10-10 03:31:57 +02:00
def __exit__(self, exc_type, exc_value, tb): # type: ignore
2014-10-16 20:40:34 +02:00
self.render_finish()
2021-10-10 03:31:57 +02:00
def __iter__(self) -> t.Iterator[V]:
2014-10-16 20:40:34 +02:00
if not self.entered:
2020-07-21 08:23:42 +02:00
raise RuntimeError("You need to use progress bars in a with block.")
2014-10-16 20:40:34 +02:00
self.render_progress()
2018-09-06 20:55:10 +02:00
return self.generator()
2021-10-10 03:31:57 +02:00
def __next__(self) -> V:
2020-07-21 08:23:42 +02:00
# Iteration is defined in terms of a generator function,
# returned by iter(self); use that to define next(). This works
# because `self.iter` is an iterable consumed by that generator,
# so it is re-entry safe. Calling `next(self.generator())`
# twice works and does "what you want".
return next(iter(self))
2021-10-10 03:31:57 +02:00
def render_finish(self) -> None:
if self.is_hidden:
2014-10-16 20:40:34 +02:00
return
self.file.write(AFTER_BAR)
self.file.flush()
@property
2021-10-10 03:31:57 +02:00
def pct(self) -> float:
2014-10-16 20:40:34 +02:00
if self.finished:
return 1.0
2021-10-10 03:31:57 +02:00
return min(self.pos / (float(self.length or 1) or 1), 1.0)
2014-10-16 20:40:34 +02:00
@property
2021-10-10 03:31:57 +02:00
def time_per_iteration(self) -> float:
2014-10-16 20:40:34 +02:00
if not self.avg:
return 0.0
return sum(self.avg) / float(len(self.avg))
@property
2021-10-10 03:31:57 +02:00
def eta(self) -> float:
if self.length is not None and not self.finished:
2014-10-16 20:40:34 +02:00
return self.time_per_iteration * (self.length - self.pos)
return 0.0
2021-10-10 03:31:57 +02:00
def format_eta(self) -> str:
2014-10-16 20:40:34 +02:00
if self.eta_known:
2018-09-06 20:55:10 +02:00
t = int(self.eta)
2015-12-04 16:51:02 +01:00
seconds = t % 60
2018-09-06 20:55:10 +02:00
t //= 60
2015-12-04 16:51:02 +01:00
minutes = t % 60
2018-09-06 20:55:10 +02:00
t //= 60
2015-12-04 16:51:02 +01:00
hours = t % 24
2018-09-06 20:55:10 +02:00
t //= 24
2015-12-04 16:51:02 +01:00
if t > 0:
2021-10-10 03:31:57 +02:00
return f"{t}d {hours:02}:{minutes:02}:{seconds:02}"
2015-12-04 16:51:02 +01:00
else:
2021-10-10 03:31:57 +02:00
return f"{hours:02}:{minutes:02}:{seconds:02}"
2020-07-21 08:23:42 +02:00
return ""
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def format_pos(self) -> str:
2014-10-16 20:40:34 +02:00
pos = str(self.pos)
2021-10-10 03:31:57 +02:00
if self.length is not None:
pos += f"/{self.length}"
2014-10-16 20:40:34 +02:00
return pos
2021-10-10 03:31:57 +02:00
def format_pct(self) -> str:
return f"{int(self.pct * 100): 4}%"[1:]
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def format_bar(self) -> str:
if self.length is not None:
2014-10-16 20:40:34 +02:00
bar_length = int(self.pct * self.width)
bar = self.fill_char * bar_length
bar += self.empty_char * (self.width - bar_length)
2018-09-06 20:55:10 +02:00
elif self.finished:
bar = self.fill_char * self.width
2014-10-16 20:40:34 +02:00
else:
2021-10-10 03:31:57 +02:00
chars = list(self.empty_char * (self.width or 1))
2018-09-06 20:55:10 +02:00
if self.time_per_iteration != 0:
2021-10-10 03:31:57 +02:00
chars[
2020-07-21 08:23:42 +02:00
int(
(math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5)
* self.width
)
] = self.fill_char
2021-10-10 03:31:57 +02:00
bar = "".join(chars)
2018-09-06 20:55:10 +02:00
return bar
2021-10-10 03:31:57 +02:00
def format_progress_line(self) -> str:
2018-09-06 20:55:10 +02:00
show_percent = self.show_percent
info_bits = []
2021-10-10 03:31:57 +02:00
if self.length is not None and show_percent is None:
2018-09-06 20:55:10 +02:00
show_percent = not self.show_pos
2014-10-16 20:40:34 +02:00
if self.show_pos:
info_bits.append(self.format_pos())
if show_percent:
info_bits.append(self.format_pct())
if self.show_eta and self.eta_known and not self.finished:
info_bits.append(self.format_eta())
if self.item_show_func is not None:
item_info = self.item_show_func(self.current_item)
if item_info is not None:
info_bits.append(item_info)
2020-07-21 08:23:42 +02:00
return (
self.bar_template
% {
"label": self.label,
"bar": self.format_bar(),
"info": self.info_sep.join(info_bits),
}
).rstrip()
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def render_progress(self) -> None:
import shutil
2014-10-16 20:40:34 +02:00
if self.is_hidden:
2021-10-10 03:31:57 +02:00
# Only output the label as it changes if the output is not a
# TTY. Use file=stderr if you expect to be piping stdout.
if self._last_line != self.label:
self._last_line = self.label
echo(self.label, file=self.file, color=self.color)
2018-09-06 20:55:10 +02:00
return
2015-12-04 16:51:02 +01:00
2018-09-06 20:55:10 +02:00
buf = []
# Update width in case the terminal has been resized
if self.autowidth:
old_width = self.width
self.width = 0
clutter_length = term_len(self.format_progress_line())
2021-10-10 03:31:57 +02:00
new_width = max(0, shutil.get_terminal_size().columns - clutter_length)
2018-09-06 20:55:10 +02:00
if new_width < old_width:
buf.append(BEFORE_BAR)
2021-10-10 03:31:57 +02:00
buf.append(" " * self.max_width) # type: ignore
2018-09-06 20:55:10 +02:00
self.max_width = new_width
self.width = new_width
clear_width = self.width
if self.max_width is not None:
clear_width = self.max_width
buf.append(BEFORE_BAR)
line = self.format_progress_line()
line_len = term_len(line)
if self.max_width is None or self.max_width < line_len:
self.max_width = line_len
buf.append(line)
2020-07-21 08:23:42 +02:00
buf.append(" " * (clear_width - line_len))
line = "".join(buf)
2015-12-04 16:51:02 +01:00
# Render the line only if it changed.
2018-09-06 20:55:10 +02:00
2021-10-10 03:31:57 +02:00
if line != self._last_line:
2015-12-04 16:51:02 +01:00
self._last_line = line
2018-09-06 20:55:10 +02:00
echo(line, file=self.file, color=self.color, nl=False)
2014-10-16 20:40:34 +02:00
self.file.flush()
2021-10-10 03:31:57 +02:00
def make_step(self, n_steps: int) -> None:
2015-07-16 14:26:14 +02:00
self.pos += n_steps
2021-10-10 03:31:57 +02:00
if self.length is not None and self.pos >= self.length:
2014-10-16 20:40:34 +02:00
self.finished = True
if (time.time() - self.last_eta) < 1.0:
return
self.last_eta = time.time()
2018-09-06 20:55:10 +02:00
# self.avg is a rolling list of length <= 7 of steps where steps are
# defined as time elapsed divided by the total progress through
# self.length.
if self.pos:
step = (time.time() - self.start) / self.pos
else:
step = time.time() - self.start
self.avg = self.avg[-6:] + [step]
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
self.eta_known = self.length is not None
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def update(self, n_steps: int, current_item: t.Optional[V] = None) -> None:
"""Update the progress bar by advancing a specified number of
steps, and optionally set the ``current_item`` for this new
position.
:param n_steps: Number of steps to advance.
:param current_item: Optional item to set as ``current_item``
for the updated position.
.. versionchanged:: 8.0
Added the ``current_item`` optional parameter.
2015-07-16 14:26:14 +02:00
2021-10-10 03:31:57 +02:00
.. versionchanged:: 8.0
Only render when the number of steps meets the
``update_min_steps`` threshold.
"""
if current_item is not None:
self.current_item = current_item
self._completed_intervals += n_steps
if self._completed_intervals >= self.update_min_steps:
self.make_step(self._completed_intervals)
self.render_progress()
self._completed_intervals = 0
def finish(self) -> None:
self.eta_known = False
2014-10-16 20:40:34 +02:00
self.current_item = None
self.finished = True
2021-10-10 03:31:57 +02:00
def generator(self) -> t.Iterator[V]:
2020-07-21 08:23:42 +02:00
"""Return a generator which yields the items added to the bar
during construction, and updates the progress bar *after* the
yielded block returns.
2018-09-06 20:55:10 +02:00
"""
2020-07-21 08:23:42 +02:00
# WARNING: the iterator interface for `ProgressBar` relies on
# this and only works because this is a simple generator which
# doesn't create or manage additional state. If this function
# changes, the impact should be evaluated both against
# `iter(bar)` and `next(bar)`. `next()` in particular may call
# `self.generator()` repeatedly, and this must remain safe in
# order for that interface to work.
2018-09-06 20:55:10 +02:00
if not self.entered:
2020-07-21 08:23:42 +02:00
raise RuntimeError("You need to use progress bars in a with block.")
2018-09-06 20:55:10 +02:00
2014-10-16 20:40:34 +02:00
if self.is_hidden:
2021-10-10 03:31:57 +02:00
yield from self.iter
2018-09-06 20:55:10 +02:00
else:
for rv in self.iter:
self.current_item = rv
2021-10-10 03:31:57 +02:00
# This allows show_item_func to be updated before the
# item is processed. Only trigger at the beginning of
# the update interval.
if self._completed_intervals == 0:
self.render_progress()
2018-09-06 20:55:10 +02:00
yield rv
self.update(1)
2021-10-10 03:31:57 +02:00
2014-10-16 20:40:34 +02:00
self.finish()
self.render_progress()
2021-10-10 03:31:57 +02:00
def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None:
2014-10-16 20:40:34 +02:00
"""Decide what method to use for paging through text."""
stdout = _default_text_stdout()
if not isatty(sys.stdin) or not isatty(stdout):
2018-09-06 20:55:10 +02:00
return _nullpager(stdout, generator, color)
2020-07-21 08:23:42 +02:00
pager_cmd = (os.environ.get("PAGER", None) or "").strip()
2015-08-23 03:10:31 +02:00
if pager_cmd:
2014-10-16 20:40:34 +02:00
if WIN:
2018-09-06 20:55:10 +02:00
return _tempfilepager(generator, pager_cmd, color)
return _pipepager(generator, pager_cmd, color)
2020-07-21 08:23:42 +02:00
if os.environ.get("TERM") in ("dumb", "emacs"):
2018-09-06 20:55:10 +02:00
return _nullpager(stdout, generator, color)
2020-07-21 08:23:42 +02:00
if WIN or sys.platform.startswith("os2"):
return _tempfilepager(generator, "more <", color)
if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
return _pipepager(generator, "less", color)
2014-10-16 20:40:34 +02:00
import tempfile
2020-07-21 08:23:42 +02:00
2014-10-16 20:40:34 +02:00
fd, filename = tempfile.mkstemp()
os.close(fd)
try:
2021-10-10 03:31:57 +02:00
if hasattr(os, "system") and os.system(f'more "{filename}"') == 0:
2020-07-21 08:23:42 +02:00
return _pipepager(generator, "more", color)
2018-09-06 20:55:10 +02:00
return _nullpager(stdout, generator, color)
2014-10-16 20:40:34 +02:00
finally:
os.unlink(filename)
2021-10-10 03:31:57 +02:00
def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> None:
2014-10-16 20:40:34 +02:00
"""Page through text by feeding it to another program. Invoking a
pager through this might support colors.
"""
import subprocess
2020-07-21 08:23:42 +02:00
2014-10-16 20:40:34 +02:00
env = dict(os.environ)
# If we're piping to less we might support colors under the
# condition that
2020-07-21 08:23:42 +02:00
cmd_detail = cmd.rsplit("/", 1)[-1].split()
if color is None and cmd_detail[0] == "less":
2021-10-10 03:31:57 +02:00
less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}"
2014-10-16 20:40:34 +02:00
if not less_flags:
2020-07-21 08:23:42 +02:00
env["LESS"] = "-R"
2014-10-16 20:40:34 +02:00
color = True
2020-07-21 08:23:42 +02:00
elif "r" in less_flags or "R" in less_flags:
2014-10-16 20:40:34 +02:00
color = True
2020-07-21 08:23:42 +02:00
c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
2021-10-10 03:31:57 +02:00
stdin = t.cast(t.BinaryIO, c.stdin)
encoding = get_best_encoding(stdin)
2014-10-16 20:40:34 +02:00
try:
2018-09-06 20:55:10 +02:00
for text in generator:
if not color:
text = strip_ansi(text)
2021-10-10 03:31:57 +02:00
stdin.write(text.encode(encoding, "replace"))
except (OSError, KeyboardInterrupt):
2014-10-16 20:40:34 +02:00
pass
2018-09-06 20:55:10 +02:00
else:
2021-10-10 03:31:57 +02:00
stdin.close()
2015-07-16 14:26:14 +02:00
# Less doesn't respect ^C, but catches it for its own UI purposes (aborting
# search or other commands inside less).
#
# That means when the user hits ^C, the parent process (click) terminates,
# but less is still alive, paging the output and messing up the terminal.
#
# If the user wants to make the pager exit on ^C, they should set
# `LESS='-K'`. It's not our decision to make.
while True:
try:
c.wait()
except KeyboardInterrupt:
pass
else:
break
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def _tempfilepager(
generator: t.Iterable[str], cmd: str, color: t.Optional[bool]
) -> None:
2014-10-16 20:40:34 +02:00
"""Page through text by invoking a program on a temporary file."""
import tempfile
2020-07-21 08:23:42 +02:00
2021-10-10 03:31:57 +02:00
fd, filename = tempfile.mkstemp()
2018-09-06 20:55:10 +02:00
# TODO: This never terminates if the passed generator never terminates.
text = "".join(generator)
2014-10-16 20:40:34 +02:00
if not color:
text = strip_ansi(text)
encoding = get_best_encoding(sys.stdout)
2020-07-21 08:23:42 +02:00
with open_stream(filename, "wb")[0] as f:
2014-10-16 20:40:34 +02:00
f.write(text.encode(encoding))
try:
2021-10-10 03:31:57 +02:00
os.system(f'{cmd} "{filename}"')
2014-10-16 20:40:34 +02:00
finally:
2021-10-10 03:31:57 +02:00
os.close(fd)
2014-10-16 20:40:34 +02:00
os.unlink(filename)
2021-10-10 03:31:57 +02:00
def _nullpager(
stream: t.TextIO, generator: t.Iterable[str], color: t.Optional[bool]
) -> None:
2014-10-16 20:40:34 +02:00
"""Simply print unformatted text. This is the ultimate fallback."""
2018-09-06 20:55:10 +02:00
for text in generator:
if not color:
text = strip_ansi(text)
stream.write(text)
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
class Editor:
def __init__(
self,
editor: t.Optional[str] = None,
env: t.Optional[t.Mapping[str, str]] = None,
require_save: bool = True,
extension: str = ".txt",
) -> None:
2014-10-16 20:40:34 +02:00
self.editor = editor
self.env = env
self.require_save = require_save
self.extension = extension
2021-10-10 03:31:57 +02:00
def get_editor(self) -> str:
2014-10-16 20:40:34 +02:00
if self.editor is not None:
return self.editor
2020-07-21 08:23:42 +02:00
for key in "VISUAL", "EDITOR":
2014-10-16 20:40:34 +02:00
rv = os.environ.get(key)
if rv:
return rv
if WIN:
2020-07-21 08:23:42 +02:00
return "notepad"
for editor in "sensible-editor", "vim", "nano":
2021-10-10 03:31:57 +02:00
if os.system(f"which {editor} >/dev/null 2>&1") == 0:
2014-10-16 20:40:34 +02:00
return editor
2020-07-21 08:23:42 +02:00
return "vi"
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def edit_file(self, filename: str) -> None:
2014-10-16 20:40:34 +02:00
import subprocess
2020-07-21 08:23:42 +02:00
2014-10-16 20:40:34 +02:00
editor = self.get_editor()
2021-10-10 03:31:57 +02:00
environ: t.Optional[t.Dict[str, str]] = None
2014-10-16 20:40:34 +02:00
if self.env:
environ = os.environ.copy()
environ.update(self.env)
2021-10-10 03:31:57 +02:00
2014-10-16 20:40:34 +02:00
try:
2021-10-10 03:31:57 +02:00
c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True)
2014-10-16 20:40:34 +02:00
exit_code = c.wait()
if exit_code != 0:
2021-10-10 03:31:57 +02:00
raise ClickException(
_("{editor}: Editing failed").format(editor=editor)
)
2014-10-16 20:40:34 +02:00
except OSError as e:
2021-10-10 03:31:57 +02:00
raise ClickException(
_("{editor}: Editing failed: {e}").format(editor=editor, e=e)
) from e
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]:
2014-10-16 20:40:34 +02:00
import tempfile
2021-10-10 03:31:57 +02:00
if not text:
data = b""
elif isinstance(text, (bytes, bytearray)):
data = text
else:
if text and not text.endswith("\n"):
text += "\n"
2014-10-16 20:40:34 +02:00
if WIN:
2021-10-10 03:31:57 +02:00
data = text.replace("\n", "\r\n").encode("utf-8-sig")
2014-10-16 20:40:34 +02:00
else:
2021-10-10 03:31:57 +02:00
data = text.encode("utf-8")
fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension)
f: t.BinaryIO
2014-10-16 20:40:34 +02:00
2021-10-10 03:31:57 +02:00
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
# If the filesystem resolution is 1 second, like Mac OS
# 10.12 Extended, or 2 seconds, like FAT32, and the editor
# closes very fast, require_save can fail. Set the modified
# time to be 2 seconds in the past to work around this.
os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2))
# Depending on the resolution, the exact value might not be
# recorded, so get the new recorded value.
2014-10-16 20:40:34 +02:00
timestamp = os.path.getmtime(name)
self.edit_file(name)
2020-07-21 08:23:42 +02:00
if self.require_save and os.path.getmtime(name) == timestamp:
2014-10-16 20:40:34 +02:00
return None
2021-10-10 03:31:57 +02:00
with open(name, "rb") as f:
2014-10-16 20:40:34 +02:00
rv = f.read()
2021-10-10 03:31:57 +02:00
if isinstance(text, (bytes, bytearray)):
return rv
return rv.decode("utf-8-sig").replace("\r\n", "\n") # type: ignore
2014-10-16 20:40:34 +02:00
finally:
os.unlink(name)
2021-10-10 03:31:57 +02:00
def open_url(url: str, wait: bool = False, locate: bool = False) -> int:
2014-10-16 20:40:34 +02:00
import subprocess
2021-10-10 03:31:57 +02:00
def _unquote_file(url: str) -> str:
from urllib.parse import unquote
2020-07-21 08:23:42 +02:00
if url.startswith("file://"):
2021-10-10 03:31:57 +02:00
url = unquote(url[7:])
2014-10-16 20:40:34 +02:00
return url
2020-07-21 08:23:42 +02:00
if sys.platform == "darwin":
args = ["open"]
2014-10-16 20:40:34 +02:00
if wait:
2020-07-21 08:23:42 +02:00
args.append("-W")
2014-10-16 20:40:34 +02:00
if locate:
2020-07-21 08:23:42 +02:00
args.append("-R")
2014-10-16 20:40:34 +02:00
args.append(_unquote_file(url))
2020-07-21 08:23:42 +02:00
null = open("/dev/null", "w")
2014-10-16 20:40:34 +02:00
try:
return subprocess.Popen(args, stderr=null).wait()
finally:
null.close()
elif WIN:
if locate:
2021-10-10 03:31:57 +02:00
url = _unquote_file(url.replace('"', ""))
args = f'explorer /select,"{url}"'
2014-10-16 20:40:34 +02:00
else:
2021-10-10 03:31:57 +02:00
url = url.replace('"', "")
wait_str = "/WAIT" if wait else ""
args = f'start {wait_str} "" "{url}"'
2014-10-16 20:40:34 +02:00
return os.system(args)
2018-09-06 20:55:10 +02:00
elif CYGWIN:
if locate:
2021-10-10 03:31:57 +02:00
url = os.path.dirname(_unquote_file(url).replace('"', ""))
args = f'cygstart "{url}"'
2018-09-06 20:55:10 +02:00
else:
2021-10-10 03:31:57 +02:00
url = url.replace('"', "")
wait_str = "-w" if wait else ""
args = f'cygstart {wait_str} "{url}"'
2018-09-06 20:55:10 +02:00
return os.system(args)
2014-10-16 20:40:34 +02:00
try:
if locate:
2020-07-21 08:23:42 +02:00
url = os.path.dirname(_unquote_file(url)) or "."
2014-10-16 20:40:34 +02:00
else:
url = _unquote_file(url)
2020-07-21 08:23:42 +02:00
c = subprocess.Popen(["xdg-open", url])
2014-10-16 20:40:34 +02:00
if wait:
return c.wait()
return 0
except OSError:
2020-07-21 08:23:42 +02:00
if url.startswith(("http://", "https://")) and not locate and not wait:
2014-10-16 20:40:34 +02:00
import webbrowser
2020-07-21 08:23:42 +02:00
2014-10-16 20:40:34 +02:00
webbrowser.open(url)
return 0
return 1
2021-10-10 03:31:57 +02:00
def _translate_ch_to_exc(ch: str) -> t.Optional[BaseException]:
if ch == "\x03":
2014-10-16 20:40:34 +02:00
raise KeyboardInterrupt()
2021-10-10 03:31:57 +02:00
if ch == "\x04" and not WIN: # Unix-like, Ctrl+D
2019-01-07 17:51:19 +01:00
raise EOFError()
2021-10-10 03:31:57 +02:00
if ch == "\x1a" and WIN: # Windows, Ctrl+Z
2014-10-16 20:40:34 +02:00
raise EOFError()
2021-10-10 03:31:57 +02:00
return None
2014-10-16 20:40:34 +02:00
if WIN:
import msvcrt
2018-09-06 20:55:10 +02:00
@contextlib.contextmanager
2021-10-10 03:31:57 +02:00
def raw_terminal() -> t.Iterator[int]:
yield -1
2018-09-06 20:55:10 +02:00
2021-10-10 03:31:57 +02:00
def getchar(echo: bool) -> str:
2019-01-07 17:51:19 +01:00
# The function `getch` will return a bytes object corresponding to
# the pressed character. Since Windows 10 build 1803, it will also
# return \x00 when called a second time after pressing a regular key.
#
# `getwch` does not share this probably-bugged behavior. Moreover, it
# returns a Unicode object by default, which is what we want.
#
# Either of these functions will return \x00 or \xe0 to indicate
# a special key, and you need to call the same function again to get
# the "rest" of the code. The fun part is that \u00e0 is
# "latin small letter a with grave", so if you type that on a French
# keyboard, you _also_ get a \xe0.
# E.g., consider the Up arrow. This returns \xe0 and then \x48. The
# resulting Unicode string reads as "a with grave" + "capital H".
# This is indistinguishable from when the user actually types
# "a with grave" and then "capital H".
#
# When \xe0 is returned, we assume it's part of a special-key sequence
# and call `getwch` again, but that means that when the user types
# the \u00e0 character, `getchar` doesn't return until a second
# character is typed.
# The alternative is returning immediately, but that would mess up
# cross-platform handling of arrow keys and others that start with
# \xe0. Another option is using `getch`, but then we can't reliably
# read non-ASCII characters, because return values of `getch` are
# limited to the current 8-bit codepage.
#
# Anyway, Click doesn't claim to do this Right(tm), and using `getwch`
# is doing the right thing in more situations than with `getch`.
2021-10-10 03:31:57 +02:00
func: t.Callable[[], str]
2014-10-16 20:40:34 +02:00
if echo:
2021-10-10 03:31:57 +02:00
func = msvcrt.getwche # type: ignore
2019-01-07 17:51:19 +01:00
else:
2021-10-10 03:31:57 +02:00
func = msvcrt.getwch # type: ignore
2019-01-07 17:51:19 +01:00
rv = func()
2021-10-10 03:31:57 +02:00
if rv in ("\x00", "\xe0"):
2019-01-07 17:51:19 +01:00
# \x00 and \xe0 are control characters that indicate special key,
# see above.
rv += func()
2021-10-10 03:31:57 +02:00
2014-10-16 20:40:34 +02:00
_translate_ch_to_exc(rv)
return rv
2020-07-21 08:23:42 +02:00
2014-10-16 20:40:34 +02:00
else:
import tty
import termios
2018-09-06 20:55:10 +02:00
@contextlib.contextmanager
2021-10-10 03:31:57 +02:00
def raw_terminal() -> t.Iterator[int]:
f: t.Optional[t.TextIO]
fd: int
2014-10-16 20:40:34 +02:00
if not isatty(sys.stdin):
2020-07-21 08:23:42 +02:00
f = open("/dev/tty")
2014-10-16 20:40:34 +02:00
fd = f.fileno()
else:
fd = sys.stdin.fileno()
f = None
2021-10-10 03:31:57 +02:00
2014-10-16 20:40:34 +02:00
try:
old_settings = termios.tcgetattr(fd)
2021-10-10 03:31:57 +02:00
2014-10-16 20:40:34 +02:00
try:
tty.setraw(fd)
2018-09-06 20:55:10 +02:00
yield fd
2014-10-16 20:40:34 +02:00
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
sys.stdout.flush()
2021-10-10 03:31:57 +02:00
2014-10-16 20:40:34 +02:00
if f is not None:
f.close()
except termios.error:
pass
2018-09-06 20:55:10 +02:00
2021-10-10 03:31:57 +02:00
def getchar(echo: bool) -> str:
2018-09-06 20:55:10 +02:00
with raw_terminal() as fd:
2021-10-10 03:31:57 +02:00
ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace")
2018-09-06 20:55:10 +02:00
if echo and isatty(sys.stdout):
sys.stdout.write(ch)
2021-10-10 03:31:57 +02:00
2018-09-06 20:55:10 +02:00
_translate_ch_to_exc(ch)
2019-01-07 17:51:19 +01:00
return ch