Coverage for typer / testing.py: 100%

187 statements  

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

1import contextlib 1abcdefg

2import io 1abcdefg

3import os 1abcdefg

4import shlex 1abcdefg

5import sys 1abcdefg

6from collections.abc import Generator, Mapping, Sequence 1abcdefg

7from types import TracebackType 1abcdefg

8from typing import IO, TYPE_CHECKING, Any, BinaryIO, cast 1abcdefg

9 

10from typer.main import Typer 1abcdefg

11from typer.main import get_command as _get_command 1abcdefg

12 

13from . import _click 1abcdefg

14from ._click import _compat, formatting, termui, utils 1abcdefg

15 

16if TYPE_CHECKING: 1abcdefg

17 from _typeshed import ReadableBuffer 

18 

19 

20def make_input_stream(input: str | bytes | None, charset: str) -> BinaryIO: 1abcdefg

21 if input is None: 1abcdefg

22 input = b"" 1abcdefg

23 elif isinstance(input, str): 1abcdefg

24 input = input.encode(charset) 1abcdefg

25 

26 return io.BytesIO(input) 1abcdefg

27 

28 

29class BytesIOCopy(io.BytesIO): 1abcdefg

30 """Patch ``io.BytesIO`` to let the written stream be copied to another.""" 

31 

32 def __init__(self, copy_to: io.BytesIO) -> None: 1abcdefg

33 super().__init__() 1abcdefg

34 self.copy_to = copy_to 1abcdefg

35 

36 def flush(self) -> None: 1abcdefg

37 super().flush() 1abcdefg

38 self.copy_to.flush() 1abcdefg

39 

40 def write(self, b: "ReadableBuffer") -> int: 1abcdefg

41 self.copy_to.write(b) 1abcdefg

42 return super().write(b) 1abcdefg

43 

44 

45class StreamMixer: 1abcdefg

46 """Mixes `<stdout>` and `<stderr>` streams. 

47 

48 The result is available in the ``output`` attribute. 

49 """ 

50 

51 def __init__(self) -> None: 1abcdefg

52 self.output: io.BytesIO = io.BytesIO() 1abcdefg

53 self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output) 1abcdefg

54 self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output) 1abcdefg

55 

56 def __del__(self) -> None: 1abcdefg

57 """Guarantee that file-like objects are closed in a predictable order""" 

58 self.stderr.close() 1abcdefg

59 self.stdout.close() 1abcdefg

60 self.output.close() 1abcdefg

61 

62 

63class _NamedTextIOWrapper(io.TextIOWrapper): 1abcdefg

64 def __init__(self, buffer: BinaryIO, name: str, mode: str, **kwargs: Any) -> None: 1abcdefg

65 super().__init__(buffer, **kwargs) 1abcdefg

66 self._name = name 1abcdefg

67 self._mode = mode 1abcdefg

68 

69 @property 1abcdefg

70 def name(self) -> str: 1abcdefg

71 return self._name # pragma: no cover 

72 

73 @property 1abcdefg

74 def mode(self) -> str: 1abcdefg

75 return self._mode # pragma: no cover 

76 

77 

78class Result: 1abcdefg

79 """Holds the captured result of an invoked CLI script.""" 

80 

81 def __init__( 1abcdefg

82 self, 

83 runner: "CliRunner", 

84 stdout_bytes: bytes, 

85 stderr_bytes: bytes, 

86 output_bytes: bytes, 

87 return_value: Any, 

88 exit_code: int, 

89 exception: BaseException | None, 

90 exc_info: tuple[type[BaseException], BaseException, TracebackType] 

91 | None = None, 

92 ): 

93 self.runner = runner 1abcdefg

94 self.stdout_bytes = stdout_bytes 1abcdefg

95 self.stderr_bytes = stderr_bytes 1abcdefg

96 self.output_bytes = output_bytes 1abcdefg

97 self.return_value = return_value 1abcdefg

98 self.exit_code = exit_code 1abcdefg

99 self.exception = exception 1abcdefg

100 self.exc_info = exc_info 1abcdefg

101 

102 @property 1abcdefg

103 def output(self) -> str: 1abcdefg

104 """The terminal output as unicode string, as the user would see it.""" 

105 return self.output_bytes.decode(self.runner.charset, "replace").replace( 1abcdefg

106 "\r\n", "\n" 

107 ) 

108 

109 @property 1abcdefg

110 def stdout(self) -> str: 1abcdefg

111 """The standard output as unicode string.""" 

112 return self.stdout_bytes.decode(self.runner.charset, "replace").replace( 1abcdefg

113 "\r\n", "\n" 

114 ) 

115 

116 @property 1abcdefg

117 def stderr(self) -> str: 1abcdefg

118 """The standard error as unicode string.""" 

119 return self.stderr_bytes.decode(self.runner.charset, "replace").replace( 1abcdefg

120 "\r\n", "\n" 

121 ) 

122 

123 def __repr__(self) -> str: 1abcdefg

124 exc_str = repr(self.exception) if self.exception else "okay" 1abcdefg

125 return f"<{type(self).__name__} {exc_str}>" 1abcdefg

126 

127 

128class CliRunner: 1abcdefg

129 """The CLI runner provides functionality to invoke a command line 

130 script for unittesting purposes in an isolated environment. This only 

131 works in single-threaded systems without any concurrency as it changes the 

132 global interpreter state. Based on functionality from Click. 

133 """ 

134 

135 def __init__( 1abcdefg

136 self, 

137 charset: str = "utf-8", 

138 env: Mapping[str, str | None] | None = None, 

139 ) -> None: 

140 self.charset = charset 1abcdefg

141 self.env: Mapping[str, str | None] = env or {} 1abcdefg

142 

143 def get_default_prog_name(self, cli: _click.Command) -> str: 1abcdefg

144 """Return the default program name for a command. 

145 The default is the `name` attribute or ``"root"`` if not set. 

146 """ 

147 return cli.name or "root" 1abcdefg

148 

149 def make_env( 1abcdefg

150 self, overrides: Mapping[str, str | None] | None = None 

151 ) -> Mapping[str, str | None]: 

152 """Returns the environment overrides for invoking a script.""" 

153 rv = dict(self.env) 1abcdefg

154 if overrides: 1abcdefg

155 rv.update(overrides) 1abcdefg

156 return rv 1abcdefg

157 

158 @contextlib.contextmanager 1abcdefg

159 def isolation( 1abcdefg

160 self, 

161 input: str | bytes | None = None, 

162 env: Mapping[str, str | None] | None = None, 

163 color: bool = False, 

164 ) -> Generator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]: 

165 """A context manager that sets up the isolation for invoking of a 

166 command line tool. This sets up `<stdin>` with the given input data 

167 and `os.environ` with the overrides from the given dictionary. 

168 """ 

169 bytes_input = make_input_stream(input, self.charset) 1abcdefg

170 

171 old_stdin = sys.stdin 1abcdefg

172 old_stdout = sys.stdout 1abcdefg

173 old_stderr = sys.stderr 1abcdefg

174 old_forced_width = formatting.FORCED_WIDTH 1abcdefg

175 formatting.FORCED_WIDTH = 80 1abcdefg

176 

177 env = self.make_env(env) 1abcdefg

178 

179 stream_mixer = StreamMixer() 1abcdefg

180 

181 sys.stdin = text_input = _NamedTextIOWrapper( 1abcdefg

182 bytes_input, encoding=self.charset, name="<stdin>", mode="r" 

183 ) 

184 

185 sys.stdout = _NamedTextIOWrapper( 1abcdefg

186 stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w" 

187 ) 

188 

189 sys.stderr = _NamedTextIOWrapper( 1abcdefg

190 stream_mixer.stderr, 

191 encoding=self.charset, 

192 name="<stderr>", 

193 mode="w", 

194 errors="backslashreplace", 

195 ) 

196 

197 def visible_input(prompt: str | None = None) -> str: 1abcdefg

198 sys.stdout.write(prompt or "") 1abcdefg

199 try: 1abcdefg

200 val = next(text_input).rstrip("\r\n") 1abcdefg

201 except StopIteration as e: # pragma: no cover 

202 raise EOFError() from e 

203 sys.stdout.write(f"{val}\n") 1abcdefg

204 sys.stdout.flush() 1abcdefg

205 return val 1abcdefg

206 

207 def hidden_input(prompt: str | None = None) -> str: 1abcdefg

208 sys.stdout.write(f"{prompt or ''}\n") 1abcdefg

209 sys.stdout.flush() 1abcdefg

210 try: 1abcdefg

211 return next(text_input).rstrip("\r\n") 1abcdefg

212 except StopIteration as e: # pragma: no cover 

213 raise EOFError() from e 

214 

215 def _getchar(echo: bool) -> str: 1abcdefg

216 char = sys.stdin.read(1) 1abcdefg

217 

218 if echo: 1abcdefg

219 sys.stdout.write(char) 1abcdefg

220 

221 sys.stdout.flush() 1abcdefg

222 return char 1abcdefg

223 

224 default_color = color 1abcdefg

225 

226 def should_strip_ansi( 1abcdefg

227 stream: IO[Any] | None = None, color: bool | None = None 

228 ) -> bool: 

229 if color is None: 1abcdefg

230 return not default_color 1abcdefg

231 return not color 1abcdefg

232 

233 old_visible_prompt_func = termui.visible_prompt_func 1abcdefg

234 old_hidden_prompt_func = termui.hidden_prompt_func 1abcdefg

235 old__getchar_func = termui._getchar 1abcdefg

236 old_should_strip_ansi = utils.should_strip_ansi # type: ignore[attr-defined] 1abcdefg

237 old__compat_should_strip_ansi = _compat.should_strip_ansi 1abcdefg

238 termui.visible_prompt_func = visible_input 1abcdefg

239 termui.hidden_prompt_func = hidden_input # ty: ignore[invalid-assignment] 1abcdefg

240 termui._getchar = _getchar 1abcdefg

241 utils.should_strip_ansi = should_strip_ansi # type: ignore 1abcdefg

242 _compat.should_strip_ansi = should_strip_ansi # ty: ignore[invalid-assignment] 1abcdefg

243 

244 old_env = {} 1abcdefg

245 try: 1abcdefg

246 for key, value in env.items(): 1abcdefg

247 old_env[key] = os.environ.get(key) 1abcdefg

248 if value is None: 1abcdefg

249 try: 1abcdefg

250 del os.environ[key] 1abcdefg

251 except Exception: # pragma: no cover 

252 pass 

253 else: 

254 os.environ[key] = value 1abcdefg

255 yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output) 1abcdefg

256 finally: 

257 for key, value in old_env.items(): 1abcdefg

258 if value is None: 1abcdefg

259 try: 1abcdefg

260 del os.environ[key] 1abcdefg

261 except Exception: # pragma: no cover 

262 pass 

263 else: 

264 os.environ[key] = value 1abcdefg

265 sys.stdout = old_stdout 1abcdefg

266 sys.stderr = old_stderr 1abcdefg

267 sys.stdin = old_stdin 1abcdefg

268 termui.visible_prompt_func = old_visible_prompt_func 1abcdefg

269 termui.hidden_prompt_func = old_hidden_prompt_func 1abcdefg

270 termui._getchar = old__getchar_func 1abcdefg

271 utils.should_strip_ansi = old_should_strip_ansi # type: ignore[attr-defined] 1abcdefg

272 _compat.should_strip_ansi = old__compat_should_strip_ansi 1abcdefg

273 formatting.FORCED_WIDTH = old_forced_width 1abcdefg

274 

275 def invoke( 1abcdefg

276 self, 

277 app: Typer, 

278 args: str | Sequence[str] | None = None, 

279 input: bytes | str | None = None, 

280 env: Mapping[str, str | None] | None = None, 

281 catch_exceptions: bool = True, 

282 color: bool = False, 

283 **extra: Any, 

284 ) -> Result: 

285 cli = _get_command(app) 1abcdefg

286 exc_info = None 1abcdefg

287 

288 with self.isolation(input=input, env=env, color=color) as outstreams: 1abcdefg

289 return_value = None 1abcdefg

290 exception: BaseException | None = None 1abcdefg

291 exit_code = 0 1abcdefg

292 

293 if isinstance(args, str): 1abcdefg

294 args = shlex.split(args) 1abcdefg

295 

296 try: 1abcdefg

297 prog_name = extra.pop("prog_name") 1abcdefg

298 except KeyError: 1abcdefg

299 prog_name = self.get_default_prog_name(cli) 1abcdefg

300 

301 try: 1abcdefg

302 return_value = cli.main(args=args or (), prog_name=prog_name, **extra) 1abcdefg

303 except SystemExit as e: 1abcdefg

304 exc_info = sys.exc_info() 1abcdefg

305 e_code = cast("int | Any | None", e.code) 1abcdefg

306 

307 if e_code is None: 1abcdefg

308 e_code = 0 1abcdefg

309 

310 if e_code != 0: 1abcdefg

311 exception = e 1abcdefg

312 

313 if not isinstance(e_code, int): 1abcdefg

314 sys.stdout.write(str(e_code)) 1abcdefg

315 sys.stdout.write("\n") 1abcdefg

316 e_code = 1 1abcdefg

317 

318 exit_code = e_code 1abcdefg

319 

320 except Exception as e: 1abcdefg

321 if not catch_exceptions: 1abcdefg

322 raise 1abcdefg

323 exception = e 1abcdefg

324 exit_code = 1 1abcdefg

325 exc_info = sys.exc_info() 1abcdefg

326 finally: 

327 sys.stdout.flush() 1abcdefg

328 sys.stderr.flush() 1abcdefg

329 stdout = outstreams[0].getvalue() 1abcdefg

330 stderr = outstreams[1].getvalue() 1abcdefg

331 output = outstreams[2].getvalue() 1abcdefg

332 

333 return Result( 1abcdefg

334 runner=self, 

335 stdout_bytes=stdout, 

336 stderr_bytes=stderr, 

337 output_bytes=output, 

338 return_value=return_value, 

339 exit_code=exit_code, 

340 exception=exception, 

341 exc_info=exc_info, # type: ignore 

342 )