25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

566 lines
18 KiB

  1. from __future__ import annotations
  2. import collections.abc as cabc
  3. import contextlib
  4. import io
  5. import os
  6. import shlex
  7. import shutil
  8. import sys
  9. import tempfile
  10. import typing as t
  11. from types import TracebackType
  12. from . import _compat
  13. from . import formatting
  14. from . import termui
  15. from . import utils
  16. from ._compat import _find_binary_reader
  17. if t.TYPE_CHECKING:
  18. from _typeshed import ReadableBuffer
  19. from .core import Command
  20. class EchoingStdin:
  21. def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None:
  22. self._input = input
  23. self._output = output
  24. self._paused = False
  25. def __getattr__(self, x: str) -> t.Any:
  26. return getattr(self._input, x)
  27. def _echo(self, rv: bytes) -> bytes:
  28. if not self._paused:
  29. self._output.write(rv)
  30. return rv
  31. def read(self, n: int = -1) -> bytes:
  32. return self._echo(self._input.read(n))
  33. def read1(self, n: int = -1) -> bytes:
  34. return self._echo(self._input.read1(n)) # type: ignore
  35. def readline(self, n: int = -1) -> bytes:
  36. return self._echo(self._input.readline(n))
  37. def readlines(self) -> list[bytes]:
  38. return [self._echo(x) for x in self._input.readlines()]
  39. def __iter__(self) -> cabc.Iterator[bytes]:
  40. return iter(self._echo(x) for x in self._input)
  41. def __repr__(self) -> str:
  42. return repr(self._input)
  43. @contextlib.contextmanager
  44. def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]:
  45. if stream is None:
  46. yield
  47. else:
  48. stream._paused = True
  49. yield
  50. stream._paused = False
  51. class BytesIOCopy(io.BytesIO):
  52. """Patch ``io.BytesIO`` to let the written stream be copied to another.
  53. .. versionadded:: 8.2
  54. """
  55. def __init__(self, copy_to: io.BytesIO) -> None:
  56. super().__init__()
  57. self.copy_to = copy_to
  58. def flush(self) -> None:
  59. super().flush()
  60. self.copy_to.flush()
  61. def write(self, b: ReadableBuffer) -> int:
  62. self.copy_to.write(b)
  63. return super().write(b)
  64. class StreamMixer:
  65. """Mixes `<stdout>` and `<stderr>` streams.
  66. The result is available in the ``output`` attribute.
  67. .. versionadded:: 8.2
  68. """
  69. def __init__(self) -> None:
  70. self.output: io.BytesIO = io.BytesIO()
  71. self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output)
  72. self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output)
  73. class _NamedTextIOWrapper(io.TextIOWrapper):
  74. def __init__(
  75. self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any
  76. ) -> None:
  77. super().__init__(buffer, **kwargs)
  78. self._name = name
  79. self._mode = mode
  80. @property
  81. def name(self) -> str:
  82. return self._name
  83. @property
  84. def mode(self) -> str:
  85. return self._mode
  86. def __next__(self) -> str: # type: ignore
  87. try:
  88. line = super().__next__()
  89. except StopIteration as e:
  90. raise EOFError() from e
  91. return line
  92. def make_input_stream(
  93. input: str | bytes | t.IO[t.Any] | None, charset: str
  94. ) -> t.BinaryIO:
  95. # Is already an input stream.
  96. if hasattr(input, "read"):
  97. rv = _find_binary_reader(t.cast("t.IO[t.Any]", input))
  98. if rv is not None:
  99. return rv
  100. raise TypeError("Could not find binary reader for input stream.")
  101. if input is None:
  102. input = b""
  103. elif isinstance(input, str):
  104. input = input.encode(charset)
  105. return io.BytesIO(input)
  106. class Result:
  107. """Holds the captured result of an invoked CLI script.
  108. :param runner: The runner that created the result
  109. :param stdout_bytes: The standard output as bytes.
  110. :param stderr_bytes: The standard error as bytes.
  111. :param output_bytes: A mix of ``stdout_bytes`` and ``stderr_bytes``, as the
  112. user would see it in its terminal.
  113. :param return_value: The value returned from the invoked command.
  114. :param exit_code: The exit code as integer.
  115. :param exception: The exception that happened if one did.
  116. :param exc_info: Exception information (exception type, exception instance,
  117. traceback type).
  118. .. versionchanged:: 8.2
  119. ``stderr_bytes`` no longer optional, ``output_bytes`` introduced and
  120. ``mix_stderr`` has been removed.
  121. .. versionadded:: 8.0
  122. Added ``return_value``.
  123. """
  124. def __init__(
  125. self,
  126. runner: CliRunner,
  127. stdout_bytes: bytes,
  128. stderr_bytes: bytes,
  129. output_bytes: bytes,
  130. return_value: t.Any,
  131. exit_code: int,
  132. exception: BaseException | None,
  133. exc_info: tuple[type[BaseException], BaseException, TracebackType]
  134. | None = None,
  135. ):
  136. self.runner = runner
  137. self.stdout_bytes = stdout_bytes
  138. self.stderr_bytes = stderr_bytes
  139. self.output_bytes = output_bytes
  140. self.return_value = return_value
  141. self.exit_code = exit_code
  142. self.exception = exception
  143. self.exc_info = exc_info
  144. @property
  145. def output(self) -> str:
  146. """The terminal output as unicode string, as the user would see it.
  147. .. versionchanged:: 8.2
  148. No longer a proxy for ``self.stdout``. Now has its own independent stream
  149. that is mixing `<stdout>` and `<stderr>`, in the order they were written.
  150. """
  151. return self.output_bytes.decode(self.runner.charset, "replace").replace(
  152. "\r\n", "\n"
  153. )
  154. @property
  155. def stdout(self) -> str:
  156. """The standard output as unicode string."""
  157. return self.stdout_bytes.decode(self.runner.charset, "replace").replace(
  158. "\r\n", "\n"
  159. )
  160. @property
  161. def stderr(self) -> str:
  162. """The standard error as unicode string.
  163. .. versionchanged:: 8.2
  164. No longer raise an exception, always returns the `<stderr>` string.
  165. """
  166. return self.stderr_bytes.decode(self.runner.charset, "replace").replace(
  167. "\r\n", "\n"
  168. )
  169. def __repr__(self) -> str:
  170. exc_str = repr(self.exception) if self.exception else "okay"
  171. return f"<{type(self).__name__} {exc_str}>"
  172. class CliRunner:
  173. """The CLI runner provides functionality to invoke a Click command line
  174. script for unittesting purposes in a isolated environment. This only
  175. works in single-threaded systems without any concurrency as it changes the
  176. global interpreter state.
  177. :param charset: the character set for the input and output data.
  178. :param env: a dictionary with environment variables for overriding.
  179. :param echo_stdin: if this is set to `True`, then reading from `<stdin>` writes
  180. to `<stdout>`. This is useful for showing examples in
  181. some circumstances. Note that regular prompts
  182. will automatically echo the input.
  183. :param catch_exceptions: Whether to catch any exceptions other than
  184. ``SystemExit`` when running :meth:`~CliRunner.invoke`.
  185. .. versionchanged:: 8.2
  186. Added the ``catch_exceptions`` parameter.
  187. .. versionchanged:: 8.2
  188. ``mix_stderr`` parameter has been removed.
  189. """
  190. def __init__(
  191. self,
  192. charset: str = "utf-8",
  193. env: cabc.Mapping[str, str | None] | None = None,
  194. echo_stdin: bool = False,
  195. catch_exceptions: bool = True,
  196. ) -> None:
  197. self.charset = charset
  198. self.env: cabc.Mapping[str, str | None] = env or {}
  199. self.echo_stdin = echo_stdin
  200. self.catch_exceptions = catch_exceptions
  201. def get_default_prog_name(self, cli: Command) -> str:
  202. """Given a command object it will return the default program name
  203. for it. The default is the `name` attribute or ``"root"`` if not
  204. set.
  205. """
  206. return cli.name or "root"
  207. def make_env(
  208. self, overrides: cabc.Mapping[str, str | None] | None = None
  209. ) -> cabc.Mapping[str, str | None]:
  210. """Returns the environment overrides for invoking a script."""
  211. rv = dict(self.env)
  212. if overrides:
  213. rv.update(overrides)
  214. return rv
  215. @contextlib.contextmanager
  216. def isolation(
  217. self,
  218. input: str | bytes | t.IO[t.Any] | None = None,
  219. env: cabc.Mapping[str, str | None] | None = None,
  220. color: bool = False,
  221. ) -> cabc.Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]:
  222. """A context manager that sets up the isolation for invoking of a
  223. command line tool. This sets up `<stdin>` with the given input data
  224. and `os.environ` with the overrides from the given dictionary.
  225. This also rebinds some internals in Click to be mocked (like the
  226. prompt functionality).
  227. This is automatically done in the :meth:`invoke` method.
  228. :param input: the input stream to put into `sys.stdin`.
  229. :param env: the environment overrides as dictionary.
  230. :param color: whether the output should contain color codes. The
  231. application can still override this explicitly.
  232. .. versionadded:: 8.2
  233. An additional output stream is returned, which is a mix of
  234. `<stdout>` and `<stderr>` streams.
  235. .. versionchanged:: 8.2
  236. Always returns the `<stderr>` stream.
  237. .. versionchanged:: 8.0
  238. `<stderr>` is opened with ``errors="backslashreplace"``
  239. instead of the default ``"strict"``.
  240. .. versionchanged:: 4.0
  241. Added the ``color`` parameter.
  242. """
  243. bytes_input = make_input_stream(input, self.charset)
  244. echo_input = None
  245. old_stdin = sys.stdin
  246. old_stdout = sys.stdout
  247. old_stderr = sys.stderr
  248. old_forced_width = formatting.FORCED_WIDTH
  249. formatting.FORCED_WIDTH = 80
  250. env = self.make_env(env)
  251. stream_mixer = StreamMixer()
  252. if self.echo_stdin:
  253. bytes_input = echo_input = t.cast(
  254. t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout)
  255. )
  256. sys.stdin = text_input = _NamedTextIOWrapper(
  257. bytes_input, encoding=self.charset, name="<stdin>", mode="r"
  258. )
  259. if self.echo_stdin:
  260. # Force unbuffered reads, otherwise TextIOWrapper reads a
  261. # large chunk which is echoed early.
  262. text_input._CHUNK_SIZE = 1 # type: ignore
  263. sys.stdout = _NamedTextIOWrapper(
  264. stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w"
  265. )
  266. sys.stderr = _NamedTextIOWrapper(
  267. stream_mixer.stderr,
  268. encoding=self.charset,
  269. name="<stderr>",
  270. mode="w",
  271. errors="backslashreplace",
  272. )
  273. @_pause_echo(echo_input) # type: ignore
  274. def visible_input(prompt: str | None = None) -> str:
  275. sys.stdout.write(prompt or "")
  276. val = next(text_input).rstrip("\r\n")
  277. sys.stdout.write(f"{val}\n")
  278. sys.stdout.flush()
  279. return val
  280. @_pause_echo(echo_input) # type: ignore
  281. def hidden_input(prompt: str | None = None) -> str:
  282. sys.stdout.write(f"{prompt or ''}\n")
  283. sys.stdout.flush()
  284. return next(text_input).rstrip("\r\n")
  285. @_pause_echo(echo_input) # type: ignore
  286. def _getchar(echo: bool) -> str:
  287. char = sys.stdin.read(1)
  288. if echo:
  289. sys.stdout.write(char)
  290. sys.stdout.flush()
  291. return char
  292. default_color = color
  293. def should_strip_ansi(
  294. stream: t.IO[t.Any] | None = None, color: bool | None = None
  295. ) -> bool:
  296. if color is None:
  297. return not default_color
  298. return not color
  299. old_visible_prompt_func = termui.visible_prompt_func
  300. old_hidden_prompt_func = termui.hidden_prompt_func
  301. old__getchar_func = termui._getchar
  302. old_should_strip_ansi = utils.should_strip_ansi # type: ignore
  303. old__compat_should_strip_ansi = _compat.should_strip_ansi
  304. termui.visible_prompt_func = visible_input
  305. termui.hidden_prompt_func = hidden_input
  306. termui._getchar = _getchar
  307. utils.should_strip_ansi = should_strip_ansi # type: ignore
  308. _compat.should_strip_ansi = should_strip_ansi
  309. old_env = {}
  310. try:
  311. for key, value in env.items():
  312. old_env[key] = os.environ.get(key)
  313. if value is None:
  314. try:
  315. del os.environ[key]
  316. except Exception:
  317. pass
  318. else:
  319. os.environ[key] = value
  320. yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output)
  321. finally:
  322. for key, value in old_env.items():
  323. if value is None:
  324. try:
  325. del os.environ[key]
  326. except Exception:
  327. pass
  328. else:
  329. os.environ[key] = value
  330. sys.stdout = old_stdout
  331. sys.stderr = old_stderr
  332. sys.stdin = old_stdin
  333. termui.visible_prompt_func = old_visible_prompt_func
  334. termui.hidden_prompt_func = old_hidden_prompt_func
  335. termui._getchar = old__getchar_func
  336. utils.should_strip_ansi = old_should_strip_ansi # type: ignore
  337. _compat.should_strip_ansi = old__compat_should_strip_ansi
  338. formatting.FORCED_WIDTH = old_forced_width
  339. def invoke(
  340. self,
  341. cli: Command,
  342. args: str | cabc.Sequence[str] | None = None,
  343. input: str | bytes | t.IO[t.Any] | None = None,
  344. env: cabc.Mapping[str, str | None] | None = None,
  345. catch_exceptions: bool | None = None,
  346. color: bool = False,
  347. **extra: t.Any,
  348. ) -> Result:
  349. """Invokes a command in an isolated environment. The arguments are
  350. forwarded directly to the command line script, the `extra` keyword
  351. arguments are passed to the :meth:`~clickpkg.Command.main` function of
  352. the command.
  353. This returns a :class:`Result` object.
  354. :param cli: the command to invoke
  355. :param args: the arguments to invoke. It may be given as an iterable
  356. or a string. When given as string it will be interpreted
  357. as a Unix shell command. More details at
  358. :func:`shlex.split`.
  359. :param input: the input data for `sys.stdin`.
  360. :param env: the environment overrides.
  361. :param catch_exceptions: Whether to catch any other exceptions than
  362. ``SystemExit``. If :data:`None`, the value
  363. from :class:`CliRunner` is used.
  364. :param extra: the keyword arguments to pass to :meth:`main`.
  365. :param color: whether the output should contain color codes. The
  366. application can still override this explicitly.
  367. .. versionadded:: 8.2
  368. The result object has the ``output_bytes`` attribute with
  369. the mix of ``stdout_bytes`` and ``stderr_bytes``, as the user would
  370. see it in its terminal.
  371. .. versionchanged:: 8.2
  372. The result object always returns the ``stderr_bytes`` stream.
  373. .. versionchanged:: 8.0
  374. The result object has the ``return_value`` attribute with
  375. the value returned from the invoked command.
  376. .. versionchanged:: 4.0
  377. Added the ``color`` parameter.
  378. .. versionchanged:: 3.0
  379. Added the ``catch_exceptions`` parameter.
  380. .. versionchanged:: 3.0
  381. The result object has the ``exc_info`` attribute with the
  382. traceback if available.
  383. """
  384. exc_info = None
  385. if catch_exceptions is None:
  386. catch_exceptions = self.catch_exceptions
  387. with self.isolation(input=input, env=env, color=color) as outstreams:
  388. return_value = None
  389. exception: BaseException | None = None
  390. exit_code = 0
  391. if isinstance(args, str):
  392. args = shlex.split(args)
  393. try:
  394. prog_name = extra.pop("prog_name")
  395. except KeyError:
  396. prog_name = self.get_default_prog_name(cli)
  397. try:
  398. return_value = cli.main(args=args or (), prog_name=prog_name, **extra)
  399. except SystemExit as e:
  400. exc_info = sys.exc_info()
  401. e_code = t.cast("int | t.Any | None", e.code)
  402. if e_code is None:
  403. e_code = 0
  404. if e_code != 0:
  405. exception = e
  406. if not isinstance(e_code, int):
  407. sys.stdout.write(str(e_code))
  408. sys.stdout.write("\n")
  409. e_code = 1
  410. exit_code = e_code
  411. except Exception as e:
  412. if not catch_exceptions:
  413. raise
  414. exception = e
  415. exit_code = 1
  416. exc_info = sys.exc_info()
  417. finally:
  418. sys.stdout.flush()
  419. sys.stderr.flush()
  420. stdout = outstreams[0].getvalue()
  421. stderr = outstreams[1].getvalue()
  422. output = outstreams[2].getvalue()
  423. return Result(
  424. runner=self,
  425. stdout_bytes=stdout,
  426. stderr_bytes=stderr,
  427. output_bytes=output,
  428. return_value=return_value,
  429. exit_code=exit_code,
  430. exception=exception,
  431. exc_info=exc_info, # type: ignore
  432. )
  433. @contextlib.contextmanager
  434. def isolated_filesystem(
  435. self, temp_dir: str | os.PathLike[str] | None = None
  436. ) -> cabc.Iterator[str]:
  437. """A context manager that creates a temporary directory and
  438. changes the current working directory to it. This isolates tests
  439. that affect the contents of the CWD to prevent them from
  440. interfering with each other.
  441. :param temp_dir: Create the temporary directory under this
  442. directory. If given, the created directory is not removed
  443. when exiting.
  444. .. versionchanged:: 8.0
  445. Added the ``temp_dir`` parameter.
  446. """
  447. cwd = os.getcwd()
  448. dt = tempfile.mkdtemp(dir=temp_dir)
  449. os.chdir(dt)
  450. try:
  451. yield dt
  452. finally:
  453. os.chdir(cwd)
  454. if temp_dir is None:
  455. try:
  456. shutil.rmtree(dt)
  457. except OSError:
  458. pass