_termui_impl.py 24 KB

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