Coverage for typer / _click / _termui_impl.py: 100%

289 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-09 00:15 +0000

1""" 

2To keep the import times down, some infrequently used termui functionality 

3is placed here and only imported as needed. 

4""" 

5 

6import contextlib 1abfgcde

7import math 1abfgcde

8import os 1abfgcde

9import sys 1abfgcde

10import time 1abfgcde

11from collections.abc import Callable, Generator, Iterable, Iterator 1abfgcde

12from io import StringIO 1abfgcde

13from types import TracebackType 1abfgcde

14from typing import Generic, TextIO, TypeVar, cast 1abfgcde

15 

16from ._compat import ( 1abfgcde

17 CYGWIN, 

18 WIN, 

19 _default_text_stdout, 

20 get_best_encoding, 

21 isatty, 

22 term_len, 

23) 

24from .utils import echo 1abfgcde

25 

26V = TypeVar("V") 1abfgcde

27 

28if os.name == "nt": 1abfgcde

29 BEFORE_BAR = "\r" 1fg

30 AFTER_BAR = "\n" 1fg

31else: 

32 BEFORE_BAR = "\r\033[?25l" 1abcde

33 AFTER_BAR = "\033[?25h\n" 1abcde

34 

35 

36class ProgressBar(Generic[V]): 1abfgcde

37 def __init__( 1abfgcde

38 self, 

39 iterable: 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: Callable[[V | None], str | None] | None = None, 

50 label: str | None = None, 

51 file: 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 1abfgcde

57 self.empty_char = empty_char 1abfgcde

58 self.bar_template = bar_template 1abfgcde

59 self.info_sep = info_sep 1abfgcde

60 self.hidden = hidden 1abfgcde

61 self.show_eta = show_eta 1abfgcde

62 self.show_percent = show_percent 1abfgcde

63 self.show_pos = show_pos 1abfgcde

64 self.item_show_func = item_show_func 1abfgcde

65 self.label: str = label or "" 1abfgcde

66 

67 if file is None: 1abfgcde

68 file = _default_text_stdout() 1abfgcde

69 

70 # There are no standard streams attached to write to. For example, 

71 # pythonw on Windows. 

72 if file is None: # pragma: no cover 1abfgcde

73 file = StringIO() 

74 

75 self.file = file 1abfgcde

76 self.color = color 1abfgcde

77 self.update_min_steps = update_min_steps 1abfgcde

78 self._completed_intervals = 0 1abfgcde

79 self.width: int = width 1abfgcde

80 self.autowidth: bool = width == 0 1abfgcde

81 

82 if length is None: 1abfgcde

83 from operator import length_hint 1abfgcde

84 

85 length = length_hint(iterable, -1) 1abfgcde

86 

87 if length == -1: # pragma: no cover 1abfgcde

88 length = None 1abfgcde

89 if iterable is None: 1abfgcde

90 if length is None: # pragma: no cover 1abfgcde

91 raise TypeError("iterable or length is required") 

92 iterable = cast("Iterable[V]", range(length)) 1abfgcde

93 self.iter: Iterable[V] = iter(iterable) 1abfgcde

94 self.length = length 1abfgcde

95 self.pos: int = 0 1abfgcde

96 self.avg: list[float] = [] 1abfgcde

97 self.last_eta: float 1abfgcde

98 self.start: float 1abfgcde

99 self.start = self.last_eta = time.time() 1abfgcde

100 self.eta_known: bool = False 1abfgcde

101 self.finished: bool = False 1abfgcde

102 self.max_width: int | None = None 1abfgcde

103 self.entered: bool = False 1abfgcde

104 self.current_item: V | None = None 1abfgcde

105 self._is_atty = isatty(self.file) 1abfgcde

106 self._last_line: str | None = None 1abfgcde

107 

108 def __enter__(self) -> "ProgressBar[V]": 1abfgcde

109 self.entered = True 1abfgcde

110 self.render_progress() 1abfgcde

111 return self 1abfgcde

112 

113 def __exit__( 1abfgcde

114 self, 

115 exc_type: type[BaseException] | None, 

116 exc_value: BaseException | None, 

117 tb: TracebackType | None, 

118 ) -> None: 

119 self.render_finish() 1abfgcde

120 

121 def __iter__(self) -> Iterator[V]: 1abfgcde

122 if not self.entered: 1abfgcde

123 raise RuntimeError("You need to use progress bars in a with block.") 1abfgcde

124 self.render_progress() 1abfgcde

125 return self.generator() 1abfgcde

126 

127 def __next__(self) -> V: 1abfgcde

128 # Iteration is defined in terms of a generator function, 

129 # returned by iter(self); use that to define next(). This works 

130 # because `self.iter` is an iterable consumed by that generator, 

131 # so it is re-entry safe. Calling `next(self.generator())` 

132 # twice works and does "what you want". 

133 return next(iter(self)) 1abfgcde

134 

135 def render_finish(self) -> None: 1abfgcde

136 if self.hidden or not self._is_atty: 1abfgcde

137 return 1abfgcde

138 self.file.write(AFTER_BAR) 1abfgcde

139 self.file.flush() 1abfgcde

140 

141 @property 1abfgcde

142 def pct(self) -> float: 1abfgcde

143 if self.finished: 1abfgcde

144 return 1.0 1abfgcde

145 return min(self.pos / (float(self.length or 1) or 1), 1.0) 1abfgcde

146 

147 @property 1abfgcde

148 def time_per_iteration(self) -> float: 1abfgcde

149 if not self.avg: 1abfgcde

150 return 0.0 1abfgcde

151 return sum(self.avg) / float(len(self.avg)) 1abfgcde

152 

153 @property 1abfgcde

154 def eta(self) -> float: 1abfgcde

155 if self.length is not None and not self.finished: 1abfgcde

156 return self.time_per_iteration * (self.length - self.pos) 1abfgcde

157 return 0.0 1abfgcde

158 

159 def format_eta(self) -> str: 1abfgcde

160 if self.eta_known: 1abfgcde

161 t = int(self.eta) 1abfgcde

162 seconds = t % 60 1abfgcde

163 t //= 60 1abfgcde

164 minutes = t % 60 1abfgcde

165 t //= 60 1abfgcde

166 hours = t % 24 1abfgcde

167 t //= 24 1abfgcde

168 if t > 0: 1abfgcde

169 return f"{t}d {hours:02}:{minutes:02}:{seconds:02}" 1abfgcde

170 else: 

171 return f"{hours:02}:{minutes:02}:{seconds:02}" 1abfgcde

172 return "" 1abfgcde

173 

174 def format_pos(self) -> str: 1abfgcde

175 pos = str(self.pos) 1abfgcde

176 if self.length is not None: 1abfgcde

177 pos += f"/{self.length}" 1abfgcde

178 return pos 1abfgcde

179 

180 def format_pct(self) -> str: 1abfgcde

181 return f"{int(self.pct * 100): 4}%"[1:] 1abfgcde

182 

183 def format_bar(self) -> str: 1abfgcde

184 if self.length is not None: 1abfgcde

185 bar_length = int(self.pct * self.width) 1abfgcde

186 bar = self.fill_char * bar_length 1abfgcde

187 bar += self.empty_char * (self.width - bar_length) 1abfgcde

188 elif self.finished: 1abfgcde

189 bar = self.fill_char * self.width 1abfgcde

190 else: 

191 chars = list(self.empty_char * (self.width or 1)) 1abfgcde

192 if self.time_per_iteration != 0: 1abfgcde

193 chars[ 1abfgcde

194 int( 

195 (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5) 

196 * self.width 

197 ) 

198 ] = self.fill_char 

199 bar = "".join(chars) 1abfgcde

200 return bar 1abfgcde

201 

202 def format_progress_line(self) -> str: 1abfgcde

203 show_percent = self.show_percent 1abfgcde

204 

205 info_bits = [] 1abfgcde

206 if self.length is not None and show_percent is None: 1abfgcde

207 show_percent = not self.show_pos 1abfgcde

208 

209 if self.show_pos: 1abfgcde

210 info_bits.append(self.format_pos()) 1abfgcde

211 if show_percent: 1abfgcde

212 info_bits.append(self.format_pct()) 1abfgcde

213 if self.show_eta and self.eta_known and not self.finished: 1abfgcde

214 info_bits.append(self.format_eta()) 1abfgcde

215 if self.item_show_func is not None: 1abfgcde

216 item_info = self.item_show_func(self.current_item) 1abfgcde

217 if item_info is not None: 1abfgcde

218 info_bits.append(item_info) 1abfgcde

219 

220 return ( 1abfgcde

221 self.bar_template 

222 % { 

223 "label": self.label, 

224 "bar": self.format_bar(), 

225 "info": self.info_sep.join(info_bits), 

226 } 

227 ).rstrip() 

228 

229 def render_progress(self) -> None: 1abfgcde

230 if self.hidden: 1abfgcde

231 return 1abfgcde

232 

233 if not self._is_atty: 1abfgcde

234 # Only output the label once if the output is not a TTY. 

235 if self._last_line != self.label: 1abfgcde

236 self._last_line = self.label 1abfgcde

237 echo(self.label, file=self.file, color=self.color) 1abfgcde

238 return 1abfgcde

239 

240 buf = [] 1abfgcde

241 # Update width in case the terminal has been resized 

242 if self.autowidth: 1abfgcde

243 import shutil 1abfgcde

244 

245 old_width = self.width 1abfgcde

246 self.width = 0 1abfgcde

247 clutter_length = term_len(self.format_progress_line()) 1abfgcde

248 new_width = max(0, shutil.get_terminal_size().columns - clutter_length) 1abfgcde

249 if new_width < old_width and self.max_width is not None: 1abfgcde

250 buf.append(BEFORE_BAR) 1abfgcde

251 buf.append(" " * self.max_width) 1abfgcde

252 self.max_width = new_width 1abfgcde

253 self.width = new_width 1abfgcde

254 

255 clear_width = self.width 1abfgcde

256 if self.max_width is not None: 1abfgcde

257 clear_width = self.max_width 1abfgcde

258 

259 buf.append(BEFORE_BAR) 1abfgcde

260 line = self.format_progress_line() 1abfgcde

261 line_len = term_len(line) 1abfgcde

262 if self.max_width is None or self.max_width < line_len: 1abfgcde

263 self.max_width = line_len 1abfgcde

264 

265 buf.append(line) 1abfgcde

266 buf.append(" " * (clear_width - line_len)) 1abfgcde

267 line = "".join(buf) 1abfgcde

268 # Render the line only if it changed. 

269 

270 if line != self._last_line: 1abfgcde

271 self._last_line = line 1abfgcde

272 echo(line, file=self.file, color=self.color, nl=False) 1abfgcde

273 self.file.flush() 1abfgcde

274 

275 def make_step(self, n_steps: int) -> None: 1abfgcde

276 self.pos += n_steps 1abfgcde

277 if self.length is not None and self.pos >= self.length: 1abfgcde

278 self.finished = True 1abfgcde

279 

280 if (time.time() - self.last_eta) < 1.0: 1abfgcde

281 return 1abfgcde

282 

283 self.last_eta = time.time() 1abfgcde

284 

285 # self.avg is a rolling list of length <= 7 of steps where steps are 

286 # defined as time elapsed divided by the total progress through 

287 # self.length. 

288 if self.pos: 1abfgcde

289 step = (time.time() - self.start) / self.pos 1abfgcde

290 else: 

291 step = time.time() - self.start 1abfgcde

292 

293 self.avg = self.avg[-6:] + [step] 1abfgcde

294 

295 self.eta_known = self.length is not None 1abfgcde

296 

297 def update(self, n_steps: int) -> None: 1abfgcde

298 """Update the progress bar by advancing a specified number of steps.""" 

299 self._completed_intervals += n_steps 1abfgcde

300 

301 if self._completed_intervals >= self.update_min_steps: 1abfgcde

302 self.make_step(self._completed_intervals) 1abfgcde

303 self.render_progress() 1abfgcde

304 self._completed_intervals = 0 1abfgcde

305 

306 def finish(self) -> None: 1abfgcde

307 self.eta_known = False 1abfgcde

308 self.current_item = None 1abfgcde

309 self.finished = True 1abfgcde

310 

311 def generator(self) -> Iterator[V]: 1abfgcde

312 """Return a generator which yields the items added to the bar 

313 during construction, and updates the progress bar *after* the 

314 yielded block returns. 

315 """ 

316 # WARNING: the iterator interface for `ProgressBar` relies on 

317 # this and only works because this is a simple generator which 

318 # doesn't create or manage additional state. If this function 

319 # changes, the impact should be evaluated both against 

320 # `iter(bar)` and `next(bar)`. `next()` in particular may call 

321 # `self.generator()` repeatedly, and this must remain safe in 

322 # order for that interface to work. 

323 if not self.entered: # pragma: no cover 1abfgcde

324 raise RuntimeError("You need to use progress bars in a with block.") 

325 

326 if not self._is_atty: 1abfgcde

327 yield from self.iter 1abfgcde

328 else: 

329 for rv in self.iter: 1abfgcde

330 self.current_item = rv 1abfgcde

331 

332 # This allows show_item_func to be updated before the 

333 # item is processed. Only trigger at the beginning of 

334 # the update interval. 

335 if self._completed_intervals == 0: 1abfgcde

336 self.render_progress() 1abfgcde

337 

338 yield rv 1abfgcde

339 self.update(1) 1abfgcde

340 

341 self.finish() 1abfgcde

342 self.render_progress() 1abfgcde

343 

344 

345def open_url(url: str, wait: bool = False, locate: bool = False) -> int: 1abfgcde

346 import subprocess 1abfgcde

347 

348 def _unquote_file(url: str) -> str: 1abfgcde

349 from urllib.parse import unquote 1abfgcde

350 

351 if url.startswith("file://"): 1abfgcde

352 url = unquote(url[7:]) 1fg

353 

354 return url 1abfgcde

355 

356 if sys.platform == "darwin": 1abfgcde

357 args = ["open"] 1cde

358 if wait: 1cde

359 args.append("-W") 1cde

360 if locate: 1cde

361 args.append("-R") 1cde

362 args.append(_unquote_file(url)) 1cde

363 null = open("/dev/null", "w") 1cde

364 try: 1cde

365 return subprocess.Popen(args, stderr=null).wait() 1cde

366 finally: 

367 null.close() 1cde

368 elif WIN: 1abfg

369 if locate: 1fg

370 url = _unquote_file(url) 1fg

371 args = ["explorer", f"/select,{url}"] 1fg

372 else: 

373 args = ["start"] 1fg

374 if wait: 1fg

375 args.append("/WAIT") 1fg

376 args.append("") 1fg

377 args.append(url) 1fg

378 try: 1fg

379 return subprocess.call(args) 1fg

380 except OSError: 1fg

381 # Command not found 

382 return 127 1fg

383 elif CYGWIN: # pragma: no cover 1ab

384 if locate: 

385 url = _unquote_file(url) 

386 args = ["cygstart", os.path.dirname(url)] 

387 else: 

388 args = ["cygstart"] 

389 if wait: 

390 args.append("-w") 

391 args.append(url) 

392 try: 

393 return subprocess.call(args) 

394 except OSError: 

395 # Command not found 

396 return 127 

397 

398 try: 1ab

399 if locate: 1ab

400 url = os.path.dirname(_unquote_file(url)) or "." 1ab

401 else: 

402 url = _unquote_file(url) 1ab

403 c = subprocess.Popen(["xdg-open", url]) 1ab

404 if wait: 1ab

405 return c.wait() 1ab

406 return 0 1ab

407 except OSError: # pragma: no cover 

408 # TODO: remove this part, doesn't get hit by Typer code paths? 

409 if url.startswith(("http://", "https://")) and not locate and not wait: 

410 import webbrowser 

411 

412 webbrowser.open(url) 

413 return 0 

414 return 1 

415 

416 

417def _translate_ch_to_exc(ch: str) -> None: 1abfgcde

418 if ch == "\x03": 1abfgcde

419 raise KeyboardInterrupt() 1fg

420 

421 if ch == "\x04" and not WIN: # Unix-like, Ctrl+D 1abfgcde

422 raise EOFError() 1abcde

423 

424 if ch == "\x1a" and WIN: # Windows, Ctrl+Z 1abfgcde

425 raise EOFError() 1fg

426 

427 return None 1abfgcde

428 

429 

430if sys.platform == "win32": 1abfgcde

431 import msvcrt 1fg

432 

433 @contextlib.contextmanager 1fg

434 def raw_terminal() -> Iterator[int]: 1fg

435 yield -1 1fg

436 

437 def getchar(echo: bool) -> str: 1fg

438 # The function `getch` will return a bytes object corresponding to 

439 # the pressed character. Since Windows 10 build 1803, it will also 

440 # return \x00 when called a second time after pressing a regular key. 

441 # 

442 # `getwch` does not share this probably-bugged behavior. Moreover, it 

443 # returns a Unicode object by default, which is what we want. 

444 # 

445 # Either of these functions will return \x00 or \xe0 to indicate 

446 # a special key, and you need to call the same function again to get 

447 # the "rest" of the code. The fun part is that \u00e0 is 

448 # "latin small letter a with grave", so if you type that on a French 

449 # keyboard, you _also_ get a \xe0. 

450 # E.g., consider the Up arrow. This returns \xe0 and then \x48. The 

451 # resulting Unicode string reads as "a with grave" + "capital H". 

452 # This is indistinguishable from when the user actually types 

453 # "a with grave" and then "capital H". 

454 # 

455 # When \xe0 is returned, we assume it's part of a special-key sequence 

456 # and call `getwch` again, but that means that when the user types 

457 # the \u00e0 character, `getchar` doesn't return until a second 

458 # character is typed. 

459 # The alternative is returning immediately, but that would mess up 

460 # cross-platform handling of arrow keys and others that start with 

461 # \xe0. Another option is using `getch`, but then we can't reliably 

462 # read non-ASCII characters, because return values of `getch` are 

463 # limited to the current 8-bit codepage. 

464 # 

465 # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` 

466 # is doing the right thing in more situations than with `getch`. 

467 

468 if echo: 1fg

469 func = cast(Callable[[], str], msvcrt.getwche) 1fg

470 else: 

471 func = cast(Callable[[], str], msvcrt.getwch) 1fg

472 

473 rv = func() 1fg

474 

475 if rv in ("\x00", "\xe0"): 1fg

476 # \x00 and \xe0 are control characters that indicate special key, 

477 # see above. 

478 rv += func() 1fg

479 

480 _translate_ch_to_exc(rv) 1fg

481 return rv 1fg

482 

483else: 

484 import termios 1abcde

485 import tty 1abcde

486 

487 @contextlib.contextmanager 1abcde

488 def raw_terminal() -> Generator[int]: 1abcde

489 f: TextIO | None 

490 fd: int 

491 

492 if not isatty(sys.stdin): 1abcde

493 f = open("/dev/tty") 1abcde

494 fd = f.fileno() 1abcde

495 else: 

496 fd = sys.stdin.fileno() 1abcde

497 f = None 1abcde

498 

499 try: 1abcde

500 old_settings = termios.tcgetattr(fd) 1abcde

501 

502 try: 1abcde

503 tty.setraw(fd) 1abcde

504 yield fd 1abcde

505 finally: 

506 termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 1abcde

507 sys.stdout.flush() 1abcde

508 

509 if f is not None: 1abcde

510 f.close() 1abcde

511 except termios.error: # pragma: no cover 

512 pass 

513 

514 def getchar(echo: bool) -> str: 1abcde

515 with raw_terminal() as fd: 1abcde

516 ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace") 1abcde

517 

518 if echo and isatty(sys.stdout): # pragma: no cover 1abcde

519 sys.stdout.write(ch) 1abcde

520 

521 _translate_ch_to_exc(ch) 1abcde

522 return ch 1abcde