Skip to content

Reference

CliRunner

The CLI runner provides functionality to invoke a command line script for unit testing purposes in a isolated environment. This only works in single-threaded systems without any concurrency as it changes the global interpreter state.

Parameters:

Name Type Description Default
charset str

the character set for the input and output data.

'utf-8'
env Mapping[str, str | None] | None

a dictionary with environment variables for overriding.

None
echo_stdin bool

if this is set to True, then reading from <stdin> writes to <stdout>. This is useful for showing examples in some circumstances.

False
Source code in clirunner/testing.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
class CliRunner:
    """The CLI runner provides functionality to invoke a command line
    script for unit testing purposes in a isolated environment.  This only
    works in single-threaded systems without any concurrency as it changes the
    global interpreter state.

    Args:
        charset: the character set for the input and output data.
        env: a dictionary with environment variables for overriding.
        echo_stdin: if this is set to `True`, then reading from `<stdin>` writes
            to `<stdout>`.  This is useful for showing examples in
            some circumstances.
    """

    def __init__(
        self,
        charset: str = "utf-8",
        env: cabc.Mapping[str, str | None] | None = None,
        echo_stdin: bool = False,
    ) -> None:
        self.charset = charset
        self.env: cabc.Mapping[str, str | None] = env or {}
        self.echo_stdin = echo_stdin

    def get_default_prog_name(self, cli: t.Callable[..., t.Any]) -> str:
        """Given a callable return the default program name for it."""
        try:
            return cli.__name__ or "main"
        except AttributeError:
            # if used with a CLI framework that creates objects like Click
            # there is no __name__ attribute
            return "main"

    def make_env(
        self, overrides: cabc.Mapping[str, str | None] | None = None
    ) -> cabc.Mapping[str, str | None]:
        """Returns the environment overrides for invoking a script."""
        rv = dict(self.env)
        if overrides:
            rv.update(overrides)
        return rv

    @contextlib.contextmanager
    def isolation(
        self,
        input: str | bytes | t.IO[t.Any] | None = None,
        env: cabc.Mapping[str, str | None] | None = None,
        # color: bool = False,
    ) -> cabc.Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]:
        """A context manager that sets up the isolation for invoking of a
        command line tool.  This sets up `<stdin>` with the given input data
        and `os.environ` with the overrides from the given dictionary.

        This is automatically done in the `invoke` method.

        Args:
            input: the input stream to put into `sys.stdin`.
            env: the environment overrides as dictionary.
        """
        # TODO: I don't think we need this color parameter as that is Click specific

        bytes_input = make_input_stream(input, self.charset)
        echo_input = None

        old_stdin = sys.stdin
        old_stdout = sys.stdout
        old_stderr = sys.stderr

        env = self.make_env(env)

        stream_mixer = StreamMixer()

        if self.echo_stdin:
            bytes_input = echo_input = t.cast(
                t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout)
            )

        sys.stdin = text_input = _NamedTextIOWrapper(
            bytes_input, encoding=self.charset, name="<stdin>", mode="r"
        )

        if self.echo_stdin:
            # Force unbuffered reads, otherwise TextIOWrapper reads a
            # large chunk which is echoed early.
            text_input._CHUNK_SIZE = 1  # type: ignore

        sys.stdout = _NamedTextIOWrapper(
            stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w"
        )

        sys.stderr = _NamedTextIOWrapper(
            stream_mixer.stderr,
            encoding=self.charset,
            name="<stderr>",
            mode="w",
            errors="backslashreplace",
        )

        # default_color = color

        # def should_strip_ansi(
        #     stream: t.IO[t.Any] | None = None, color: bool | None = None
        # ) -> bool:
        #     if color is None:
        #         return not default_color
        #     return not color

        # old_should_strip_ansi = utils.should_strip_ansi  # type: ignore
        # utils.should_strip_ansi = should_strip_ansi  # type: ignore

        old_env = {}
        try:
            for key, value in env.items():
                old_env[key] = os.environ.get(key)
                if value is None:
                    try:
                        del os.environ[key]
                    except Exception:
                        pass
                else:
                    os.environ[key] = value
            yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output)
        finally:
            for key, value in old_env.items():
                if value is None:
                    try:
                        del os.environ[key]
                    except Exception:
                        pass
                else:
                    os.environ[key] = value
            sys.stdout = old_stdout
            sys.stderr = old_stderr
            sys.stdin = old_stdin
            # utils.should_strip_ansi = old_should_strip_ansi  # type: ignore

    def invoke(
        self,
        cli: t.Callable[..., t.Any],
        args: str | cabc.Sequence[str] | None = None,
        input: str | bytes | t.IO[t.Any] | None = None,
        env: cabc.Mapping[str, str | None] | None = None,
        catch_exceptions: bool = True,
        # color: bool = False,
        **extra: t.Any,
    ) -> Result:
        """Invokes a command in an isolated environment.  The arguments are
        forwarded directly to the command line script.

        Args:
            cli: the command to invoke
            args: the arguments to invoke. It may be given as an iterable
                or a string. When given as string it will be interpreted
                as a Unix shell command. More details at
                `shlex.split`.
            input: the input data for `sys.stdin`.
            env: the environment overrides.
            catch_exceptions: Whether to catch any other exceptions than
                ``SystemExit``.

        Returns: `Result` object with results of the invocation.
        """
        exc_info = None
        with self.isolation(input=input, env=env) as outstreams:
            return_value = None
            exception: BaseException | None = None
            exit_code = 0

            if isinstance(args, str):
                args = shlex.split(args)

            try:
                prog_name = extra.pop("prog_name")
            except KeyError:
                prog_name = self.get_default_prog_name(cli)

            # set up sys.argv properly with the arguments
            call_args = args or []
            old_argv = sys.argv
            sys.argv = [prog_name, *call_args]
            try:
                return_value = cli()
            except SystemExit as e:
                exc_info = sys.exc_info()
                e_code = t.cast("int | t.Any | None", e.code)

                if e_code is None:
                    e_code = 0

                if e_code != 0:
                    exception = e

                if not isinstance(e_code, int):
                    sys.stdout.write(str(e_code))
                    sys.stdout.write("\n")
                    e_code = 1

                exit_code = e_code

            except Exception as e:
                if not catch_exceptions:
                    raise
                exception = e
                exit_code = 1
                exc_info = sys.exc_info()
            finally:
                sys.argv = old_argv
                sys.stdout.flush()
                sys.stderr.flush()
                stdout = outstreams[0].getvalue()
                stderr = outstreams[1].getvalue()
                output = outstreams[2].getvalue()

        return Result(
            runner=self,
            stdout_bytes=stdout,
            stderr_bytes=stderr,
            output_bytes=output,
            return_value=return_value,
            exit_code=exit_code,
            exception=exception,
            exc_info=exc_info,  # type: ignore
        )

    @contextlib.contextmanager
    def isolated_filesystem(
        self, temp_dir: str | os.PathLike[str] | None = None
    ) -> cabc.Iterator[str]:
        """A context manager that creates a temporary directory and
        changes the current working directory to it. This isolates tests
        that affect the contents of the CWD to prevent them from
        interfering with each other.

        Args:
            temp_dir: Create the temporary directory under this
                directory. If given, the created directory is not removed
                when exiting.
        """
        cwd = os.getcwd()
        dt = tempfile.mkdtemp(dir=temp_dir)
        os.chdir(dt)

        try:
            yield dt
        finally:
            os.chdir(cwd)

            if temp_dir is None:
                try:
                    shutil.rmtree(dt)
                except OSError:  # noqa: B014
                    pass

get_default_prog_name(cli)

Given a callable return the default program name for it.

Source code in clirunner/testing.py
216
217
218
219
220
221
222
223
def get_default_prog_name(self, cli: t.Callable[..., t.Any]) -> str:
    """Given a callable return the default program name for it."""
    try:
        return cli.__name__ or "main"
    except AttributeError:
        # if used with a CLI framework that creates objects like Click
        # there is no __name__ attribute
        return "main"

invoke(cli, args=None, input=None, env=None, catch_exceptions=True, **extra)

Invokes a command in an isolated environment. The arguments are forwarded directly to the command line script.

Parameters:

Name Type Description Default
cli Callable[..., Any]

the command to invoke

required
args str | Sequence[str] | None

the arguments to invoke. It may be given as an iterable or a string. When given as string it will be interpreted as a Unix shell command. More details at shlex.split.

None
input str | bytes | IO[Any] | None

the input data for sys.stdin.

None
env Mapping[str, str | None] | None

the environment overrides.

None
catch_exceptions bool

Whether to catch any other exceptions than SystemExit.

True

Returns: Result object with results of the invocation.

Source code in clirunner/testing.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
def invoke(
    self,
    cli: t.Callable[..., t.Any],
    args: str | cabc.Sequence[str] | None = None,
    input: str | bytes | t.IO[t.Any] | None = None,
    env: cabc.Mapping[str, str | None] | None = None,
    catch_exceptions: bool = True,
    # color: bool = False,
    **extra: t.Any,
) -> Result:
    """Invokes a command in an isolated environment.  The arguments are
    forwarded directly to the command line script.

    Args:
        cli: the command to invoke
        args: the arguments to invoke. It may be given as an iterable
            or a string. When given as string it will be interpreted
            as a Unix shell command. More details at
            `shlex.split`.
        input: the input data for `sys.stdin`.
        env: the environment overrides.
        catch_exceptions: Whether to catch any other exceptions than
            ``SystemExit``.

    Returns: `Result` object with results of the invocation.
    """
    exc_info = None
    with self.isolation(input=input, env=env) as outstreams:
        return_value = None
        exception: BaseException | None = None
        exit_code = 0

        if isinstance(args, str):
            args = shlex.split(args)

        try:
            prog_name = extra.pop("prog_name")
        except KeyError:
            prog_name = self.get_default_prog_name(cli)

        # set up sys.argv properly with the arguments
        call_args = args or []
        old_argv = sys.argv
        sys.argv = [prog_name, *call_args]
        try:
            return_value = cli()
        except SystemExit as e:
            exc_info = sys.exc_info()
            e_code = t.cast("int | t.Any | None", e.code)

            if e_code is None:
                e_code = 0

            if e_code != 0:
                exception = e

            if not isinstance(e_code, int):
                sys.stdout.write(str(e_code))
                sys.stdout.write("\n")
                e_code = 1

            exit_code = e_code

        except Exception as e:
            if not catch_exceptions:
                raise
            exception = e
            exit_code = 1
            exc_info = sys.exc_info()
        finally:
            sys.argv = old_argv
            sys.stdout.flush()
            sys.stderr.flush()
            stdout = outstreams[0].getvalue()
            stderr = outstreams[1].getvalue()
            output = outstreams[2].getvalue()

    return Result(
        runner=self,
        stdout_bytes=stdout,
        stderr_bytes=stderr,
        output_bytes=output,
        return_value=return_value,
        exit_code=exit_code,
        exception=exception,
        exc_info=exc_info,  # type: ignore
    )

isolated_filesystem(temp_dir=None)

A context manager that creates a temporary directory and changes the current working directory to it. This isolates tests that affect the contents of the CWD to prevent them from interfering with each other.

Parameters:

Name Type Description Default
temp_dir str | PathLike[str] | None

Create the temporary directory under this directory. If given, the created directory is not removed when exiting.

None
Source code in clirunner/testing.py
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
@contextlib.contextmanager
def isolated_filesystem(
    self, temp_dir: str | os.PathLike[str] | None = None
) -> cabc.Iterator[str]:
    """A context manager that creates a temporary directory and
    changes the current working directory to it. This isolates tests
    that affect the contents of the CWD to prevent them from
    interfering with each other.

    Args:
        temp_dir: Create the temporary directory under this
            directory. If given, the created directory is not removed
            when exiting.
    """
    cwd = os.getcwd()
    dt = tempfile.mkdtemp(dir=temp_dir)
    os.chdir(dt)

    try:
        yield dt
    finally:
        os.chdir(cwd)

        if temp_dir is None:
            try:
                shutil.rmtree(dt)
            except OSError:  # noqa: B014
                pass

isolation(input=None, env=None)

A context manager that sets up the isolation for invoking of a command line tool. This sets up <stdin> with the given input data and os.environ with the overrides from the given dictionary.

This is automatically done in the invoke method.

Parameters:

Name Type Description Default
input str | bytes | IO[Any] | None

the input stream to put into sys.stdin.

None
env Mapping[str, str | None] | None

the environment overrides as dictionary.

None
Source code in clirunner/testing.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
@contextlib.contextmanager
def isolation(
    self,
    input: str | bytes | t.IO[t.Any] | None = None,
    env: cabc.Mapping[str, str | None] | None = None,
    # color: bool = False,
) -> cabc.Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]:
    """A context manager that sets up the isolation for invoking of a
    command line tool.  This sets up `<stdin>` with the given input data
    and `os.environ` with the overrides from the given dictionary.

    This is automatically done in the `invoke` method.

    Args:
        input: the input stream to put into `sys.stdin`.
        env: the environment overrides as dictionary.
    """
    # TODO: I don't think we need this color parameter as that is Click specific

    bytes_input = make_input_stream(input, self.charset)
    echo_input = None

    old_stdin = sys.stdin
    old_stdout = sys.stdout
    old_stderr = sys.stderr

    env = self.make_env(env)

    stream_mixer = StreamMixer()

    if self.echo_stdin:
        bytes_input = echo_input = t.cast(
            t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout)
        )

    sys.stdin = text_input = _NamedTextIOWrapper(
        bytes_input, encoding=self.charset, name="<stdin>", mode="r"
    )

    if self.echo_stdin:
        # Force unbuffered reads, otherwise TextIOWrapper reads a
        # large chunk which is echoed early.
        text_input._CHUNK_SIZE = 1  # type: ignore

    sys.stdout = _NamedTextIOWrapper(
        stream_mixer.stdout, encoding=self.charset, name="<stdout>", mode="w"
    )

    sys.stderr = _NamedTextIOWrapper(
        stream_mixer.stderr,
        encoding=self.charset,
        name="<stderr>",
        mode="w",
        errors="backslashreplace",
    )

    # default_color = color

    # def should_strip_ansi(
    #     stream: t.IO[t.Any] | None = None, color: bool | None = None
    # ) -> bool:
    #     if color is None:
    #         return not default_color
    #     return not color

    # old_should_strip_ansi = utils.should_strip_ansi  # type: ignore
    # utils.should_strip_ansi = should_strip_ansi  # type: ignore

    old_env = {}
    try:
        for key, value in env.items():
            old_env[key] = os.environ.get(key)
            if value is None:
                try:
                    del os.environ[key]
                except Exception:
                    pass
            else:
                os.environ[key] = value
        yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output)
    finally:
        for key, value in old_env.items():
            if value is None:
                try:
                    del os.environ[key]
                except Exception:
                    pass
            else:
                os.environ[key] = value
        sys.stdout = old_stdout
        sys.stderr = old_stderr
        sys.stdin = old_stdin

make_env(overrides=None)

Returns the environment overrides for invoking a script.

Source code in clirunner/testing.py
225
226
227
228
229
230
231
232
def make_env(
    self, overrides: cabc.Mapping[str, str | None] | None = None
) -> cabc.Mapping[str, str | None]:
    """Returns the environment overrides for invoking a script."""
    rv = dict(self.env)
    if overrides:
        rv.update(overrides)
    return rv