_termui_impl.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. # -*- coding: utf-8 -*-
  2. """
  3. This module contains implementations for the termui module. To keep the
  4. import time of Click down, some infrequently used functionality is
  5. placed in this module and only imported as needed.
  6. """
  7. import contextlib
  8. import math
  9. import os
  10. import sys
  11. import time
  12. from ._compat import _default_text_stdout
  13. from ._compat import CYGWIN
  14. from ._compat import get_best_encoding
  15. from ._compat import int_types
  16. from ._compat import isatty
  17. from ._compat import open_stream
  18. from ._compat import range_type
  19. from ._compat import strip_ansi
  20. from ._compat import term_len
  21. from ._compat import WIN
  22. from .exceptions import ClickException
  23. from .utils import echo
  24. if os.name == "nt":
  25. BEFORE_BAR = "\r"
  26. AFTER_BAR = "\n"
  27. else:
  28. BEFORE_BAR = "\r\033[?25l"
  29. AFTER_BAR = "\033[?25h\n"
  30. def _length_hint(obj):
  31. """Returns the length hint of an object."""
  32. try:
  33. return len(obj)
  34. except (AttributeError, TypeError):
  35. try:
  36. get_hint = type(obj).__length_hint__
  37. except AttributeError:
  38. return None
  39. try:
  40. hint = get_hint(obj)
  41. except TypeError:
  42. return None
  43. if hint is NotImplemented or not isinstance(hint, int_types) or hint < 0:
  44. return None
  45. return hint
  46. class ProgressBar(object):
  47. def __init__(
  48. self,
  49. iterable,
  50. length=None,
  51. fill_char="#",
  52. empty_char=" ",
  53. bar_template="%(bar)s",
  54. info_sep=" ",
  55. show_eta=True,
  56. show_percent=None,
  57. show_pos=False,
  58. item_show_func=None,
  59. label=None,
  60. file=None,
  61. color=None,
  62. width=30,
  63. ):
  64. self.fill_char = fill_char
  65. self.empty_char = empty_char
  66. self.bar_template = bar_template
  67. self.info_sep = info_sep
  68. self.show_eta = show_eta
  69. self.show_percent = show_percent
  70. self.show_pos = show_pos
  71. self.item_show_func = item_show_func
  72. self.label = label or ""
  73. if file is None:
  74. file = _default_text_stdout()
  75. self.file = file
  76. self.color = color
  77. self.width = width
  78. self.autowidth = width == 0
  79. if length is None:
  80. length = _length_hint(iterable)
  81. if iterable is None:
  82. if length is None:
  83. raise TypeError("iterable or length is required")
  84. iterable = range_type(length)
  85. self.iter = iter(iterable)
  86. self.length = length
  87. self.length_known = length is not None
  88. self.pos = 0
  89. self.avg = []
  90. self.start = self.last_eta = time.time()
  91. self.eta_known = False
  92. self.finished = False
  93. self.max_width = None
  94. self.entered = False
  95. self.current_item = None
  96. self.is_hidden = not isatty(self.file)
  97. self._last_line = None
  98. self.short_limit = 0.5
  99. def __enter__(self):
  100. self.entered = True
  101. self.render_progress()
  102. return self
  103. def __exit__(self, exc_type, exc_value, tb):
  104. self.render_finish()
  105. def __iter__(self):
  106. if not self.entered:
  107. raise RuntimeError("You need to use progress bars in a with block.")
  108. self.render_progress()
  109. return self.generator()
  110. def __next__(self):
  111. # Iteration is defined in terms of a generator function,
  112. # returned by iter(self); use that to define next(). This works
  113. # because `self.iter` is an iterable consumed by that generator,
  114. # so it is re-entry safe. Calling `next(self.generator())`
  115. # twice works and does "what you want".
  116. return next(iter(self))
  117. # Python 2 compat
  118. next = __next__
  119. def is_fast(self):
  120. return time.time() - self.start <= self.short_limit
  121. def render_finish(self):
  122. if self.is_hidden or self.is_fast():
  123. return
  124. self.file.write(AFTER_BAR)
  125. self.file.flush()
  126. @property
  127. def pct(self):
  128. if self.finished:
  129. return 1.0
  130. return min(self.pos / (float(self.length) or 1), 1.0)
  131. @property
  132. def time_per_iteration(self):
  133. if not self.avg:
  134. return 0.0
  135. return sum(self.avg) / float(len(self.avg))
  136. @property
  137. def eta(self):
  138. if self.length_known and not self.finished:
  139. return self.time_per_iteration * (self.length - self.pos)
  140. return 0.0
  141. def format_eta(self):
  142. if self.eta_known:
  143. t = int(self.eta)
  144. seconds = t % 60
  145. t //= 60
  146. minutes = t % 60
  147. t //= 60
  148. hours = t % 24
  149. t //= 24
  150. if t > 0:
  151. return "{}d {:02}:{:02}:{:02}".format(t, hours, minutes, seconds)
  152. else:
  153. return "{:02}:{:02}:{:02}".format(hours, minutes, seconds)
  154. return ""
  155. def format_pos(self):
  156. pos = str(self.pos)
  157. if self.length_known:
  158. pos += "/{}".format(self.length)
  159. return pos
  160. def format_pct(self):
  161. return "{: 4}%".format(int(self.pct * 100))[1:]
  162. def format_bar(self):
  163. if self.length_known:
  164. bar_length = int(self.pct * self.width)
  165. bar = self.fill_char * bar_length
  166. bar += self.empty_char * (self.width - bar_length)
  167. elif self.finished:
  168. bar = self.fill_char * self.width
  169. else:
  170. bar = list(self.empty_char * (self.width or 1))
  171. if self.time_per_iteration != 0:
  172. bar[
  173. int(
  174. (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5)
  175. * self.width
  176. )
  177. ] = self.fill_char
  178. bar = "".join(bar)
  179. return bar
  180. def format_progress_line(self):
  181. show_percent = self.show_percent
  182. info_bits = []
  183. if self.length_known and show_percent is None:
  184. show_percent = not self.show_pos
  185. if self.show_pos:
  186. info_bits.append(self.format_pos())
  187. if show_percent:
  188. info_bits.append(self.format_pct())
  189. if self.show_eta and self.eta_known and not self.finished:
  190. info_bits.append(self.format_eta())
  191. if self.item_show_func is not None:
  192. item_info = self.item_show_func(self.current_item)
  193. if item_info is not None:
  194. info_bits.append(item_info)
  195. return (
  196. self.bar_template
  197. % {
  198. "label": self.label,
  199. "bar": self.format_bar(),
  200. "info": self.info_sep.join(info_bits),
  201. }
  202. ).rstrip()
  203. def render_progress(self):
  204. from .termui import get_terminal_size
  205. if self.is_hidden:
  206. return
  207. buf = []
  208. # Update width in case the terminal has been resized
  209. if self.autowidth:
  210. old_width = self.width
  211. self.width = 0
  212. clutter_length = term_len(self.format_progress_line())
  213. new_width = max(0, get_terminal_size()[0] - clutter_length)
  214. if new_width < old_width:
  215. buf.append(BEFORE_BAR)
  216. buf.append(" " * self.max_width)
  217. self.max_width = new_width
  218. self.width = new_width
  219. clear_width = self.width
  220. if self.max_width is not None:
  221. clear_width = self.max_width
  222. buf.append(BEFORE_BAR)
  223. line = self.format_progress_line()
  224. line_len = term_len(line)
  225. if self.max_width is None or self.max_width < line_len:
  226. self.max_width = line_len
  227. buf.append(line)
  228. buf.append(" " * (clear_width - line_len))
  229. line = "".join(buf)
  230. # Render the line only if it changed.
  231. if line != self._last_line and not self.is_fast():
  232. self._last_line = line
  233. echo(line, file=self.file, color=self.color, nl=False)
  234. self.file.flush()
  235. def make_step(self, n_steps):
  236. self.pos += n_steps
  237. if self.length_known and self.pos >= self.length:
  238. self.finished = True
  239. if (time.time() - self.last_eta) < 1.0:
  240. return
  241. self.last_eta = time.time()
  242. # self.avg is a rolling list of length <= 7 of steps where steps are
  243. # defined as time elapsed divided by the total progress through
  244. # self.length.
  245. if self.pos:
  246. step = (time.time() - self.start) / self.pos
  247. else:
  248. step = time.time() - self.start
  249. self.avg = self.avg[-6:] + [step]
  250. self.eta_known = self.length_known
  251. def update(self, n_steps):
  252. self.make_step(n_steps)
  253. self.render_progress()
  254. def finish(self):
  255. self.eta_known = 0
  256. self.current_item = None
  257. self.finished = True
  258. def generator(self):
  259. """Return a generator which yields the items added to the bar
  260. during construction, and updates the progress bar *after* the
  261. yielded block returns.
  262. """
  263. # WARNING: the iterator interface for `ProgressBar` relies on
  264. # this and only works because this is a simple generator which
  265. # doesn't create or manage additional state. If this function
  266. # changes, the impact should be evaluated both against
  267. # `iter(bar)` and `next(bar)`. `next()` in particular may call
  268. # `self.generator()` repeatedly, and this must remain safe in
  269. # order for that interface to work.
  270. if not self.entered:
  271. raise RuntimeError("You need to use progress bars in a with block.")
  272. if self.is_hidden:
  273. for rv in self.iter:
  274. yield rv
  275. else:
  276. for rv in self.iter:
  277. self.current_item = rv
  278. yield rv
  279. self.update(1)
  280. self.finish()
  281. self.render_progress()
  282. def pager(generator, color=None):
  283. """Decide what method to use for paging through text."""
  284. stdout = _default_text_stdout()
  285. if not isatty(sys.stdin) or not isatty(stdout):
  286. return _nullpager(stdout, generator, color)
  287. pager_cmd = (os.environ.get("PAGER", None) or "").strip()
  288. if pager_cmd:
  289. if WIN:
  290. return _tempfilepager(generator, pager_cmd, color)
  291. return _pipepager(generator, pager_cmd, color)
  292. if os.environ.get("TERM") in ("dumb", "emacs"):
  293. return _nullpager(stdout, generator, color)
  294. if WIN or sys.platform.startswith("os2"):
  295. return _tempfilepager(generator, "more <", color)
  296. if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
  297. return _pipepager(generator, "less", color)
  298. import tempfile
  299. fd, filename = tempfile.mkstemp()
  300. os.close(fd)
  301. try:
  302. if hasattr(os, "system") and os.system('more "{}"'.format(filename)) == 0:
  303. return _pipepager(generator, "more", color)
  304. return _nullpager(stdout, generator, color)
  305. finally:
  306. os.unlink(filename)
  307. def _pipepager(generator, cmd, color):
  308. """Page through text by feeding it to another program. Invoking a
  309. pager through this might support colors.
  310. """
  311. import subprocess
  312. env = dict(os.environ)
  313. # If we're piping to less we might support colors under the
  314. # condition that
  315. cmd_detail = cmd.rsplit("/", 1)[-1].split()
  316. if color is None and cmd_detail[0] == "less":
  317. less_flags = "{}{}".format(os.environ.get("LESS", ""), " ".join(cmd_detail[1:]))
  318. if not less_flags:
  319. env["LESS"] = "-R"
  320. color = True
  321. elif "r" in less_flags or "R" in less_flags:
  322. color = True
  323. c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
  324. encoding = get_best_encoding(c.stdin)
  325. try:
  326. for text in generator:
  327. if not color:
  328. text = strip_ansi(text)
  329. c.stdin.write(text.encode(encoding, "replace"))
  330. except (IOError, KeyboardInterrupt):
  331. pass
  332. else:
  333. c.stdin.close()
  334. # Less doesn't respect ^C, but catches it for its own UI purposes (aborting
  335. # search or other commands inside less).
  336. #
  337. # That means when the user hits ^C, the parent process (click) terminates,
  338. # but less is still alive, paging the output and messing up the terminal.
  339. #
  340. # If the user wants to make the pager exit on ^C, they should set
  341. # `LESS='-K'`. It's not our decision to make.
  342. while True:
  343. try:
  344. c.wait()
  345. except KeyboardInterrupt:
  346. pass
  347. else:
  348. break
  349. def _tempfilepager(generator, cmd, color):
  350. """Page through text by invoking a program on a temporary file."""
  351. import tempfile
  352. filename = tempfile.mktemp()
  353. # TODO: This never terminates if the passed generator never terminates.
  354. text = "".join(generator)
  355. if not color:
  356. text = strip_ansi(text)
  357. encoding = get_best_encoding(sys.stdout)
  358. with open_stream(filename, "wb")[0] as f:
  359. f.write(text.encode(encoding))
  360. try:
  361. os.system('{} "{}"'.format(cmd, filename))
  362. finally:
  363. os.unlink(filename)
  364. def _nullpager(stream, generator, color):
  365. """Simply print unformatted text. This is the ultimate fallback."""
  366. for text in generator:
  367. if not color:
  368. text = strip_ansi(text)
  369. stream.write(text)
  370. class Editor(object):
  371. def __init__(self, editor=None, env=None, require_save=True, extension=".txt"):
  372. self.editor = editor
  373. self.env = env
  374. self.require_save = require_save
  375. self.extension = extension
  376. def get_editor(self):
  377. if self.editor is not None:
  378. return self.editor
  379. for key in "VISUAL", "EDITOR":
  380. rv = os.environ.get(key)
  381. if rv:
  382. return rv
  383. if WIN:
  384. return "notepad"
  385. for editor in "sensible-editor", "vim", "nano":
  386. if os.system("which {} >/dev/null 2>&1".format(editor)) == 0:
  387. return editor
  388. return "vi"
  389. def edit_file(self, filename):
  390. import subprocess
  391. editor = self.get_editor()
  392. if self.env:
  393. environ = os.environ.copy()
  394. environ.update(self.env)
  395. else:
  396. environ = None
  397. try:
  398. c = subprocess.Popen(
  399. '{} "{}"'.format(editor, filename), env=environ, shell=True,
  400. )
  401. exit_code = c.wait()
  402. if exit_code != 0:
  403. raise ClickException("{}: Editing failed!".format(editor))
  404. except OSError as e:
  405. raise ClickException("{}: Editing failed: {}".format(editor, e))
  406. def edit(self, text):
  407. import tempfile
  408. text = text or ""
  409. if text and not text.endswith("\n"):
  410. text += "\n"
  411. fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension)
  412. try:
  413. if WIN:
  414. encoding = "utf-8-sig"
  415. text = text.replace("\n", "\r\n")
  416. else:
  417. encoding = "utf-8"
  418. text = text.encode(encoding)
  419. f = os.fdopen(fd, "wb")
  420. f.write(text)
  421. f.close()
  422. timestamp = os.path.getmtime(name)
  423. self.edit_file(name)
  424. if self.require_save and os.path.getmtime(name) == timestamp:
  425. return None
  426. f = open(name, "rb")
  427. try:
  428. rv = f.read()
  429. finally:
  430. f.close()
  431. return rv.decode("utf-8-sig").replace("\r\n", "\n")
  432. finally:
  433. os.unlink(name)
  434. def open_url(url, wait=False, locate=False):
  435. import subprocess
  436. def _unquote_file(url):
  437. try:
  438. import urllib
  439. except ImportError:
  440. import urllib
  441. if url.startswith("file://"):
  442. url = urllib.unquote(url[7:])
  443. return url
  444. if sys.platform == "darwin":
  445. args = ["open"]
  446. if wait:
  447. args.append("-W")
  448. if locate:
  449. args.append("-R")
  450. args.append(_unquote_file(url))
  451. null = open("/dev/null", "w")
  452. try:
  453. return subprocess.Popen(args, stderr=null).wait()
  454. finally:
  455. null.close()
  456. elif WIN:
  457. if locate:
  458. url = _unquote_file(url)
  459. args = 'explorer /select,"{}"'.format(_unquote_file(url.replace('"', "")))
  460. else:
  461. args = 'start {} "" "{}"'.format(
  462. "/WAIT" if wait else "", url.replace('"', "")
  463. )
  464. return os.system(args)
  465. elif CYGWIN:
  466. if locate:
  467. url = _unquote_file(url)
  468. args = 'cygstart "{}"'.format(os.path.dirname(url).replace('"', ""))
  469. else:
  470. args = 'cygstart {} "{}"'.format("-w" if wait else "", url.replace('"', ""))
  471. return os.system(args)
  472. try:
  473. if locate:
  474. url = os.path.dirname(_unquote_file(url)) or "."
  475. else:
  476. url = _unquote_file(url)
  477. c = subprocess.Popen(["xdg-open", url])
  478. if wait:
  479. return c.wait()
  480. return 0
  481. except OSError:
  482. if url.startswith(("http://", "https://")) and not locate and not wait:
  483. import webbrowser
  484. webbrowser.open(url)
  485. return 0
  486. return 1
  487. def _translate_ch_to_exc(ch):
  488. if ch == u"\x03":
  489. raise KeyboardInterrupt()
  490. if ch == u"\x04" and not WIN: # Unix-like, Ctrl+D
  491. raise EOFError()
  492. if ch == u"\x1a" and WIN: # Windows, Ctrl+Z
  493. raise EOFError()
  494. if WIN:
  495. import msvcrt
  496. @contextlib.contextmanager
  497. def raw_terminal():
  498. yield
  499. def getchar(echo):
  500. # The function `getch` will return a bytes object corresponding to
  501. # the pressed character. Since Windows 10 build 1803, it will also
  502. # return \x00 when called a second time after pressing a regular key.
  503. #
  504. # `getwch` does not share this probably-bugged behavior. Moreover, it
  505. # returns a Unicode object by default, which is what we want.
  506. #
  507. # Either of these functions will return \x00 or \xe0 to indicate
  508. # a special key, and you need to call the same function again to get
  509. # the "rest" of the code. The fun part is that \u00e0 is
  510. # "latin small letter a with grave", so if you type that on a French
  511. # keyboard, you _also_ get a \xe0.
  512. # E.g., consider the Up arrow. This returns \xe0 and then \x48. The
  513. # resulting Unicode string reads as "a with grave" + "capital H".
  514. # This is indistinguishable from when the user actually types
  515. # "a with grave" and then "capital H".
  516. #
  517. # When \xe0 is returned, we assume it's part of a special-key sequence
  518. # and call `getwch` again, but that means that when the user types
  519. # the \u00e0 character, `getchar` doesn't return until a second
  520. # character is typed.
  521. # The alternative is returning immediately, but that would mess up
  522. # cross-platform handling of arrow keys and others that start with
  523. # \xe0. Another option is using `getch`, but then we can't reliably
  524. # read non-ASCII characters, because return values of `getch` are
  525. # limited to the current 8-bit codepage.
  526. #
  527. # Anyway, Click doesn't claim to do this Right(tm), and using `getwch`
  528. # is doing the right thing in more situations than with `getch`.
  529. if echo:
  530. func = msvcrt.getwche
  531. else:
  532. func = msvcrt.getwch
  533. rv = func()
  534. if rv in (u"\x00", u"\xe0"):
  535. # \x00 and \xe0 are control characters that indicate special key,
  536. # see above.
  537. rv += func()
  538. _translate_ch_to_exc(rv)
  539. return rv
  540. else:
  541. import tty
  542. import termios
  543. @contextlib.contextmanager
  544. def raw_terminal():
  545. if not isatty(sys.stdin):
  546. f = open("/dev/tty")
  547. fd = f.fileno()
  548. else:
  549. fd = sys.stdin.fileno()
  550. f = None
  551. try:
  552. old_settings = termios.tcgetattr(fd)
  553. try:
  554. tty.setraw(fd)
  555. yield fd
  556. finally:
  557. termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
  558. sys.stdout.flush()
  559. if f is not None:
  560. f.close()
  561. except termios.error:
  562. pass
  563. def getchar(echo):
  564. with raw_terminal() as fd:
  565. ch = os.read(fd, 32)
  566. ch = ch.decode(get_best_encoding(sys.stdin), "replace")
  567. if echo and isatty(sys.stdout):
  568. sys.stdout.write(ch)
  569. _translate_ch_to_exc(ch)
  570. return ch