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

190 statements  

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

1import os 1cdabefg

2import re 1cdabefg

3import sys 1cdabefg

4from collections.abc import Callable, Iterable, Iterator 1cdabefg

5from functools import update_wrapper 1cdabefg

6from types import ModuleType, TracebackType 1cdabefg

7from typing import ( 1cdabefg

8 IO, 

9 Any, 

10 AnyStr, 

11 BinaryIO, 

12 Literal, 

13 ParamSpec, 

14 TextIO, 

15 TypeVar, 

16 cast, 

17) 

18 

19from ._compat import ( 1cdabefg

20 WIN, 

21 _default_text_stderr, 

22 _default_text_stdout, 

23 _find_binary_writer, 

24 auto_wrap_for_ansi, 

25 binary_streams, 

26 open_stream, 

27 should_strip_ansi, 

28 strip_ansi, 

29 text_streams, 

30) 

31from .globals import resolve_color_default 1cdabefg

32 

33P = ParamSpec("P") 1cdabefg

34R = TypeVar("R") 1cdabefg

35 

36 

37def _escape_control_characters(value: str) -> str: 1cdabefg

38 """Display untrusted diagnostic values without terminal control characters.""" 

39 return re.sub( 1cdabefg

40 r"[\x00-\x1f\x7f-\x9f]", lambda match: f"\\x{ord(match[0]):02x}", value 

41 ) 

42 

43 

44def _posixify(name: str) -> str: 1cdabefg

45 return "-".join(name.split()).lower() 1cd

46 

47 

48def safecall(func: Callable[P, R]) -> Callable[P, R | None]: 1cdabefg

49 """Wraps a function so that it swallows exceptions.""" 

50 

51 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: 1cdabefg

52 try: 1cdabefg

53 return func(*args, **kwargs) 1cdabefg

54 except Exception: # pragma: no cover 

55 pass 

56 return None # pragma: no cover 

57 

58 return update_wrapper(wrapper, func) 1cdabefg

59 

60 

61def make_default_short_help(help: str, max_length: int = 45) -> str: 1cdabefg

62 """Returns a condensed version of help string.""" 

63 # Consider only the first paragraph. 

64 paragraph_end = help.find("\n\n") 1cdabefg

65 

66 if paragraph_end != -1: 1cdabefg

67 help = help[:paragraph_end] 1cdabefg

68 

69 # Collapse newlines, tabs, and spaces. 

70 words = help.split() 1cdabefg

71 

72 if not words: 1cdabefg

73 return "" 1cdabefg

74 

75 # The first paragraph started with a "no rewrap" marker, ignore it. 

76 if words[0] == "\b": 1cdabefg

77 words = words[1:] 1cdabefg

78 

79 total_length = 0 1cdabefg

80 last_index = len(words) - 1 1cdabefg

81 

82 for i, word in enumerate(words): 1cdabefg

83 total_length += len(word) + (i > 0) 1cdabefg

84 

85 if total_length > max_length: # too long, truncate 1cdabefg

86 break 1cdabefg

87 

88 if word[-1] == ".": # sentence end, truncate without "..." 1cdabefg

89 return " ".join(words[: i + 1]) 1cdabefg

90 

91 if total_length == max_length and i != last_index: 1cdabefg

92 break # not at sentence end, truncate with "..." 1cdabefg

93 else: 

94 return " ".join(words) # no truncation needed 1cdabefg

95 

96 # Account for the length of the suffix. 

97 total_length += len("...") 1cdabefg

98 

99 # remove words until the length is short enough 

100 while i > 0: 1cdabefg

101 total_length -= len(words[i]) + (i > 0) 1cdabefg

102 

103 if total_length <= max_length: 1cdabefg

104 break 1cdabefg

105 

106 i -= 1 1cdabefg

107 

108 return " ".join(words[:i]) + "..." 1cdabefg

109 

110 

111class LazyFile: 1cdabefg

112 """A lazy file works like a regular file but it does not fully open 

113 the file but it does perform some basic checks early to see if the 

114 filename parameter does make sense. This is useful for safely opening 

115 files for writing. 

116 """ 

117 

118 def __init__( 1cdabefg

119 self, 

120 filename: str | os.PathLike[str], 

121 mode: str = "r", 

122 encoding: str | None = None, 

123 errors: str | None = "strict", 

124 atomic: bool = False, 

125 ): 

126 self.name: str = os.fspath(filename) 1cdabefg

127 self.mode = mode 1cdabefg

128 self.encoding = encoding 1cdabefg

129 self.errors = errors 1cdabefg

130 self.atomic = atomic 1cdabefg

131 self._f: IO[Any] | None 1cdabefg

132 self.should_close: bool 1cdabefg

133 

134 if self.name == "-": 1cdabefg

135 self._f, self.should_close = open_stream(filename, mode, encoding, errors) 1cdabefg

136 else: 

137 if "r" in mode: 1cdabefg

138 # Open and close the file in case we're opening it for 

139 # reading so that we can catch at least some errors in 

140 # some cases early. 

141 open(filename, mode).close() 1cdabefg

142 self._f = None 1cdabefg

143 self.should_close = True 1cdabefg

144 

145 def __getattr__(self, name: str) -> Any: 1cdabefg

146 return getattr(self.open(), name) 1cdabefg

147 

148 def __repr__(self) -> str: 1cdabefg

149 if self._f is not None: 1cdabefg

150 return repr(self._f) 1cdabefg

151 return f"<unopened file '{format_filename(self.name)}' {self.mode}>" 1cdabefg

152 

153 def open(self) -> IO[Any]: 1cdabefg

154 """Opens the file if it's not yet open. This call might fail with 

155 a `FileError`. Not handling this error will produce an error 

156 that Click shows. 

157 """ 

158 if self._f is not None: 1cdabefg

159 return self._f 1cdabefg

160 try: 1cdabefg

161 rv, self.should_close = open_stream( 1cdabefg

162 self.name, self.mode, self.encoding, self.errors, atomic=self.atomic 

163 ) 

164 except OSError as e: 1cdabefg

165 from .exceptions import FileError 1cdabefg

166 

167 raise FileError(self.name, hint=e.strerror) from e 1cdabefg

168 self._f = rv 1cdabefg

169 return rv 1cdabefg

170 

171 def close(self) -> None: 1cdabefg

172 """Closes the underlying file, no matter what.""" 

173 if self._f is not None: 1cdabefg

174 self._f.close() 1cdabefg

175 

176 def close_intelligently(self) -> None: 1cdabefg

177 """This function only closes the file if it was opened by the lazy 

178 file wrapper. For instance this will never close stdin. 

179 """ 

180 if self.should_close: 1cdabefg

181 self.close() 1cdabefg

182 

183 def __enter__(self) -> "LazyFile": 1cdabefg

184 return self 1cdabefg

185 

186 def __exit__( 1cdabefg

187 self, 

188 exc_type: type[BaseException] | None, 

189 exc_value: BaseException | None, 

190 tb: TracebackType | None, 

191 ) -> None: 

192 self.close_intelligently() 1cdabefg

193 

194 def __iter__(self) -> Iterator[AnyStr]: 1cdabefg

195 self.open() 1cdabefg

196 return iter(self._f) # type: ignore 1cdabefg

197 

198 

199def echo( 1cdabefg

200 message: Any | None = None, 

201 file: IO[Any] | None = None, 

202 nl: bool = True, 

203 err: bool = False, 

204 color: bool | None = None, 

205) -> None: 

206 """Print a message and newline to stdout or a file. This should be 

207 used instead of `print` because it provides better support 

208 for different data, files, and environments. 

209 

210 Compared to `print`, this does the following: 

211 

212 - Ensures that the output encoding is not misconfigured on Linux. 

213 - Supports Unicode in the Windows console. 

214 - Supports writing to binary outputs, and supports writing bytes 

215 to text outputs. 

216 - Supports colors and styles on Windows. 

217 - Removes ANSI color and style codes if the output does not look 

218 like an interactive terminal. 

219 - Always flushes the output. 

220 """ 

221 if file is None: 1cdabefg

222 if err: 1cdabefg

223 file = _default_text_stderr() 1cdabefg

224 else: 

225 file = _default_text_stdout() 1cdabefg

226 

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

228 # pythonw on Windows. 

229 if file is None: 1cdabefg

230 return 1cdabefg

231 

232 # Convert non bytes/text into the native string type. 

233 if message is not None and not isinstance(message, (str, bytes, bytearray)): 1cdabefg

234 out: str | bytes | bytearray | None = str(message) 1cdabefg

235 else: 

236 out = message 1cdabefg

237 

238 if nl: 1cdabefg

239 out = out or "" 1cdabefg

240 if isinstance(out, str): 1cdabefg

241 out += "\n" 1cdabefg

242 else: 

243 out += b"\n" 1cdabefg

244 

245 if not out: 1cdabefg

246 file.flush() 1cdabefg

247 return 1cdabefg

248 

249 # If there is a message and the value looks like bytes, we manually 

250 # need to find the binary stream and write the message in there. 

251 # This is done separately so that most stream types will work as you 

252 # would expect. Eg: you can write to StringIO for other cases. 

253 if isinstance(out, (bytes, bytearray)): 1cdabefg

254 binary_file = _find_binary_writer(file) 1cdabefg

255 

256 if binary_file is not None: 1cdabefg

257 file.flush() 1cdabefg

258 binary_file.write(out) 1cdabefg

259 binary_file.flush() 1cdabefg

260 return 1cdabefg

261 

262 # ANSI style code support. For no message or bytes, nothing happens. 

263 # When outputting to a file instead of a terminal, strip codes. 

264 else: 

265 color = resolve_color_default(color) 1cdabefg

266 

267 if should_strip_ansi(file, color): 1cdabefg

268 out = strip_ansi(out) 1cdabefg

269 elif WIN: 1cdabefg

270 if auto_wrap_for_ansi is not None: 1ab

271 file = auto_wrap_for_ansi(file, color) # type: ignore[arg-type,call-arg] 1ab

272 elif not color: 1ab

273 out = strip_ansi(out) 1ab

274 

275 file.write(out) # type: ignore[arg-type] 1cdabefg

276 file.flush() 1cdabefg

277 

278 

279def get_binary_stream(name: Literal["stdin", "stdout", "stderr"]) -> BinaryIO: 1cdabefg

280 """Returns a system stream for byte processing.""" 

281 opener = binary_streams.get(name) 1cdabefg

282 if opener is None: 1cdabefg

283 raise TypeError(f"Unknown standard stream '{name}'") 1cdabefg

284 return opener() 1cdabefg

285 

286 

287def get_text_stream( 1cdabefg

288 name: Literal["stdin", "stdout", "stderr"], 

289 encoding: str | None = None, 

290 errors: str | None = "strict", 

291) -> TextIO: 

292 """Returns a system stream for text processing. This usually returns 

293 a wrapped stream around a binary stream returned from 

294 `get_binary_stream` but it also can take shortcuts for already 

295 correctly configured streams. 

296 """ 

297 opener = text_streams.get(name) 1cdabefg

298 if opener is None: 1cdabefg

299 raise TypeError(f"Unknown standard stream '{name}'") 1cdabefg

300 return opener(encoding, errors) 1cdabefg

301 

302 

303def format_filename( 1cdabefg

304 filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

305 shorten: bool = False, 

306) -> str: 

307 """Format a filename as a string for display. Ensures the filename can be 

308 displayed by replacing any invalid bytes or surrogate escapes in the name 

309 with the replacement character ``�``. 

310 

311 Invalid bytes or surrogate escapes will raise an error when written to a 

312 stream with ``errors="strict"``. This will typically happen with ``stdout`` 

313 when the locale is something like ``en_GB.UTF-8``. 

314 

315 Many scenarios *are* safe to write surrogates though, due to PEP 538 and 

316 PEP 540, including: 

317 

318 - Writing to ``stderr``, which uses ``errors="backslashreplace"``. 

319 - The system has ``LANG=C.UTF-8``, ``C``, or ``POSIX``. Python opens 

320 stdout and stderr with ``errors="surrogateescape"``. 

321 - None of ``LANG/LC_*`` are set. Python assumes ``LANG=C.UTF-8``. 

322 - Python is started in UTF-8 mode with ``PYTHONUTF8=1`` or ``-X utf8``. 

323 Python opens stdout and stderr with ``errors="surrogateescape"``. 

324 """ 

325 if shorten: 1cdabefg

326 filename = os.path.basename(filename) 1cdabefg

327 else: 

328 filename = os.fspath(filename) 1cdabefg

329 

330 if isinstance(filename, bytes): 1cdabefg

331 filename = filename.decode(sys.getfilesystemencoding(), "replace") 1cdabefg

332 else: 

333 filename = filename.encode("utf-8", "surrogateescape").decode( 1cdabefg

334 "utf-8", "replace" 

335 ) 

336 

337 return filename 1cdabefg

338 

339 

340def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str: 1cdabefg

341 r"""Returns the config folder for the application. The default behavior 

342 is to return whatever is most appropriate for the operating system. 

343 

344 To give you an idea, for an app called ``"Foo Bar"``, something like 

345 the following folders could be returned: 

346 

347 Mac OS X: 

348 ``~/Library/Application Support/Foo Bar`` 

349 Mac OS X (POSIX): 

350 ``~/.foo-bar`` 

351 Unix: 

352 ``~/.config/foo-bar`` 

353 Unix (POSIX): 

354 ``~/.foo-bar`` 

355 Windows (roaming): 

356 ``C:\Users\<user>\AppData\Roaming\Foo Bar`` 

357 Windows (not roaming): 

358 ``C:\Users\<user>\AppData\Local\Foo Bar`` 

359 """ 

360 if WIN: 1cdabefg

361 key = "APPDATA" if roaming else "LOCALAPPDATA" 1ab

362 folder = os.environ.get(key) 1ab

363 if folder is None: 1ab

364 folder = os.path.expanduser("~") 1ab

365 return os.path.join(folder, app_name) 1ab

366 if force_posix: 1cdefg

367 return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}")) 1cd

368 if sys.platform == "darwin": 1cdefg

369 return os.path.join( 1efg

370 os.path.expanduser("~/Library/Application Support"), app_name 

371 ) 

372 return os.path.join( 1cd

373 os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), 

374 _posixify(app_name), 

375 ) 

376 

377 

378class PacifyFlushWrapper: 1cdabefg

379 """This wrapper is used to catch and suppress BrokenPipeErrors resulting 

380 from ``.flush()`` being called on broken pipe during the shutdown/final-GC 

381 of the Python interpreter. Notably ``.flush()`` is always called on 

382 ``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any 

383 other cleanup code, and the case where the underlying file is not a broken 

384 pipe, all calls and attributes are proxied. 

385 """ 

386 

387 def __init__(self, wrapped: IO[Any]) -> None: 1cdabefg

388 self.wrapped = wrapped 1cdabefg

389 

390 def flush(self) -> None: 1cdabefg

391 try: 1cdabefg

392 self.wrapped.flush() 1cdabefg

393 except OSError as e: # pragma: no cover 

394 import errno 

395 

396 if e.errno != errno.EPIPE: 

397 raise 

398 

399 def __getattr__(self, attr: str) -> Any: 1cdabefg

400 return getattr(self.wrapped, attr) 1cdabefg

401 

402 

403def _detect_program_name( 1cdabefg

404 path: str | None = None, _main: ModuleType | None = None 

405) -> str: 

406 """Determine the command used to run the program, for use in help 

407 text. If a file or entry point was executed, the file name is 

408 returned. If ``python -m`` was used to execute a module or package, 

409 ``python -m name`` is returned. 

410 

411 This doesn't try to be too precise, the goal is to give a concise 

412 name for help text. Files are only shown as their name without the 

413 path. ``python`` is only shown for modules, and the full path to 

414 ``sys.executable`` is not shown. 

415 """ 

416 if _main is None: 1cdabefg

417 _main = sys.modules["__main__"] 1cdabefg

418 

419 if not path: 1cdabefg

420 path = sys.argv[0] 1cdabefg

421 

422 # The value of __package__ indicates how Python was called. It may 

423 # not exist if a setuptools script is installed as an egg. It may be 

424 # set incorrectly for entry points created with pip on Windows. 

425 # It is set to "" inside a Shiv or PEX zipapp. 

426 if getattr(_main, "__package__", None) in {None, ""} or ( 1cdabefg

427 os.name == "nt" 

428 and _main.__package__ == "" 

429 and not os.path.exists(path) 

430 and os.path.exists(f"{path}.exe") 

431 ): 

432 # Executed a file, like "python app.py". 

433 return os.path.basename(path) 1cdabefg

434 

435 # Executed a module, like "python -m example". 

436 # Rewritten by Python from "-m script" to "/path/to/script.py". 

437 # Need to look at main module to determine how it was executed. 

438 py_module = cast(str, _main.__package__) 1cdabefg

439 name = os.path.splitext(os.path.basename(path))[0] 1cdabefg

440 

441 # A submodule like "example.cli". 

442 if name != "__main__": 1cdabefg

443 py_module = f"{py_module}.{name}" 1cdabefg

444 

445 return f"python -m {py_module.lstrip('.')}" 1cdabefg

446 

447 

448def _expand_args( 1cdabefg

449 args: Iterable[str], 

450 *, 

451 user: bool = True, 

452 env: bool = True, 

453 glob_recursive: bool = True, 

454) -> list[str]: 

455 """Simulate Unix shell expansion with Python functions.""" 

456 from glob import glob 1ab

457 

458 out = [] 1ab

459 

460 for arg in args: 1ab

461 if user: 1ab

462 arg = os.path.expanduser(arg) 1ab

463 

464 if env: 1ab

465 arg = os.path.expandvars(arg) 1ab

466 

467 try: 1ab

468 matches = glob(arg, recursive=glob_recursive) 1ab

469 except re.error: # pragma: no cover 

470 matches = [] 

471 

472 if not matches: 1ab

473 out.append(arg) 1ab

474 else: 

475 out.extend(matches) 1ab

476 

477 return out 1ab