_termui_impl.py 27 KB

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