clirunner


Nameclirunner JSON
Version 0.0.4 PyPI version JSON
download
home_pageNone
SummaryCliRunner test runner for command line applications.
upload_time2023-12-02 21:48:13
maintainerNone
docs_urlNone
authorNone
requires_pythonNone
licenseNone
keywords
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # CliRunner

A test helper for invoking and testing command line interfaces (CLIs). This is adapted from the [Click](https://click.palletsprojects.com/) [CliRunner](https://click.palletsprojects.com/en/8.1.x/testing/) but modified to work with non-Click scripts, such as those using [argparse](https://docs.python.org/3/library/argparse.html) for parsing command line arguments.

## Installation

`python3 -m pip install clirunner`

## Source Code

The source code is available on [GitHub](https://github.com/RhetTbull/clirunner).

## Motivation

I write a lot of Python command line tools. I usually reach for Click to build the CLI but sometimes will use argparse or even just manual `sys.argv` parsing for simple scripts or where I do not want to introduce a dependency on Click. Click provides a very useful [CliRunner](https://click.palletsprojects.com/en/8.1.x/testing/) for testing CLIs, but it only works with Click applications. This project is a derivative of Click's CliRunner that works with non-Click scripts. The API is the same as Click's CliRunner, so it should be easy to switch between the two if you later refactor to use Click.

## Supported Platforms

Tested on macOS, Ubuntu Linux, and Windows using "*-latest" GitHub Workflow runners with Python 3.9 - 3.12.

## Documentation

Full documentation is available [here](https://rhettbull.github.io/clirunner/).

## Basic Testing

CliRunner can invoke your CLI's main function as a command line script. The CliRunner.invoke() method runs the command line script in isolation and captures the output as both bytes and binary data.

The return value is a Result object, which has the captured output data, exit code, and optional exception attached:

### hello.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/hello.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Simple CLI """

import argparse


def hello():
    """Print Hello World"""
    argp = argparse.ArgumentParser(description="Print Hello World")
    argp.add_argument("-n", "--name", help="Name to greet")
    args = argp.parse_args()
    print(f"Hello {args.name or 'World'}!")


if __name__ == "__main__":
    hello()
```
<!--[[[end]]]-->

### test_hello.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/test_hello.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Test hello.py"""

from hello import hello

from clirunner import CliRunner


def test_hello_world():
    runner = CliRunner()
    result = runner.invoke(hello, ["--name", "Peter"])
    assert result.exit_code == 0
    assert result.output == "Hello Peter!\n"
```
<!--[[[end]]]-->

Note that `result.output` will contain the combined output of `stdout` and `stderr`. If you want to capture `stdout` and `stderr` separately, use `result.stdout` and `result.stderr`.

## File System Isolation

For basic command line tools with file system operations, the `CliRunner.isolated_filesystem()` method is useful for setting the current working directory to a new, empty folder.

### cat.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/cat.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Simple cat program for testing isolated file system"""

import argparse


def cat():
    argp = argparse.ArgumentParser()
    argp.add_argument("file", type=argparse.FileType("r"))
    args = argp.parse_args()
    print(args.file.read(), end="")


if __name__ == "__main__":
    cat()
```
<!--[[[end]]]-->

### test_cat.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/test_cat.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Test cat.py example."""

from cat import cat

from clirunner import CliRunner


def test_cat():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open("hello.txt", "w") as f:
            f.write("Hello World!\n")

        result = runner.invoke(cat, ["hello.txt"])
        assert result.exit_code == 0
        assert result.output == "Hello World!\n"
```
<!--[[[end]]]-->

Pass `temp_dir` to control where the temporary directory is created. The directory will not be removed by `CliRunner` in this case. This is useful to integrate with a framework like Pytest that manages temporary files.

```python
def test_keep_dir(tmp_path):
    runner = CliRunner()

    with runner.isolated_filesystem(temp_dir=tmp_path) as td:
        ...
```

## Input Streams

The test wrapper can also be used to provide input data for the input stream (stdin). This is very useful for testing prompts, for instance:

### prompt.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/prompt.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Simple example for testing input streams"""


def prompt():
    foo = input("Foo: ")
    print(f"foo = {foo}")


if __name__ == "__main__":
    prompt()
```
<!--[[[end]]]-->

### test_prompt.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/test_prompt.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Test prompt.py example"""

from prompt import prompt

from clirunner import CliRunner


def test_prompts():
    runner = CliRunner()
    result = runner.invoke(prompt, input="wau wau\n")
    assert not result.exception
    # note: unlike click.CliRunner, clirunner.CliRunner does not echo the input
    assert "foo = wau wau\n" in result.output
```
<!--[[[end]]]-->

Note that the input will not be echoed to the output stream. This is different from the behavior of the `input()` function, which does echo the input and from click's `prompt()` function, which also echo's the input when under test.

## Environment Variable Isolation

The `CliRunner.invoke()` method can also be used to set environment variables for the command line script. This is useful for testing command line tools that use environment variables for configuration.

### hello_env.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/hello_env.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Say hello to the world, shouting if desired."""

import os


def hello():
    """Say hello to the world, shouting if desired."""
    if os.getenv("SHOUT") == "1":
        print("HELLO WORLD!")
    else:
        print("Hello World!")


if __name__ == "__main__":
    hello()
```
<!--[[[end]]]-->

### test_hello_env.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/test_hello_env.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Test hello2.py showing how to set environment variables for testing."""

from hello_env import hello

from clirunner import CliRunner


def test_hello():
    """Test hello2.py"""
    runner = CliRunner()
    result = runner.invoke(hello)
    assert result.exit_code == 0
    assert result.output == "Hello World!\n"


def test_hello_shouting():
    """Test hello2.py"""
    runner = CliRunner()
    result = runner.invoke(hello, env={"SHOUT": "1"})
    assert result.exit_code == 0
    assert result.output == "HELLO WORLD!\n"
```
<!--[[[end]]]-->

## Handling Exceptions

Normally the `CliRunner.invoke()` method will catch exceptions in the CLI under test. If an exception is raised, it will be available via the `Result.exception` property. This can be disabled by passing `catch_exceptions=False` to the `CliRunner.invoke()` method.

### raise_exception.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/raise_exception.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Simple script that raises an exception"""


def raise_exception():
    """Raises a ValueError exception"""
    raise ValueError("Exception raised")


if __name__ == "__main__":
    raise_exception()
```
<!--[[[end]]]-->

### test_raise_exception.py

<!--[[[cog
cog.out("\n```python\n")
with open("tests/test_raise_exception.py", "r") as f:
    cog.out(f.read())
cog.out("```\n")
]]]-->

```python
"""Test raise_exception.py"""

import pytest
from raise_exception import raise_exception

from clirunner import CliRunner


def test_exception_caught():
    """CliRunner normally catches exceptions"""
    runner = CliRunner()
    result = runner.invoke(raise_exception)
    # exit code will not be 0 if exception is raised
    assert result.exit_code != 0
    assert isinstance(result.exception, ValueError)


def test_exception_not_caught():
    """CliRunner can be configured to not catch exceptions"""
    runner = CliRunner()
    with pytest.raises(ValueError):
        runner.invoke(raise_exception, catch_exceptions=False)
```
<!--[[[end]]]-->

## Testing Click Applications

Do not use `clirunner.CliRunner` to test applications built with [Click](https://pypi.org/project/click/), [Typer](https://pypi.org/project/typer/), or another Click derivative. Instead, use Click's built-in [CliRunner](https://click.palletsprojects.com/en/8.1.x/testing). `clirunner.CliRunner` is only for testing non-Click scripts such as those using [argparse](https://docs.python.org/3/library/argparse.html) or manual [sys.argv](https://docs.python.org/3/library/sys.html#sys.argv) argument parsing.

## License

CliRunner is a derivative work of Click's CliRunner, and so it is licensed under the same BSD 3-clause license as Click. See the [LICENSE](https://github.com/RhetTbull/clirunner/blob/main/LICENSE) and [LICENSE_CLICK](https://github.com/RhetTbull/clirunner/blob/main/LICENSE_CLICK) files for details.

            

Raw data

            {
    "_id": null,
    "home_page": null,
    "name": "clirunner",
    "maintainer": null,
    "docs_url": null,
    "requires_python": null,
    "maintainer_email": null,
    "keywords": null,
    "author": null,
    "author_email": "Rhet Turnbull <rturnbull+git@gmail.com>",
    "download_url": "https://files.pythonhosted.org/packages/d6/94/ae9eec413bf0095b448c53a80f99b6533b937aa85a029aebed05f6135f24/clirunner-0.0.4.tar.gz",
    "platform": null,
    "description": "# CliRunner\n\nA test helper for invoking and testing command line interfaces (CLIs). This is adapted from the [Click](https://click.palletsprojects.com/) [CliRunner](https://click.palletsprojects.com/en/8.1.x/testing/) but modified to work with non-Click scripts, such as those using [argparse](https://docs.python.org/3/library/argparse.html) for parsing command line arguments.\n\n## Installation\n\n`python3 -m pip install clirunner`\n\n## Source Code\n\nThe source code is available on [GitHub](https://github.com/RhetTbull/clirunner).\n\n## Motivation\n\nI write a lot of Python command line tools. I usually reach for Click to build the CLI but sometimes will use argparse or even just manual `sys.argv` parsing for simple scripts or where I do not want to introduce a dependency on Click. Click provides a very useful [CliRunner](https://click.palletsprojects.com/en/8.1.x/testing/) for testing CLIs, but it only works with Click applications. This project is a derivative of Click's CliRunner that works with non-Click scripts. The API is the same as Click's CliRunner, so it should be easy to switch between the two if you later refactor to use Click.\n\n## Supported Platforms\n\nTested on macOS, Ubuntu Linux, and Windows using \"*-latest\" GitHub Workflow runners with Python 3.9 - 3.12.\n\n## Documentation\n\nFull documentation is available [here](https://rhettbull.github.io/clirunner/).\n\n## Basic Testing\n\nCliRunner can invoke your CLI's main function as a command line script. The CliRunner.invoke() method runs the command line script in isolation and captures the output as both bytes and binary data.\n\nThe return value is a Result object, which has the captured output data, exit code, and optional exception attached:\n\n### hello.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/hello.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Simple CLI \"\"\"\n\nimport argparse\n\n\ndef hello():\n    \"\"\"Print Hello World\"\"\"\n    argp = argparse.ArgumentParser(description=\"Print Hello World\")\n    argp.add_argument(\"-n\", \"--name\", help=\"Name to greet\")\n    args = argp.parse_args()\n    print(f\"Hello {args.name or 'World'}!\")\n\n\nif __name__ == \"__main__\":\n    hello()\n```\n<!--[[[end]]]-->\n\n### test_hello.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/test_hello.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Test hello.py\"\"\"\n\nfrom hello import hello\n\nfrom clirunner import CliRunner\n\n\ndef test_hello_world():\n    runner = CliRunner()\n    result = runner.invoke(hello, [\"--name\", \"Peter\"])\n    assert result.exit_code == 0\n    assert result.output == \"Hello Peter!\\n\"\n```\n<!--[[[end]]]-->\n\nNote that `result.output` will contain the combined output of `stdout` and `stderr`. If you want to capture `stdout` and `stderr` separately, use `result.stdout` and `result.stderr`.\n\n## File System Isolation\n\nFor basic command line tools with file system operations, the `CliRunner.isolated_filesystem()` method is useful for setting the current working directory to a new, empty folder.\n\n### cat.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/cat.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Simple cat program for testing isolated file system\"\"\"\n\nimport argparse\n\n\ndef cat():\n    argp = argparse.ArgumentParser()\n    argp.add_argument(\"file\", type=argparse.FileType(\"r\"))\n    args = argp.parse_args()\n    print(args.file.read(), end=\"\")\n\n\nif __name__ == \"__main__\":\n    cat()\n```\n<!--[[[end]]]-->\n\n### test_cat.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/test_cat.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Test cat.py example.\"\"\"\n\nfrom cat import cat\n\nfrom clirunner import CliRunner\n\n\ndef test_cat():\n    runner = CliRunner()\n    with runner.isolated_filesystem():\n        with open(\"hello.txt\", \"w\") as f:\n            f.write(\"Hello World!\\n\")\n\n        result = runner.invoke(cat, [\"hello.txt\"])\n        assert result.exit_code == 0\n        assert result.output == \"Hello World!\\n\"\n```\n<!--[[[end]]]-->\n\nPass `temp_dir` to control where the temporary directory is created. The directory will not be removed by `CliRunner` in this case. This is useful to integrate with a framework like Pytest that manages temporary files.\n\n```python\ndef test_keep_dir(tmp_path):\n    runner = CliRunner()\n\n    with runner.isolated_filesystem(temp_dir=tmp_path) as td:\n        ...\n```\n\n## Input Streams\n\nThe test wrapper can also be used to provide input data for the input stream (stdin). This is very useful for testing prompts, for instance:\n\n### prompt.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/prompt.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Simple example for testing input streams\"\"\"\n\n\ndef prompt():\n    foo = input(\"Foo: \")\n    print(f\"foo = {foo}\")\n\n\nif __name__ == \"__main__\":\n    prompt()\n```\n<!--[[[end]]]-->\n\n### test_prompt.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/test_prompt.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Test prompt.py example\"\"\"\n\nfrom prompt import prompt\n\nfrom clirunner import CliRunner\n\n\ndef test_prompts():\n    runner = CliRunner()\n    result = runner.invoke(prompt, input=\"wau wau\\n\")\n    assert not result.exception\n    # note: unlike click.CliRunner, clirunner.CliRunner does not echo the input\n    assert \"foo = wau wau\\n\" in result.output\n```\n<!--[[[end]]]-->\n\nNote that the input will not be echoed to the output stream. This is different from the behavior of the `input()` function, which does echo the input and from click's `prompt()` function, which also echo's the input when under test.\n\n## Environment Variable Isolation\n\nThe `CliRunner.invoke()` method can also be used to set environment variables for the command line script. This is useful for testing command line tools that use environment variables for configuration.\n\n### hello_env.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/hello_env.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Say hello to the world, shouting if desired.\"\"\"\n\nimport os\n\n\ndef hello():\n    \"\"\"Say hello to the world, shouting if desired.\"\"\"\n    if os.getenv(\"SHOUT\") == \"1\":\n        print(\"HELLO WORLD!\")\n    else:\n        print(\"Hello World!\")\n\n\nif __name__ == \"__main__\":\n    hello()\n```\n<!--[[[end]]]-->\n\n### test_hello_env.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/test_hello_env.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Test hello2.py showing how to set environment variables for testing.\"\"\"\n\nfrom hello_env import hello\n\nfrom clirunner import CliRunner\n\n\ndef test_hello():\n    \"\"\"Test hello2.py\"\"\"\n    runner = CliRunner()\n    result = runner.invoke(hello)\n    assert result.exit_code == 0\n    assert result.output == \"Hello World!\\n\"\n\n\ndef test_hello_shouting():\n    \"\"\"Test hello2.py\"\"\"\n    runner = CliRunner()\n    result = runner.invoke(hello, env={\"SHOUT\": \"1\"})\n    assert result.exit_code == 0\n    assert result.output == \"HELLO WORLD!\\n\"\n```\n<!--[[[end]]]-->\n\n## Handling Exceptions\n\nNormally the `CliRunner.invoke()` method will catch exceptions in the CLI under test. If an exception is raised, it will be available via the `Result.exception` property. This can be disabled by passing `catch_exceptions=False` to the `CliRunner.invoke()` method.\n\n### raise_exception.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/raise_exception.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Simple script that raises an exception\"\"\"\n\n\ndef raise_exception():\n    \"\"\"Raises a ValueError exception\"\"\"\n    raise ValueError(\"Exception raised\")\n\n\nif __name__ == \"__main__\":\n    raise_exception()\n```\n<!--[[[end]]]-->\n\n### test_raise_exception.py\n\n<!--[[[cog\ncog.out(\"\\n```python\\n\")\nwith open(\"tests/test_raise_exception.py\", \"r\") as f:\n    cog.out(f.read())\ncog.out(\"```\\n\")\n]]]-->\n\n```python\n\"\"\"Test raise_exception.py\"\"\"\n\nimport pytest\nfrom raise_exception import raise_exception\n\nfrom clirunner import CliRunner\n\n\ndef test_exception_caught():\n    \"\"\"CliRunner normally catches exceptions\"\"\"\n    runner = CliRunner()\n    result = runner.invoke(raise_exception)\n    # exit code will not be 0 if exception is raised\n    assert result.exit_code != 0\n    assert isinstance(result.exception, ValueError)\n\n\ndef test_exception_not_caught():\n    \"\"\"CliRunner can be configured to not catch exceptions\"\"\"\n    runner = CliRunner()\n    with pytest.raises(ValueError):\n        runner.invoke(raise_exception, catch_exceptions=False)\n```\n<!--[[[end]]]-->\n\n## Testing Click Applications\n\nDo not use `clirunner.CliRunner` to test applications built with [Click](https://pypi.org/project/click/), [Typer](https://pypi.org/project/typer/), or another Click derivative. Instead, use Click's built-in [CliRunner](https://click.palletsprojects.com/en/8.1.x/testing). `clirunner.CliRunner` is only for testing non-Click scripts such as those using [argparse](https://docs.python.org/3/library/argparse.html) or manual [sys.argv](https://docs.python.org/3/library/sys.html#sys.argv) argument parsing.\n\n## License\n\nCliRunner is a derivative work of Click's CliRunner, and so it is licensed under the same BSD 3-clause license as Click. See the [LICENSE](https://github.com/RhetTbull/clirunner/blob/main/LICENSE) and [LICENSE_CLICK](https://github.com/RhetTbull/clirunner/blob/main/LICENSE_CLICK) files for details.\n",
    "bugtrack_url": null,
    "license": null,
    "summary": "CliRunner test runner for command line applications.",
    "version": "0.0.4",
    "project_urls": {
        "Home": "https://github.com/RhetTbull/clirunner",
        "Issues": "https://github.com/RhetTbull/clirunner/issues",
        "Source": "https://github.com/RhetTbull/clirunner"
    },
    "split_keywords": [],
    "urls": [
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "fa7cb6ae54411a1663375ec032b813680b66ebc17b041e04a845c62e18ddbb3d",
                "md5": "8c5664058b9571e548243b511d29b5ed",
                "sha256": "0c7397870ad83570c89db584d9a37899aca3516c802dee81f4ae227977cdbb92"
            },
            "downloads": -1,
            "filename": "clirunner-0.0.4-py2.py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "8c5664058b9571e548243b511d29b5ed",
            "packagetype": "bdist_wheel",
            "python_version": "py2.py3",
            "requires_python": null,
            "size": 24457,
            "upload_time": "2023-12-02T21:48:12",
            "upload_time_iso_8601": "2023-12-02T21:48:12.045910Z",
            "url": "https://files.pythonhosted.org/packages/fa/7c/b6ae54411a1663375ec032b813680b66ebc17b041e04a845c62e18ddbb3d/clirunner-0.0.4-py2.py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "d694ae9eec413bf0095b448c53a80f99b6533b937aa85a029aebed05f6135f24",
                "md5": "f5db2f84f2ce9e7cf73645e0194b2682",
                "sha256": "00a4cd10c71d7ffa3441b092e0962ff426b0f0b26e0c21ea15d4479572ededc0"
            },
            "downloads": -1,
            "filename": "clirunner-0.0.4.tar.gz",
            "has_sig": false,
            "md5_digest": "f5db2f84f2ce9e7cf73645e0194b2682",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": null,
            "size": 29682,
            "upload_time": "2023-12-02T21:48:13",
            "upload_time_iso_8601": "2023-12-02T21:48:13.696440Z",
            "url": "https://files.pythonhosted.org/packages/d6/94/ae9eec413bf0095b448c53a80f99b6533b937aa85a029aebed05f6135f24/clirunner-0.0.4.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2023-12-02 21:48:13",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "RhetTbull",
    "github_project": "clirunner",
    "travis_ci": false,
    "coveralls": false,
    "github_actions": true,
    "lcname": "clirunner"
}
        
Elapsed time: 0.15553s