From 716ddf21a007250f470fab91434d8e5bd9861ecf Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Wed, 10 Dec 2025 19:43:18 +0100 Subject: [PATCH 1/3] Add context manager for changing directories temporarily --- cli_test_helpers/__init__.py | 3 ++- cli_test_helpers/decorators.py | 20 ++++++++++++++++++++ pyproject.toml | 1 + tests/test_decorators.py | 19 ++++++++++++++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cli_test_helpers/__init__.py b/cli_test_helpers/__init__.py index d3af124..d4ad522 100644 --- a/cli_test_helpers/__init__.py +++ b/cli_test_helpers/__init__.py @@ -5,8 +5,9 @@ __all__ = [ "ArgvContext", "EnvironContext", + "RandomDirectoryContext", "shell", ] from .commands import shell -from .decorators import ArgvContext, EnvironContext +from .decorators import ArgvContext, EnvironContext, RandomDirectoryContext diff --git a/cli_test_helpers/decorators.py b/cli_test_helpers/decorators.py index 919a141..1875b78 100644 --- a/cli_test_helpers/decorators.py +++ b/cli_test_helpers/decorators.py @@ -3,7 +3,9 @@ """ import contextlib +import os import sys +from tempfile import TemporaryDirectory from unittest.mock import patch __all__ = [] @@ -50,3 +52,21 @@ def __enter__(self): for key in self.clear_variables: with contextlib.suppress(KeyError): self.in_dict.pop(key) + + +class RandomDirectoryContext(TemporaryDirectory): + """ + Change the execution directory to a random location, temporarily. + """ + + def __enter__(self): + """Create a temporary directory and ``cd`` into it.""" + self.__prev_dir = os.getcwd() + super().__enter__() + os.chdir(self.name) + return self.name + + def __exit__(self, exc_type, exc_value, traceback): + """Return to the original directory before execution.""" + os.chdir(self.__prev_dir) + return super().__exit__(exc_type, exc_value, traceback) diff --git a/pyproject.toml b/pyproject.toml index f155f42..ff66327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ extend-ignore = [ "D203", "D205", "D212", + "PTH109", "Q000", ] diff --git a/tests/test_decorators.py b/tests/test_decorators.py index d904e8b..f221ef3 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -3,7 +3,7 @@ import os import sys -from cli_test_helpers import ArgvContext, EnvironContext +from cli_test_helpers import ArgvContext, EnvironContext, RandomDirectoryContext def test_argv_context(): @@ -45,3 +45,20 @@ def test_environ_context(): assert os.environ == old_environ, "object os.environ was not restored" assert os.getenv("PATH") == old_path, "env var PATH was not restored" assert os.getenv("FOO") is None, "env var FOO was not cleared" + + +def test_random_directory_context(): + """ + In a directory context, are we effectively in a different location? + """ + before_dir = os.getcwd() + + with RandomDirectoryContext() as random_dir: + new_dir = os.getcwd() + + assert new_dir == random_dir, "Does't behave like TemporaryDirectory" + assert new_dir != before_dir, "Context not in a different file system location" + + after_dir = os.getcwd() + + assert after_dir == before_dir, "Execution directory not restored to original" From 25c3331ecf2ba1aa7af9705ddcff1a286725b8cd Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Wed, 10 Dec 2025 23:26:06 +0100 Subject: [PATCH 2/3] Add API docs chapter, document RandomDirectoryContext --- cli_test_helpers/decorators.py | 10 ++++++++++ docs/api.rst | 21 +++++++++++++++++++++ docs/conf.py | 7 ++++--- docs/index.rst | 1 + docs/tutorial.rst | 25 ++++++++++++++++++++----- 5 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 docs/api.rst diff --git a/cli_test_helpers/decorators.py b/cli_test_helpers/decorators.py index 1875b78..9ffc5a0 100644 --- a/cli_test_helpers/decorators.py +++ b/cli_test_helpers/decorators.py @@ -14,6 +14,10 @@ class ArgvContext: """ A simple context manager allowing to temporarily override ``sys.argv``. + + Use it to mimic the command line arguments of the CLI application. + Note that the first argument (index ``0``) is always the script or + application name. """ def __init__(self, *new_args): @@ -57,6 +61,12 @@ def __enter__(self): class RandomDirectoryContext(TemporaryDirectory): """ Change the execution directory to a random location, temporarily. + + Keyword arguments are optional and identical to the ones of + `tempfile.TemporaryDirectory`_ of the Python standard library. + + .. _tempfile.TemporaryDirectory: + https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryDirectory """ def __enter__(self): diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..f3c6499 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,21 @@ +API Documentation +================= + +This section describes the context managers and helper functions provided +by the CLI test helpers package. + +.. module:: cli_test_helpers + +Context Managers +---------------- + +.. autoclass:: ArgvContext + +.. autoclass:: EnvironContext + +.. autoclass:: RandomDirectoryContext + +Utilities +--------- + +.. autofunction:: shell diff --git a/docs/conf.py b/docs/conf.py index 1f06592..0568457 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,9 +10,9 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- @@ -28,6 +28,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + 'sphinx.ext.autodoc', 'sphinx.ext.extlinks', 'sphinx.ext.viewcode', ] diff --git a/docs/index.rst b/docs/index.rst index 49fe44c..160ab2f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ Contents installation tutorial + api other techniques contributing diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8048c63..f27eb17 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -36,8 +36,8 @@ Start with a simple set of functional tests: installed) - Is command XYZ available? etc. Cover your entire CLI usage here! -This is almost a stupid exercise: Run the command as a shell command -and inspect the exit code of the exiting process, e.g. +This is almost a stupid exercise: Run the command as a :func:`~cli_test_helpers.shell` +command and inspect the exit code of the exiting process, e.g. .. code-block:: python @@ -67,7 +67,8 @@ Then you're ready to take advantage of our helpers. ``ArgvContext`` +++++++++++++++ -``ArgvContext`` allows you to mimic the use of specific CLI arguments: +:class:`~cli_test_helpers.ArgvContext` allows you to mimic the use of +specific CLI arguments: .. code-block:: python @@ -96,8 +97,8 @@ See more |example code (argparse-cli)|_. ``EnvironContext`` ++++++++++++++++++ -``EnvironContext`` allows you to mimic the presence (or absence) of -environment variables: +:class:`~cli_test_helpers.EnvironContext` allows you to mimic the presence +(or absence) of environment variables: .. code-block:: python @@ -112,6 +113,20 @@ environment variables: See more |example code (click-command)|_. +``RandomDirectoryContext`` +++++++++++++++++++++++++++ + +:class:`~cli_test_helpers.RandomDirectoryContext` allows you to verify that +your CLI program logic is independent of where it is executed in the file +system: + +.. code-block:: python + + def test_load_configfile(): + """Must not fail when executed anywhere in the filesystem.""" + with ArgvContext('foobar', 'load'), RandomDirectoryContext(): + foobar.cli.main() + .. |example code (argparse-cli)| replace:: example code .. |example code (click-cli)| replace:: example code From b61356409b16aa84264bc26b25e4092b90838de6 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Sat, 13 Dec 2025 01:34:18 +0100 Subject: [PATCH 3/3] Make directory context manager more robust The test failed on macOS runners previously, due to an unresolved symbolic link --- cli_test_helpers/decorators.py | 2 +- tests/test_decorators.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli_test_helpers/decorators.py b/cli_test_helpers/decorators.py index 9ffc5a0..0cda18e 100644 --- a/cli_test_helpers/decorators.py +++ b/cli_test_helpers/decorators.py @@ -74,7 +74,7 @@ def __enter__(self): self.__prev_dir = os.getcwd() super().__enter__() os.chdir(self.name) - return self.name + return os.getcwd() def __exit__(self, exc_type, exc_value, traceback): """Return to the original directory before execution.""" diff --git a/tests/test_decorators.py b/tests/test_decorators.py index f221ef3..025ae7b 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -56,7 +56,7 @@ def test_random_directory_context(): with RandomDirectoryContext() as random_dir: new_dir = os.getcwd() - assert new_dir == random_dir, "Does't behave like TemporaryDirectory" + assert new_dir == random_dir, "Doesn't behave like TemporaryDirectory" assert new_dir != before_dir, "Context not in a different file system location" after_dir = os.getcwd()