_termui_impl.py 19 KB

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