_termui_impl.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. """
  2. click._termui_impl
  3. ~~~~~~~~~~~~~~~~~~
  4. This module contains implementations for the termui module. To keep the
  5. import time of Click down, some infrequently used functionality is placed
  6. in this module and only imported as needed.
  7. :copyright: (c) 2014 by Armin Ronacher.
  8. :license: BSD, see LICENSE for more details.
  9. """
  10. import os
  11. import sys
  12. import time
  13. import math
  14. from ._compat import _default_text_stdout, range_type, PY2, isatty, \
  15. open_stream, strip_ansi, term_len, get_best_encoding, WIN
  16. from .utils import echo
  17. from .exceptions import ClickException
  18. if os.name == 'nt':
  19. BEFORE_BAR = '\r'
  20. AFTER_BAR = '\n'
  21. else:
  22. BEFORE_BAR = '\r\033[?25l'
  23. AFTER_BAR = '\033[?25h\n'
  24. def _length_hint(obj):
  25. """Returns the length hint of an object."""
  26. try:
  27. return len(obj)
  28. except (AttributeError, TypeError):
  29. try:
  30. get_hint = type(obj).__length_hint__
  31. except AttributeError:
  32. return None
  33. try:
  34. hint = get_hint(obj)
  35. except TypeError:
  36. return None
  37. if hint is NotImplemented or \
  38. not isinstance(hint, (int, long)) or \
  39. hint < 0:
  40. return None
  41. return hint
  42. class ProgressBar(object):
  43. def __init__(self, iterable, length=None, fill_char='#', empty_char=' ',
  44. bar_template='%(bar)s', info_sep=' ', show_eta=True,
  45. show_percent=None, show_pos=False, item_show_func=None,
  46. label=None, file=None, color=None, width=30):
  47. self.fill_char = fill_char
  48. self.empty_char = empty_char
  49. self.bar_template = bar_template
  50. self.info_sep = info_sep
  51. self.show_eta = show_eta
  52. self.show_percent = show_percent
  53. self.show_pos = show_pos
  54. self.item_show_func = item_show_func
  55. self.label = label or ''
  56. if file is None:
  57. file = _default_text_stdout()
  58. self.file = file
  59. self.color = color
  60. self.width = width
  61. self.autowidth = width == 0
  62. if length is None:
  63. length = _length_hint(iterable)
  64. if iterable is None:
  65. if length is None:
  66. raise TypeError('iterable or length is required')
  67. iterable = range_type(length)
  68. self.iter = iter(iterable)
  69. self.length = length
  70. self.length_known = length is not None
  71. self.pos = 0
  72. self.avg = []
  73. self.start = self.last_eta = time.time()
  74. self.eta_known = False
  75. self.finished = False
  76. self.max_width = None
  77. self.entered = False
  78. self.current_item = None
  79. self.is_hidden = not isatty(self.file)
  80. self._last_line = None
  81. def __enter__(self):
  82. self.entered = True
  83. self.render_progress()
  84. return self
  85. def __exit__(self, exc_type, exc_value, tb):
  86. self.render_finish()
  87. def __iter__(self):
  88. if not self.entered:
  89. raise RuntimeError('You need to use progress bars in a with block.')
  90. self.render_progress()
  91. return self
  92. def render_finish(self):
  93. if self.is_hidden:
  94. return
  95. self.file.write(AFTER_BAR)
  96. self.file.flush()
  97. @property
  98. def pct(self):
  99. if self.finished:
  100. return 1.0
  101. return min(self.pos / (float(self.length) or 1), 1.0)
  102. @property
  103. def time_per_iteration(self):
  104. if not self.avg:
  105. return 0.0
  106. return sum(self.avg) / float(len(self.avg))
  107. @property
  108. def eta(self):
  109. if self.length_known and not self.finished:
  110. return self.time_per_iteration * (self.length - self.pos)
  111. return 0.0
  112. def format_eta(self):
  113. if self.eta_known:
  114. t = self.eta + 1
  115. seconds = t % 60
  116. t /= 60
  117. minutes = t % 60
  118. t /= 60
  119. hours = t % 24
  120. t /= 24
  121. if t > 0:
  122. days = t
  123. return '%dd %02d:%02d:%02d' % (days, hours, minutes, seconds)
  124. else:
  125. return '%02d:%02d:%02d' % (hours, minutes, seconds)
  126. return ''
  127. def format_pos(self):
  128. pos = str(self.pos)
  129. if self.length_known:
  130. pos += '/%s' % self.length
  131. return pos
  132. def format_pct(self):
  133. return ('% 4d%%' % int(self.pct * 100))[1:]
  134. def format_progress_line(self):
  135. show_percent = self.show_percent
  136. info_bits = []
  137. if self.length_known:
  138. bar_length = int(self.pct * self.width)
  139. bar = self.fill_char * bar_length
  140. bar += self.empty_char * (self.width - bar_length)
  141. if show_percent is None:
  142. show_percent = not self.show_pos
  143. else:
  144. if self.finished:
  145. bar = self.fill_char * self.width
  146. else:
  147. bar = list(self.empty_char * (self.width or 1))
  148. if self.time_per_iteration != 0:
  149. bar[int((math.cos(self.pos * self.time_per_iteration)
  150. / 2.0 + 0.5) * self.width)] = self.fill_char
  151. bar = ''.join(bar)
  152. if self.show_pos:
  153. info_bits.append(self.format_pos())
  154. if show_percent:
  155. info_bits.append(self.format_pct())
  156. if self.show_eta and self.eta_known and not self.finished:
  157. info_bits.append(self.format_eta())
  158. if self.item_show_func is not None:
  159. item_info = self.item_show_func(self.current_item)
  160. if item_info is not None:
  161. info_bits.append(item_info)
  162. return (self.bar_template % {
  163. 'label': self.label,
  164. 'bar': bar,
  165. 'info': self.info_sep.join(info_bits)
  166. }).rstrip()
  167. def render_progress(self):
  168. from .termui import get_terminal_size
  169. nl = False
  170. if self.is_hidden:
  171. buf = [self.label]
  172. nl = True
  173. else:
  174. buf = []
  175. # Update width in case the terminal has been resized
  176. if self.autowidth:
  177. old_width = self.width
  178. self.width = 0
  179. clutter_length = term_len(self.format_progress_line())
  180. new_width = max(0, get_terminal_size()[0] - clutter_length)
  181. if new_width < old_width:
  182. buf.append(BEFORE_BAR)
  183. buf.append(' ' * self.max_width)
  184. self.max_width = new_width
  185. self.width = new_width
  186. clear_width = self.width
  187. if self.max_width is not None:
  188. clear_width = self.max_width
  189. buf.append(BEFORE_BAR)
  190. line = self.format_progress_line()
  191. line_len = term_len(line)
  192. if self.max_width is None or self.max_width < line_len:
  193. self.max_width = line_len
  194. buf.append(line)
  195. buf.append(' ' * (clear_width - line_len))
  196. line = ''.join(buf)
  197. # Render the line only if it changed.
  198. if line != self._last_line:
  199. self._last_line = line
  200. echo(line, file=self.file, color=self.color, nl=nl)
  201. self.file.flush()
  202. def make_step(self, n_steps):
  203. self.pos += n_steps
  204. if self.length_known and self.pos >= self.length:
  205. self.finished = True
  206. if (time.time() - self.last_eta) < 1.0:
  207. return
  208. self.last_eta = time.time()
  209. self.avg = self.avg[-6:] + [-(self.start - time.time()) / (self.pos)]
  210. self.eta_known = self.length_known
  211. def update(self, n_steps):
  212. self.make_step(n_steps)
  213. self.render_progress()
  214. def finish(self):
  215. self.eta_known = 0
  216. self.current_item = None
  217. self.finished = True
  218. def next(self):
  219. if self.is_hidden:
  220. return next(self.iter)
  221. try:
  222. rv = next(self.iter)
  223. self.current_item = rv
  224. except StopIteration:
  225. self.finish()
  226. self.render_progress()
  227. raise StopIteration()
  228. else:
  229. self.update(1)
  230. return rv
  231. if not PY2:
  232. __next__ = next
  233. del next
  234. def pager(text, color=None):
  235. """Decide what method to use for paging through text."""
  236. stdout = _default_text_stdout()
  237. if not isatty(sys.stdin) or not isatty(stdout):
  238. return _nullpager(stdout, text, color)
  239. pager_cmd = (os.environ.get('PAGER', None) or '').strip()
  240. if pager_cmd:
  241. if WIN:
  242. return _tempfilepager(text, pager_cmd, color)
  243. return _pipepager(text, pager_cmd, color)
  244. if os.environ.get('TERM') in ('dumb', 'emacs'):
  245. return _nullpager(stdout, text, color)
  246. if WIN or sys.platform.startswith('os2'):
  247. return _tempfilepager(text, 'more <', color)
  248. if hasattr(os, 'system') and os.system('(less) 2>/dev/null') == 0:
  249. return _pipepager(text, 'less', color)
  250. import tempfile
  251. fd, filename = tempfile.mkstemp()
  252. os.close(fd)
  253. try:
  254. if hasattr(os, 'system') and os.system('more "%s"' % filename) == 0:
  255. return _pipepager(text, 'more', color)
  256. return _nullpager(stdout, text, color)
  257. finally:
  258. os.unlink(filename)
  259. def _pipepager(text, cmd, color):
  260. """Page through text by feeding it to another program. Invoking a
  261. pager through this might support colors.
  262. """
  263. import subprocess
  264. env = dict(os.environ)
  265. # If we're piping to less we might support colors under the
  266. # condition that
  267. cmd_detail = cmd.rsplit('/', 1)[-1].split()
  268. if color is None and cmd_detail[0] == 'less':
  269. less_flags = os.environ.get('LESS', '') + ' '.join(cmd_detail[1:])
  270. if not less_flags:
  271. env['LESS'] = '-R'
  272. color = True
  273. elif 'r' in less_flags or 'R' in less_flags:
  274. color = True
  275. if not color:
  276. text = strip_ansi(text)
  277. c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE,
  278. env=env)
  279. encoding = get_best_encoding(c.stdin)
  280. try:
  281. c.stdin.write(text.encode(encoding, 'replace'))
  282. c.stdin.close()
  283. except (IOError, KeyboardInterrupt):
  284. pass
  285. # Less doesn't respect ^C, but catches it for its own UI purposes (aborting
  286. # search or other commands inside less).
  287. #
  288. # That means when the user hits ^C, the parent process (click) terminates,
  289. # but less is still alive, paging the output and messing up the terminal.
  290. #
  291. # If the user wants to make the pager exit on ^C, they should set
  292. # `LESS='-K'`. It's not our decision to make.
  293. while True:
  294. try:
  295. c.wait()
  296. except KeyboardInterrupt:
  297. pass
  298. else:
  299. break
  300. def _tempfilepager(text, cmd, color):
  301. """Page through text by invoking a program on a temporary file."""
  302. import tempfile
  303. filename = tempfile.mktemp()
  304. if not color:
  305. text = strip_ansi(text)
  306. encoding = get_best_encoding(sys.stdout)
  307. with open_stream(filename, 'wb')[0] as f:
  308. f.write(text.encode(encoding))
  309. try:
  310. os.system(cmd + ' "' + filename + '"')
  311. finally:
  312. os.unlink(filename)
  313. def _nullpager(stream, text, color):
  314. """Simply print unformatted text. This is the ultimate fallback."""
  315. if not color:
  316. text = strip_ansi(text)
  317. stream.write(text)
  318. class Editor(object):
  319. def __init__(self, editor=None, env=None, require_save=True,
  320. extension='.txt'):
  321. self.editor = editor
  322. self.env = env
  323. self.require_save = require_save
  324. self.extension = extension
  325. def get_editor(self):
  326. if self.editor is not None:
  327. return self.editor
  328. for key in 'VISUAL', 'EDITOR':
  329. rv = os.environ.get(key)
  330. if rv:
  331. return rv
  332. if WIN:
  333. return 'notepad'
  334. for editor in 'vim', 'nano':
  335. if os.system('which %s >/dev/null 2>&1' % editor) == 0:
  336. return editor
  337. return 'vi'
  338. def edit_file(self, filename):
  339. import subprocess
  340. editor = self.get_editor()
  341. if self.env:
  342. environ = os.environ.copy()
  343. environ.update(self.env)
  344. else:
  345. environ = None
  346. try:
  347. c = subprocess.Popen('%s "%s"' % (editor, filename),
  348. env=environ, shell=True)
  349. exit_code = c.wait()
  350. if exit_code != 0:
  351. raise ClickException('%s: Editing failed!' % editor)
  352. except OSError as e:
  353. raise ClickException('%s: Editing failed: %s' % (editor, e))
  354. def edit(self, text):
  355. import tempfile
  356. text = text or ''
  357. if text and not text.endswith('\n'):
  358. text += '\n'
  359. fd, name = tempfile.mkstemp(prefix='editor-', suffix=self.extension)
  360. try:
  361. if WIN:
  362. encoding = 'utf-8-sig'
  363. text = text.replace('\n', '\r\n')
  364. else:
  365. encoding = 'utf-8'
  366. text = text.encode(encoding)
  367. f = os.fdopen(fd, 'wb')
  368. f.write(text)
  369. f.close()
  370. timestamp = os.path.getmtime(name)
  371. self.edit_file(name)
  372. if self.require_save \
  373. and os.path.getmtime(name) == timestamp:
  374. return None
  375. f = open(name, 'rb')
  376. try:
  377. rv = f.read()
  378. finally:
  379. f.close()
  380. return rv.decode('utf-8-sig').replace('\r\n', '\n')
  381. finally:
  382. os.unlink(name)
  383. def open_url(url, wait=False, locate=False):
  384. import subprocess
  385. def _unquote_file(url):
  386. try:
  387. import urllib
  388. except ImportError:
  389. import urllib
  390. if url.startswith('file://'):
  391. url = urllib.unquote(url[7:])
  392. return url
  393. if sys.platform == 'darwin':
  394. args = ['open']
  395. if wait:
  396. args.append('-W')
  397. if locate:
  398. args.append('-R')
  399. args.append(_unquote_file(url))
  400. null = open('/dev/null', 'w')
  401. try:
  402. return subprocess.Popen(args, stderr=null).wait()
  403. finally:
  404. null.close()
  405. elif WIN:
  406. if locate:
  407. url = _unquote_file(url)
  408. args = 'explorer /select,"%s"' % _unquote_file(
  409. url.replace('"', ''))
  410. else:
  411. args = 'start %s "" "%s"' % (
  412. wait and '/WAIT' or '', url.replace('"', ''))
  413. return os.system(args)
  414. try:
  415. if locate:
  416. url = os.path.dirname(_unquote_file(url)) or '.'
  417. else:
  418. url = _unquote_file(url)
  419. c = subprocess.Popen(['xdg-open', url])
  420. if wait:
  421. return c.wait()
  422. return 0
  423. except OSError:
  424. if url.startswith(('http://', 'https://')) and not locate and not wait:
  425. import webbrowser
  426. webbrowser.open(url)
  427. return 0
  428. return 1
  429. def _translate_ch_to_exc(ch):
  430. if ch == '\x03':
  431. raise KeyboardInterrupt()
  432. if ch == '\x04':
  433. raise EOFError()
  434. if WIN:
  435. import msvcrt
  436. def getchar(echo):
  437. rv = msvcrt.getch()
  438. if echo:
  439. msvcrt.putchar(rv)
  440. _translate_ch_to_exc(rv)
  441. if PY2:
  442. enc = getattr(sys.stdin, 'encoding', None)
  443. if enc is not None:
  444. rv = rv.decode(enc, 'replace')
  445. else:
  446. rv = rv.decode('cp1252', 'replace')
  447. return rv
  448. else:
  449. import tty
  450. import termios
  451. def getchar(echo):
  452. if not isatty(sys.stdin):
  453. f = open('/dev/tty')
  454. fd = f.fileno()
  455. else:
  456. fd = sys.stdin.fileno()
  457. f = None
  458. try:
  459. old_settings = termios.tcgetattr(fd)
  460. try:
  461. tty.setraw(fd)
  462. ch = os.read(fd, 32)
  463. if echo and isatty(sys.stdout):
  464. sys.stdout.write(ch)
  465. finally:
  466. termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
  467. sys.stdout.flush()
  468. if f is not None:
  469. f.close()
  470. except termios.error:
  471. pass
  472. _translate_ch_to_exc(ch)
  473. return ch.decode(get_best_encoding(sys.stdin), 'replace')