_termui_impl.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. """
  2. This module contains implementations for the termui module. To keep the
  3. import time of Click down, some infrequently used functionality is
  4. placed in this module and only imported as needed.
  5. """
  6. from __future__ import annotations
  7. import collections.abc as cabc
  8. import contextlib
  9. import math
  10. import os
  11. import shlex
  12. import sys
  13. import time
  14. import typing as t
  15. from gettext import gettext as _
  16. from io import StringIO
  17. from pathlib import Path
  18. from types import TracebackType
  19. from ._compat import _default_text_stdout
  20. from ._compat import CYGWIN
  21. from ._compat import get_best_encoding
  22. from ._compat import isatty
  23. from ._compat import strip_ansi
  24. from ._compat import term_len
  25. from ._compat import WIN
  26. from .exceptions import ClickException
  27. from .utils import echo
  28. V = t.TypeVar("V")
  29. if os.name == "nt":
  30. BEFORE_BAR = "\r"
  31. AFTER_BAR = "\n"
  32. else:
  33. BEFORE_BAR = "\r\033[?25l"
  34. AFTER_BAR = "\033[?25h\n"
  35. class ProgressBar(t.Generic[V]):
  36. def __init__(
  37. self,
  38. iterable: cabc.Iterable[V] | None,
  39. length: int | None = None,
  40. fill_char: str = "#",
  41. empty_char: str = " ",
  42. bar_template: str = "%(bar)s",
  43. info_sep: str = " ",
  44. hidden: bool = False,
  45. show_eta: bool = True,
  46. show_percent: bool | None = None,
  47. show_pos: bool = False,
  48. item_show_func: t.Callable[[V | None], str | None] | None = None,
  49. label: str | None = None,
  50. file: t.TextIO | None = None,
  51. color: bool | None = None,
  52. update_min_steps: int = 1,
  53. width: int = 30,
  54. ) -> None:
  55. self.fill_char = fill_char
  56. self.empty_char = empty_char
  57. self.bar_template = bar_template
  58. self.info_sep = info_sep
  59. self.hidden = hidden
  60. self.show_eta = show_eta
  61. self.show_percent = show_percent
  62. self.show_pos = show_pos
  63. self.item_show_func = item_show_func
  64. self.label: str = label or ""
  65. if file is None:
  66. file = _default_text_stdout()
  67. # There are no standard streams attached to write to. For example,
  68. # pythonw on Windows.
  69. if file is None:
  70. file = StringIO()
  71. self.file = file
  72. self.color = color
  73. self.update_min_steps = update_min_steps
  74. self._completed_intervals = 0
  75. self.width: int = width
  76. self.autowidth: bool = width == 0
  77. if length is None:
  78. from operator import length_hint
  79. length = length_hint(iterable, -1)
  80. if length == -1:
  81. length = None
  82. if iterable is None:
  83. if length is None:
  84. raise TypeError("iterable or length is required")
  85. iterable = t.cast("cabc.Iterable[V]", range(length))
  86. self.iter: cabc.Iterable[V] = iter(iterable)
  87. self.length = length
  88. self.pos: int = 0
  89. self.avg: list[float] = []
  90. self.last_eta: float
  91. self.start: float
  92. self.start = self.last_eta = time.time()
  93. self.eta_known: bool = False
  94. self.finished: bool = False
  95. self.max_width: int | None = None
  96. self.entered: bool = False
  97. self.current_item: V | None = None
  98. self._is_atty = isatty(self.file)
  99. self._last_line: str | None = None
  100. def __enter__(self) -> ProgressBar[V]:
  101. self.entered = True
  102. self.render_progress()
  103. return self
  104. def __exit__(
  105. self,
  106. exc_type: type[BaseException] | None,
  107. exc_value: BaseException | None,
  108. tb: TracebackType | None,
  109. ) -> None:
  110. self.render_finish()
  111. def __iter__(self) -> cabc.Iterator[V]:
  112. if not self.entered:
  113. raise RuntimeError("You need to use progress bars in a with block.")
  114. self.render_progress()
  115. return self.generator()
  116. def __next__(self) -> V:
  117. # Iteration is defined in terms of a generator function,
  118. # returned by iter(self); use that to define next(). This works
  119. # because `self.iter` is an iterable consumed by that generator,
  120. # so it is re-entry safe. Calling `next(self.generator())`
  121. # twice works and does "what you want".
  122. return next(iter(self))
  123. def render_finish(self) -> None:
  124. # Steps stay pending until they add up to update_min_steps, so a length
  125. # that is not a multiple of it keeps a sub-threshold remainder that
  126. # never reaches pos. Apply it so the bar settles on its true position.
  127. if self._completed_intervals:
  128. self.make_step(self._completed_intervals)
  129. self._completed_intervals = 0
  130. self.render_progress()
  131. if self.hidden or not self._is_atty:
  132. return
  133. self.file.write(AFTER_BAR)
  134. self.file.flush()
  135. @property
  136. def pct(self) -> float:
  137. if self.finished:
  138. return 1.0
  139. return min(self.pos / (float(self.length or 1) or 1), 1.0)
  140. @property
  141. def time_per_iteration(self) -> float:
  142. if not self.avg:
  143. return 0.0
  144. return sum(self.avg) / float(len(self.avg))
  145. @property
  146. def eta(self) -> float:
  147. if self.length is not None and not self.finished:
  148. return self.time_per_iteration * (self.length - self.pos)
  149. return 0.0
  150. def format_eta(self) -> str:
  151. if self.eta_known:
  152. t = int(self.eta)
  153. seconds = t % 60
  154. t //= 60
  155. minutes = t % 60
  156. t //= 60
  157. hours = t % 24
  158. t //= 24
  159. if t > 0:
  160. return "{d}{day_label} {h:02}:{m:02}:{s:02}".format(
  161. d=t,
  162. day_label=_("d"),
  163. h=hours,
  164. m=minutes,
  165. s=seconds,
  166. )
  167. else:
  168. return f"{hours:02}:{minutes:02}:{seconds:02}"
  169. return ""
  170. def format_pos(self) -> str:
  171. pos = str(self.pos)
  172. if self.length is not None:
  173. pos += f"/{self.length}"
  174. return pos
  175. def format_pct(self) -> str:
  176. return f"{int(self.pct * 100): 4}%"[1:]
  177. def format_bar(self) -> str:
  178. if self.length is not None:
  179. bar_length = int(self.pct * self.width)
  180. bar = self.fill_char * bar_length
  181. bar += self.empty_char * (self.width - bar_length)
  182. elif self.finished:
  183. bar = self.fill_char * self.width
  184. else:
  185. chars = list(self.empty_char * (self.width or 1))
  186. if self.time_per_iteration != 0:
  187. chars[
  188. int(
  189. (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5)
  190. * self.width
  191. )
  192. ] = self.fill_char
  193. bar = "".join(chars)
  194. return bar
  195. def format_progress_line(self) -> str:
  196. show_percent = self.show_percent
  197. info_bits = []
  198. if self.length is not None and show_percent is None:
  199. show_percent = not self.show_pos
  200. if self.show_pos:
  201. info_bits.append(self.format_pos())
  202. if show_percent:
  203. info_bits.append(self.format_pct())
  204. if self.show_eta and self.eta_known and not self.finished:
  205. info_bits.append(self.format_eta())
  206. if self.item_show_func is not None:
  207. item_info = self.item_show_func(self.current_item)
  208. if item_info is not None:
  209. info_bits.append(item_info)
  210. return (
  211. self.bar_template
  212. % {
  213. "label": self.label,
  214. "bar": self.format_bar(),
  215. "info": self.info_sep.join(info_bits),
  216. }
  217. ).rstrip()
  218. def render_progress(self) -> None:
  219. if self.hidden:
  220. return
  221. if not self._is_atty:
  222. # Only output the label once if the output is not a TTY.
  223. if self._last_line != self.label:
  224. self._last_line = self.label
  225. echo(self.label, file=self.file, color=self.color)
  226. return
  227. buf = []
  228. # Update width in case the terminal has been resized
  229. if self.autowidth:
  230. import shutil
  231. old_width = self.width
  232. self.width = 0
  233. clutter_length = term_len(self.format_progress_line())
  234. new_width = max(0, shutil.get_terminal_size().columns - clutter_length)
  235. if new_width < old_width and self.max_width is not None:
  236. buf.append(BEFORE_BAR)
  237. buf.append(" " * self.max_width)
  238. self.max_width = new_width
  239. self.width = new_width
  240. clear_width = self.width
  241. if self.max_width is not None:
  242. clear_width = self.max_width
  243. buf.append(BEFORE_BAR)
  244. line = self.format_progress_line()
  245. line_len = term_len(line)
  246. if self.max_width is None or self.max_width < line_len:
  247. self.max_width = line_len
  248. buf.append(line)
  249. buf.append(" " * (clear_width - line_len))
  250. line = "".join(buf)
  251. # Render the line only if it changed.
  252. if line != self._last_line:
  253. self._last_line = line
  254. echo(line, file=self.file, color=self.color, nl=False)
  255. self.file.flush()
  256. def make_step(self, n_steps: int) -> None:
  257. self.pos += n_steps
  258. if self.length is not None and self.pos >= self.length:
  259. self.finished = True
  260. if (time.time() - self.last_eta) < 1.0:
  261. return
  262. self.last_eta = time.time()
  263. # self.avg is a rolling list of length <= 7 of steps where steps are
  264. # defined as time elapsed divided by the total progress through
  265. # self.length.
  266. if self.pos:
  267. step = (time.time() - self.start) / self.pos
  268. else:
  269. step = time.time() - self.start
  270. self.avg = self.avg[-6:] + [step]
  271. self.eta_known = self.length is not None
  272. def update(self, n_steps: int, current_item: V | None = None) -> None:
  273. """Update the progress bar by advancing a specified number of
  274. steps, and optionally set the ``current_item`` for this new
  275. position.
  276. :param n_steps: Number of steps to advance.
  277. :param current_item: Optional item to set as ``current_item``
  278. for the updated position.
  279. .. versionchanged:: 8.0
  280. Added the ``current_item`` optional parameter.
  281. .. versionchanged:: 8.0
  282. Only render when the number of steps meets the
  283. ``update_min_steps`` threshold.
  284. """
  285. if current_item is not None:
  286. self.current_item = current_item
  287. self._completed_intervals += n_steps
  288. if self._completed_intervals >= self.update_min_steps:
  289. self.make_step(self._completed_intervals)
  290. self.render_progress()
  291. self._completed_intervals = 0
  292. def finish(self) -> None:
  293. self.eta_known = False
  294. self.current_item = None
  295. self.finished = True
  296. def generator(self) -> cabc.Iterator[V]:
  297. """Return a generator which yields the items added to the bar
  298. during construction, and updates the progress bar *after* the
  299. yielded block returns.
  300. """
  301. # WARNING: the iterator interface for `ProgressBar` relies on
  302. # this and only works because this is a simple generator which
  303. # doesn't create or manage additional state. If this function
  304. # changes, the impact should be evaluated both against
  305. # `iter(bar)` and `next(bar)`. `next()` in particular may call
  306. # `self.generator()` repeatedly, and this must remain safe in
  307. # order for that interface to work.
  308. if not self.entered:
  309. raise RuntimeError("You need to use progress bars in a with block.")
  310. if not self._is_atty:
  311. yield from self.iter
  312. else:
  313. for rv in self.iter:
  314. self.current_item = rv
  315. # This allows show_item_func to be updated before the
  316. # item is processed. Only trigger at the beginning of
  317. # the update interval.
  318. if self._completed_intervals == 0:
  319. self.render_progress()
  320. yield rv
  321. self.update(1)
  322. self.finish()
  323. self.render_progress()
  324. class _PagerWriter:
  325. """Wrap a pager's output stream to strip ANSI styling when colors are
  326. disabled.
  327. The wrapped stream is owned by the pager strategy that produced it, so this
  328. wrapper never closes it: ``_pipepager`` closes its pipe to signal EOF to the
  329. pager process, ``_tempfilepager`` closes and removes its temporary file, and
  330. ``_nullpager`` leaves an external stream such as ``sys.stdout`` untouched.
  331. The ``color`` attribute lets :func:`click.echo` detect that ANSI stripping
  332. is handled here, so it doesn't strip a second time (see
  333. :func:`._compat.should_strip_ansi`).
  334. """
  335. def __init__(self, stream: t.TextIO, color: bool) -> None:
  336. self._stream = stream
  337. self.color = color
  338. def write(self, text: str) -> int:
  339. if not self.color:
  340. text = strip_ansi(text)
  341. return self._stream.write(text)
  342. def writelines(self, lines: cabc.Iterable[str]) -> None:
  343. for line in lines:
  344. self.write(line)
  345. def flush(self) -> None:
  346. self._stream.flush()
  347. def close(self) -> None:
  348. # The pager strategy owns the stream's lifecycle. Flush pending output,
  349. # but never close the underlying stream from here.
  350. self._stream.flush()
  351. def __getattr__(self, name: str) -> t.Any:
  352. return getattr(self._stream, name)
  353. def _resolve_pager_command(cmd_parts: list[str]) -> tuple[Path, list[str]] | None:
  354. """Resolve a pager ``argv`` to an absolute command path and its parameters.
  355. The command is looked up with :func:`shutil.which`, which the
  356. :mod:`subprocess` docs recommend on every platform for an unqualified name:
  357. https://docs.python.org/3/library/subprocess.html#popen-constructor
  358. Returns ``None`` when there is no command to run or it is not on the path,
  359. leaving the caller to pick a fallback.
  360. """
  361. if not cmd_parts:
  362. return None
  363. import shutil
  364. cmd_filepath = shutil.which(cmd_parts[0])
  365. if not cmd_filepath:
  366. return None
  367. # Normalize to an absolute path without resolving symlinks: multi-call
  368. # binaries such as busybox derive their identity from the link they are
  369. # invoked through, so resolving less -> busybox makes them misbehave.
  370. # See https://github.com/pallets/click/issues/2943 and
  371. # https://github.com/pallets/click/pull/2944
  372. return Path(cmd_filepath).absolute(), cmd_parts[1:]
  373. def _pager_contextmanager(
  374. color: bool | None = None,
  375. ) -> t.ContextManager[tuple[t.TextIO, bool | None]]:
  376. """Decide what method to use for paging through text."""
  377. stdout = _default_text_stdout()
  378. # There are no standard streams attached to write to. For example,
  379. # pythonw on Windows.
  380. if stdout is None:
  381. stdout = StringIO()
  382. if not isatty(sys.stdin) or not isatty(stdout):
  383. return _nullpager(stdout, color)
  384. # Split using POSIX mode (the default) so that quote characters are
  385. # stripped from tokens and quoted Windows paths are preserved.
  386. # Non-POSIX mode retains quotes in tokens, and wrapping tokens
  387. # with shlex.quote re-introduces quoting issues on Windows.
  388. pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""))
  389. if pager_cmd_parts:
  390. # Piping to `more` on Windows adds spurious \r\n, so it gets the temp
  391. # file strategy whatever the user asked for.
  392. use_tempfile = WIN
  393. else:
  394. if os.environ.get("TERM") in ("dumb", "emacs"):
  395. return _nullpager(stdout, color)
  396. use_tempfile = WIN or sys.platform.startswith("os2")
  397. pager_cmd_parts = ["more"] if use_tempfile else ["less"]
  398. resolved = _resolve_pager_command(pager_cmd_parts)
  399. if resolved is None:
  400. # Page through stdout, which _nullpager leaves open for its owner.
  401. return _nullpager(stdout, color)
  402. cmd_path, cmd_params = resolved
  403. if use_tempfile:
  404. return _tempfilepager(cmd_path, cmd_params, color)
  405. return _pipepager(cmd_path, cmd_params, color)
  406. @contextlib.contextmanager
  407. def get_pager_file(color: bool | None = None) -> t.Generator[t.TextIO, None, None]:
  408. """Context manager.
  409. Yields a writable file-like object which can be used as an output pager.
  410. .. versionadded:: 8.4.0
  411. :param color: controls if the pager supports ANSI colors or not. The
  412. default is autodetection.
  413. """
  414. with _pager_contextmanager(color=color) as (stream, color):
  415. # Every pager strategy yields a text stream, so the only thing left to
  416. # do is strip ANSI styling when colors are disabled. A strategy yields
  417. # None when it has no opinion on colors, which settles as off here. The
  418. # wrapper does not close the stream: each strategy owns its stream's
  419. # lifecycle.
  420. writer = _PagerWriter(stream, color=bool(color))
  421. try:
  422. yield t.cast(t.TextIO, writer)
  423. finally:
  424. writer.flush()
  425. def _less_uses_raw_mode(less_env: str, cmd_params: list[str]) -> bool:
  426. """Detect a raw-control-characters request in a ``less`` invocation.
  427. Parses the tokens of the ``LESS`` environment variable and the command
  428. line: only short-option clusters (``-R``, ``-rX``, ``-XrY``) and the
  429. ``--raw-control-chars`` long option request raw mode. Filenames and
  430. long-option values may carry the letter ``r`` without being such a
  431. request.
  432. """
  433. try:
  434. env_tokens = shlex.split(less_env)
  435. except ValueError:
  436. # Unbalanced quotes make ``LESS`` unparsable: fall back to whitespace
  437. # splitting rather than failing the pager over it.
  438. env_tokens = less_env.split()
  439. tokens = env_tokens + cmd_params
  440. for token in tokens:
  441. if token.startswith("--"):
  442. if token.casefold() == "--raw-control-chars":
  443. return True
  444. elif token.startswith("-") and len(token) > 1:
  445. if "r" in token[1:].casefold():
  446. return True
  447. return False
  448. @contextlib.contextmanager
  449. def _pipepager(
  450. cmd_path: Path, cmd_params: list[str], color: bool | None = None
  451. ) -> t.Iterator[tuple[t.TextIO, bool | None]]:
  452. """Page through text by feeding it to another program.
  453. Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list.
  454. Invoking a pager through this might support colors: if piping to
  455. ``less`` and the user hasn't decided on colors, ``LESS=-R`` is set
  456. automatically.
  457. """
  458. import subprocess
  459. # Make a local copy of the environment to not affect the global one.
  460. env = dict(os.environ)
  461. # If we're piping to less and the user hasn't decided on colors, we enable
  462. # them: either the invocation already requests raw control characters, or
  463. # ``LESS=-R`` is injected. Match on the stem so a resolved ``less.exe`` is
  464. # recognized like bare ``less``, case-insensitively on Windows since its
  465. # filesystems are. POSIX keeps an exact match.
  466. cmd_name = cmd_path.stem
  467. if WIN:
  468. # ``_pager_contextmanager`` currently routes every Windows invocation
  469. # to ``_tempfilepager``, so this branch is not reachable on Windows
  470. # today: it keeps the detection correct should that routing ever
  471. # change.
  472. cmd_name = cmd_name.casefold()
  473. if color is None and cmd_name == "less":
  474. less_env = os.environ.get("LESS", "")
  475. if _less_uses_raw_mode(less_env, cmd_params):
  476. color = True
  477. elif not less_env and not cmd_params:
  478. env["LESS"] = "-R"
  479. color = True
  480. c = subprocess.Popen(
  481. [str(cmd_path)] + cmd_params,
  482. shell=False,
  483. stdin=subprocess.PIPE,
  484. env=env,
  485. errors="replace",
  486. text=True,
  487. )
  488. # With ``text=True``, ``c.stdin`` is already a text stream that encodes
  489. # writes for the pager process (honoring ``errors="replace"``).
  490. stdin = t.cast(t.TextIO, c.stdin)
  491. try:
  492. yield stdin, color
  493. except BrokenPipeError:
  494. # In case the pager exited unexpectedly, ignore the broken pipe error.
  495. pass
  496. except Exception as e:
  497. # In case there is an exception we want to close the pager immediately
  498. # and let the caller handle it.
  499. # Otherwise the pager will keep running, and the user may not notice
  500. # the error message, or worse yet it may leave the terminal in a broken state.
  501. c.terminate()
  502. raise e
  503. finally:
  504. # We must close stdin and wait for the pager to exit before we continue
  505. try:
  506. stdin.close()
  507. # Close implies flush, so it might throw a BrokenPipeError if the pager
  508. # process exited already.
  509. except BrokenPipeError:
  510. pass
  511. # Less doesn't respect ^C, but catches it for its own UI purposes (aborting
  512. # search or other commands inside less).
  513. #
  514. # That means when the user hits ^C, the parent process (click) terminates,
  515. # but less is still alive, paging the output and messing up the terminal.
  516. #
  517. # If the user wants to make the pager exit on ^C, they should set
  518. # `LESS='-K'`. It's not our decision to make.
  519. while True:
  520. try:
  521. c.wait()
  522. except KeyboardInterrupt:
  523. pass
  524. else:
  525. break
  526. @contextlib.contextmanager
  527. def _tempfilepager(
  528. cmd_path: Path, cmd_params: list[str], color: bool | None = None
  529. ) -> t.Iterator[tuple[t.TextIO, bool | None]]:
  530. """Page through text by invoking a program on a temporary file.
  531. Used as the primary pager strategy on Windows (where piping to
  532. ``more`` adds spurious ``\\r\\n``), and as a fallback on other
  533. platforms. The command is invoked with any parameters from ``PAGER``
  534. followed by the temporary file name.
  535. """
  536. import subprocess
  537. import tempfile
  538. encoding = get_best_encoding(sys.stdout)
  539. # On Windows, NamedTemporaryFile cannot be opened by another process
  540. # while Python still has it open, so we use delete=False and clean up manually
  541. # rather than using a contextmanager here.
  542. f = tempfile.NamedTemporaryFile(
  543. mode="w", encoding=encoding, errors="replace", delete=False
  544. )
  545. try:
  546. yield t.cast(t.TextIO, f), color
  547. f.flush()
  548. f.close()
  549. subprocess.call([str(cmd_path), *cmd_params, f.name])
  550. finally:
  551. # An error raised while paging skips the close() above, and Windows
  552. # refuses to unlink a file the process still holds open. Closing here
  553. # keeps that PermissionError from replacing the original exception.
  554. # close() is idempotent, so this is a no-op on the success path.
  555. f.close()
  556. os.unlink(f.name)
  557. @contextlib.contextmanager
  558. def _nullpager(
  559. stream: t.TextIO, color: bool | None = None
  560. ) -> t.Iterator[tuple[t.TextIO, bool | None]]:
  561. """Simply print unformatted text. This is the ultimate fallback.
  562. The stream comes from elsewhere (typically ``sys.stdout``), so its lifecycle
  563. is left untouched: :class:`_PagerWriter` never closes what it wraps, and it
  564. is the only thing :func:`get_pager_file` hands to the caller.
  565. """
  566. yield stream, color
  567. class Editor:
  568. def __init__(
  569. self,
  570. editor: str | None = None,
  571. env: cabc.Mapping[str, str] | None = None,
  572. require_save: bool = True,
  573. extension: str = ".txt",
  574. ) -> None:
  575. self.editor = editor
  576. self.env = env
  577. self.require_save = require_save
  578. self.extension = extension
  579. def get_editor(self) -> str:
  580. if self.editor is not None:
  581. return self.editor
  582. for key in "VISUAL", "EDITOR":
  583. rv = os.environ.get(key)
  584. if rv:
  585. return rv
  586. if WIN:
  587. return "notepad"
  588. from shutil import which
  589. for editor in "sensible-editor", "vim", "nano":
  590. if which(editor) is not None:
  591. return editor
  592. return "vi"
  593. def edit_files(self, filenames: cabc.Iterable[str | os.PathLike[str]]) -> None:
  594. """Open files in the user's editor."""
  595. import shlex
  596. import subprocess
  597. editor = self.get_editor()
  598. environ: dict[str, str] | None = None
  599. if self.env:
  600. environ = os.environ.copy()
  601. environ.update(self.env)
  602. try:
  603. # Split in POSIX mode (the default) for the same reasons as
  604. # in pager(): strips quotes from tokens and preserves quoted
  605. # Windows paths.
  606. c = subprocess.Popen(
  607. args=shlex.split(editor) + list(filenames),
  608. env=environ,
  609. )
  610. exit_code = c.wait()
  611. if exit_code != 0:
  612. raise ClickException(
  613. _("{editor}: Editing failed").format(editor=editor)
  614. )
  615. except OSError as e:
  616. raise ClickException(
  617. _("{editor}: Editing failed: {e}").format(editor=editor, e=e)
  618. ) from e
  619. @t.overload
  620. def edit(self, text: bytes | bytearray) -> bytes | None: ...
  621. # We cannot know whether or not the type expected is str or bytes when None
  622. # is passed, so str is returned as that was what was done before.
  623. @t.overload
  624. def edit(self, text: str | None) -> str | None: ...
  625. def edit(self, text: str | bytes | bytearray | None) -> str | bytes | None:
  626. import tempfile
  627. if text is None:
  628. data: bytes | bytearray = b""
  629. elif isinstance(text, (bytes, bytearray)):
  630. data = text
  631. else:
  632. if text and not text.endswith("\n"):
  633. text += "\n"
  634. if WIN:
  635. data = text.replace("\n", "\r\n").encode("utf-8-sig")
  636. else:
  637. data = text.encode("utf-8")
  638. fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension)
  639. f: t.BinaryIO
  640. try:
  641. with os.fdopen(fd, "wb") as f:
  642. f.write(data)
  643. # If the filesystem resolution is 1 second, like Mac OS
  644. # 10.12 Extended, or 2 seconds, like FAT32, and the editor
  645. # closes very fast, require_save can fail. Set the modified
  646. # time to be 2 seconds in the past to work around this.
  647. os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2))
  648. # Depending on the resolution, the exact value might not be
  649. # recorded, so get the new recorded value.
  650. timestamp = os.path.getmtime(name)
  651. self.edit_files((name,))
  652. if self.require_save and os.path.getmtime(name) == timestamp:
  653. return None
  654. with open(name, "rb") as f:
  655. rv = f.read()
  656. if isinstance(text, (bytes, bytearray)):
  657. return rv
  658. return rv.decode("utf-8-sig").replace("\r\n", "\n")
  659. finally:
  660. os.unlink(name)
  661. def open_url(url: str, wait: bool = False, locate: bool = False) -> int:
  662. import subprocess
  663. def _unquote_file(url: str) -> str:
  664. from urllib.parse import unquote
  665. if url.startswith("file://"):
  666. url = unquote(url[7:])
  667. return url
  668. if sys.platform == "darwin":
  669. args = ["open"]
  670. if wait:
  671. args.append("-W")
  672. if locate:
  673. args.append("-R")
  674. args.append(_unquote_file(url))
  675. null = open("/dev/null", "w")
  676. try:
  677. return subprocess.Popen(args, stderr=null).wait()
  678. finally:
  679. null.close()
  680. elif WIN:
  681. if locate:
  682. url = _unquote_file(url)
  683. args = ["explorer", "/select,", url]
  684. try:
  685. return subprocess.call(args)
  686. except OSError:
  687. return 127
  688. else:
  689. try:
  690. os.startfile(url) # type: ignore[attr-defined]
  691. except OSError:
  692. return 127
  693. return 0
  694. elif CYGWIN:
  695. if locate:
  696. url = _unquote_file(url)
  697. args = ["cygstart", os.path.dirname(url)]
  698. else:
  699. args = ["cygstart"]
  700. if wait:
  701. args.append("-w")
  702. args.append(url)
  703. try:
  704. return subprocess.call(args)
  705. except OSError:
  706. # Command not found
  707. return 127
  708. try:
  709. if locate:
  710. url = os.path.dirname(_unquote_file(url)) or "."
  711. else:
  712. url = _unquote_file(url)
  713. c = subprocess.Popen(["xdg-open", url])
  714. if wait:
  715. return c.wait()
  716. return 0
  717. except OSError:
  718. if url.startswith(("http://", "https://")) and not locate and not wait:
  719. import webbrowser
  720. webbrowser.open(url)
  721. return 0
  722. return 1
  723. def _translate_ch_to_exc(ch: str) -> None:
  724. if ch == "\x03":
  725. raise KeyboardInterrupt()
  726. if ch == "\x04" and not WIN: # Unix-like, Ctrl+D
  727. raise EOFError()
  728. if ch == "\x1a" and WIN: # Windows, Ctrl+Z
  729. raise EOFError()
  730. if sys.platform == "win32":
  731. import msvcrt
  732. @contextlib.contextmanager
  733. def raw_terminal() -> cabc.Iterator[int]:
  734. yield -1
  735. def getchar(echo: bool) -> str:
  736. # The function `getch` will return a bytes object corresponding to
  737. # the pressed character. Since Windows 10 build 1803, it will also
  738. # return \x00 when called a second time after pressing a regular key.
  739. #
  740. # `getwch` does not share this probably-bugged behavior. Moreover, it
  741. # returns a Unicode object by default, which is what we want.
  742. #
  743. # Either of these functions will return \x00 or \xe0 to indicate
  744. # a special key, and you need to call the same function again to get
  745. # the "rest" of the code. The fun part is that \u00e0 is
  746. # "latin small letter a with grave", so if you type that on a French
  747. # keyboard, you _also_ get a \xe0.
  748. # E.g., consider the Up arrow. This returns \xe0 and then \x48. The
  749. # resulting Unicode string reads as "a with grave" + "capital H".
  750. # This is indistinguishable from when the user actually types
  751. # "a with grave" and then "capital H".
  752. #
  753. # When \xe0 is returned, we assume it's part of a special-key sequence
  754. # and call `getwch` again, but that means that when the user types
  755. # the \u00e0 character, `getchar` doesn't return until a second
  756. # character is typed.
  757. # The alternative is returning immediately, but that would mess up
  758. # cross-platform handling of arrow keys and others that start with
  759. # \xe0. Another option is using `getch`, but then we can't reliably
  760. # read non-ASCII characters, because return values of `getch` are
  761. # limited to the current 8-bit codepage.
  762. #
  763. # Anyway, Click doesn't claim to do this Right(tm), and using `getwch`
  764. # is doing the right thing in more situations than with `getch`.
  765. if echo:
  766. func = t.cast(t.Callable[[], str], msvcrt.getwche)
  767. else:
  768. func = t.cast(t.Callable[[], str], msvcrt.getwch)
  769. rv = func()
  770. if rv in ("\x00", "\xe0"):
  771. # \x00 and \xe0 are control characters that indicate special key,
  772. # see above.
  773. rv += func()
  774. _translate_ch_to_exc(rv)
  775. return rv
  776. else:
  777. import termios
  778. import tty
  779. @contextlib.contextmanager
  780. def raw_terminal() -> cabc.Iterator[int]:
  781. f: t.TextIO | None
  782. fd: int
  783. if not isatty(sys.stdin):
  784. f = open("/dev/tty")
  785. fd = f.fileno()
  786. else:
  787. fd = sys.stdin.fileno()
  788. f = None
  789. try:
  790. old_settings = termios.tcgetattr(fd)
  791. try:
  792. tty.setraw(fd)
  793. yield fd
  794. finally:
  795. termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
  796. sys.stdout.flush()
  797. if f is not None:
  798. f.close()
  799. except termios.error:
  800. pass
  801. def getchar(echo: bool) -> str:
  802. with raw_terminal() as fd:
  803. ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace")
  804. if echo and isatty(sys.stdout):
  805. sys.stdout.write(ch)
  806. _translate_ch_to_exc(ch)
  807. return ch