From 09e33f977ad884c25a668e14e8fa4ec3bb3ede21 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 27 Aug 2021 22:51:04 -0700 Subject: [PATCH 1/4] Add testing info to docs --- README.md | 114 +++++++++++++++++++++++++++++++++++++++++- pydantic_cli/py.typed | 0 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 pydantic_cli/py.typed diff --git a/README.md b/README.md index 6d24557..0df51c2 100644 --- a/README.md +++ b/README.md @@ -590,9 +590,121 @@ See [shtab](https://github.com/iterative/shtab) for more details. Note, that due to the (typically) global zsh completions directory, this can create some friction points with different virtual (or conda) ENVS with the same executable name. +# General Suggested Testing Model + +At a high level, `pydantic_cli` is (hopefully) a thin bridge between your `Options` defined as a Pydantic model and your +main `runner(opts: Options)` func that has hooks into the startup, shutdown and error handling of the command line tool. +It also supports loading config files defined as JSON. By design, `pydantic_cli` explicitly doesn't expose, or leak the argparse instance +because it would add too much surface area and it would enable users' to start mucking with the argparse instance in all kinds of unexpected ways. +The use of `argparse` internally is an hidden implementation detail. + +Testing can be done by leveraging the `to_runner` interface. + + + +1. It's recommend trying to do the majority of testing via unit tests (independent of `pydantic_cli`) with your main function and different instances of your pydantic data model. +2. Once this test coverage is reasonable, it can be useful to add a few smoke tests at the integration level leveraging `to_runner` to make sure the tool is functional. Any bugs at this level are probably at the `pydantic_cli` level, not your library code. + +Note, that `to_runner(Opts, my_main)` returns a `Callable[[List[str]], int]` that can be used with `argv` to return an integer exit code of your program. The `to_runner` layer will also catch any exceptions. + +```python +import unittest + +from pydantic import BaseModel +from pydantic_cli import to_runner + + +class Options(BaseModel): + alpha: int + + +def main(opts: Options) -> int: + if opts.alpha < 0: + raise Exception(f"Got options {opts}. Forced raise for testing.") + return 0 + + +class TestExample(unittest.TestCase): + + def test_core(self): + # Note, this has nothing to do with pydantic_cli + # If possible, this is where the bulk of the testing should be + self.assertEqual(0, main(Options(alpha=1))) + + def test_example(self): + f = to_runner(Options, main) + self.assertEqual(0, f(["--alpha", "100"])) + + def test_expected_error(self): + f = to_runner(Options, main) + self.assertEqual(1, f(["--alpha", "-10"])) +``` + + + +For more scrappy, interactive local development, it can be useful to add `ipdb` or `pdb` and create a custom `exception_handler`. + +```python +import sys +from pydantic import BaseModel +from pydantic_cli import default_exception_handler, run_and_exit + + +class Options(BaseModel): + alpha: int + + +def exception_handler(ex: BaseException) -> int: + exit_code = default_exception_handler(ex) + import ipdb; ipdb.set_trace() + return exit_code + + +def main(opts: Options) -> int: + if opts.alpha < 0: + raise Exception(f"Got options {opts}. Forced raise for testing.") + return 0 + + +if __name__ == "__main__": + run_and_exit(Options, main, exception_handler=exception_handler)(sys.argv[1:]) +``` + +Alternatively, wrap your main function to call `ipdb`. + +```python +import sys + +from pydantic import BaseModel +from pydantic_cli import run_and_exit + + +class Options(BaseModel): + alpha: int + + +def main(opts: Options) -> int: + if opts.alpha < 0: + raise Exception(f"Got options {opts}. Forced raise for testing.") + return 0 + + +def main_with_ipd(opts: Options) -> int: + import ipdb; ipdb.set_trace() + return main(opts) + + +if __name__ == "__main__": + run_and_exit(Options, main_with_ipd)([sys.argv[1:]]) +``` + +The core design choice in `pydantic_cli` is leveraging composable functions `f(g(x))` style providing a straight-forward mechanism to plug into. + # More Examples -[More examples are provided here](https://github.com/mpkocher/pydantic-cli/tree/master/pydantic_cli/examples) +[More examples are provided here](https://github.com/mpkocher/pydantic-cli/tree/master/pydantic_cli/examples) and [Testing Examples can be seen here](https://github.com/mpkocher/pydantic-cli/tree/master/pydantic_cli/tests). + +The [TestHarness](https://github.com/mpkocher/pydantic-cli/blob/master/pydantic_cli/tests/__init__.py) might provide examples of how to test your CLI tool(s) # Limitations diff --git a/pydantic_cli/py.typed b/pydantic_cli/py.typed new file mode 100644 index 0000000..e69de29 From f484ecd6d349e47b3f677c1f85499740601ad40f Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 27 Aug 2021 22:51:33 -0700 Subject: [PATCH 2/4] Skip test if shtab is not installed --- ...est_examples_simple_with_shell_autocomplete_support.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py b/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py index 4af5652..faf6b31 100644 --- a/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py +++ b/pydantic_cli/tests/test_examples_simple_with_shell_autocomplete_support.py @@ -1,3 +1,5 @@ +import unittest + from pydantic_cli.examples.simple_with_shell_autocomplete_support import ( Options, example_runner, @@ -19,12 +21,12 @@ def test_simple_02(self): def _test_auto_complete_shell(self, shell_id): if HAS_AUTOCOMPLETE_SUPPORT: args = ["--emit-completion", shell_id] - else: - args = ["-i", "/path/to/file.txt", "-f", "1.0", "2"] - self.run_config(args) + self.run_config(args) + @unittest.skipIf(not HAS_AUTOCOMPLETE_SUPPORT, "shtab not installed") def test_auto_complete_zsh(self): self._test_auto_complete_shell("zsh") + @unittest.skipIf(not HAS_AUTOCOMPLETE_SUPPORT, "shtab not installed") def test_auto_complete_bash(self): self._test_auto_complete_shell("bash") From b4a5c0d48fd5c3746d8f888b8d058c0d5c060cb3 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 27 Aug 2021 22:52:34 -0700 Subject: [PATCH 3/4] Bump version to 4.2.0 to have py.typed for better mypy support --- pydantic_cli/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_cli/_version.py b/pydantic_cli/_version.py index 7039708..0fd7811 100644 --- a/pydantic_cli/_version.py +++ b/pydantic_cli/_version.py @@ -1 +1 @@ -__version__ = "4.1.0" +__version__ = "4.2.0" From 5ced745e70214a5c10c32f5d8b26b037d3bc682b Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 27 Aug 2021 22:59:22 -0700 Subject: [PATCH 4/4] Explicitly add py.typed to package_data --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 605392c..8753513 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ def get_version(): python_requires=">=3.7", install_requires=_get_requirements("REQUIREMENTS.txt"), packages=['pydantic_cli', 'pydantic_cli.examples'], + package_data={"pydantic_cli": ["py.typed"]}, tests_require=_get_requirements("REQUIREMENTS-TEST.txt"), extras_require={"shtab": "shtab>=1.3.1"}, zip_safe=False,