edq.testing.cli

Infrastructure for testing CLI tools using a JSON file which describes a test case, which is essentially an invocation of a CLI tool and the expected output.

The test case file must be a .txt file that live in the test cases dir. The file contains two parts (separated by a line with just TEST_CASE_SEP): the first part which is a JSON object (see below for available keys), and a second part which is the expected text output (stdout). For the keys of the JSON section, see the defaulted arguments to CLITestInfo. The options JSON will be splatted into CLITestInfo's constructor.

If a test class implements a method with the signature modify_cli_test_info(self, test_info: CLITestInfo) -> None, then this method will be called with the test info right after the test info is read from disk.

If a test class implements a class method with the signature get_test_basename(cls, path: str) -> str, then this method will be called to create the base name for the test case at the given path.

The expected output or any argument can reference the test's current temp or data dirs with __TEMP_DIR__() or __DATA_DIR__(), respectively. An optional slash-separated path can be used as an argument to reference a path within those base directories. For example, __DATA_DIR__(foo/bar.txt) references bar.txt inside the foo directory inside the data directory.

  1"""
  2Infrastructure for testing CLI tools using a JSON file which describes a test case,
  3which is essentially an invocation of a CLI tool and the expected output.
  4
  5The test case file must be a `.txt` file that live in the test cases dir.
  6The file contains two parts (separated by a line with just TEST_CASE_SEP):
  7the first part which is a JSON object (see below for available keys),
  8and a second part which is the expected text output (stdout).
  9For the keys of the JSON section, see the defaulted arguments to CLITestInfo.
 10The options JSON will be splatted into CLITestInfo's constructor.
 11
 12If a test class implements a method with the signature `modify_cli_test_info(self, test_info: CLITestInfo) -> None`,
 13then this method will be called with the test info right after the test info is read from disk.
 14
 15If a test class implements a class method with the signature `get_test_basename(cls, path: str) -> str`,
 16then this method will be called to create the base name for the test case at the given path.
 17
 18The expected output or any argument can reference the test's current temp or data dirs with `__TEMP_DIR__()` or `__DATA_DIR__()`, respectively.
 19An optional slash-separated path can be used as an argument to reference a path within those base directories.
 20For example, `__DATA_DIR__(foo/bar.txt)` references `bar.txt` inside the `foo` directory inside the data directory.
 21"""
 22
 23import contextlib
 24import glob
 25import io
 26import os
 27import re
 28import sys
 29import typing
 30
 31import edq.testing.asserts
 32import edq.testing.unittest
 33import edq.util.dirent
 34import edq.util.json
 35import edq.util.pyimport
 36
 37TEST_CASE_SEP: str = '---'
 38OUTPUT_SEP: str = '+++'
 39DATA_DIR_ID: str = '__DATA_DIR__'
 40ABS_DATA_DIR_ID: str = '__ABS_DATA_DIR__'
 41TEMP_DIR_ID: str = '__TEMP_DIR__'
 42BASE_DIR_ID: str = '__BASE_DIR__'
 43
 44DEFAULT_ASSERTION_FUNC_NAME: str = 'edq.testing.asserts.content_equals_normalize'
 45
 46BASE_TEMP_DIR_ATTR: str = '_edq_cli_base_test_dir'
 47
 48class CLITestInfo:
 49    """ The required information to run a CLI test. """
 50
 51    def __init__(self,
 52            test_name: str,
 53            base_dir: str,
 54            data_dir: str,
 55            temp_dir: str,
 56            cli: typing.Union[str, None] = None,
 57            arguments: typing.Union[typing.List[str], None] = None,
 58            error: bool = False,
 59            platform_skip: typing.Union[str, None] = None,
 60            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 61            stderr_assertion_func: typing.Union[str, None] = None,
 62            expected_stdout: str = '',
 63            expected_stderr: str = '',
 64            split_stdout_stderr: bool = False,
 65            strip_error_output: bool = True,
 66            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 67            **kwargs: typing.Any) -> None:
 68        self.skip_reasons: typing.List[str] = []
 69        """
 70        Reasons that this test will be skipped.
 71        Any entries in this list indicate that the test should be skipped.
 72        """
 73
 74        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
 75        """
 76        A pattern to check if the test should be skipped on the current platform.
 77        Will be used in `re.search()` against `sys.platform`.
 78        """
 79
 80        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
 81            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
 82
 83        self.test_name: str = test_name
 84        """ The name of this test. """
 85
 86        self.base_dir: str = base_dir
 87        """
 88        The base directory for this test (usually the dir the CLI test file lives.
 89        This is the expansion for `__BASE_DIR__` paths.
 90        """
 91
 92        self.data_dir: str = data_dir
 93        """
 94        A directory that additional testing data lives in.
 95        This is the expansion for `__DATA_DIR__` paths.
 96        """
 97
 98        self.temp_dir: str = temp_dir
 99        """
100        A temp directory that this test has access to.
101        This is the expansion for `__TEMP_DIR__` paths.
102        """
103
104        edq.util.dirent.mkdir(temp_dir)
105
106        if (cli is None):
107            raise ValueError("Missing CLI module.")
108
109        self.module_name: str = cli
110        """ The name of the module to invoke. """
111
112        self.module: typing.Any = None
113        """ The module to invoke. """
114
115        if (not self.should_skip()):
116            self.module = edq.util.pyimport.import_name(self.module_name)
117
118        if (arguments is None):
119            arguments = []
120
121        self.arguments: typing.List[str] = arguments
122        """ The CLI arguments. """
123
124        self.error: bool = error
125        """ Whether or not this test is expected to be an error (raise an exception). """
126
127        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
128        """ The assertion func to compare the expected and actual stdout of the CLI. """
129
130        if ((stdout_assertion_func is not None) and (not self.should_skip())):
131            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
132
133        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
134        """ The assertion func to compare the expected and actual stderr of the CLI. """
135
136        if ((stderr_assertion_func is not None) and (not self.should_skip())):
137            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
138
139        self.expected_stdout: str = expected_stdout
140        """ The expected stdout. """
141
142        self.expected_stderr: str = expected_stderr
143        """ The expected stderr. """
144
145        if (error and strip_error_output):
146            self.expected_stdout = self.expected_stdout.strip()
147            self.expected_stderr = self.expected_stderr.strip()
148
149        self.split_stdout_stderr: bool = split_stdout_stderr
150        """
151        Split stdout and stderr into different strings for testing.
152        By default, these two will be combined.
153        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
154        Otherwise, only the non-empty one will be present with no separator.
155        Any stdout assertions will be applied to the combined text.
156        """
157
158        # Make any path normalizations over the arguments and expected output.
159        self.expected_stdout = self._expand_paths(self.expected_stdout)
160        self.expected_stderr = self._expand_paths(self.expected_stderr)
161        for (i, argument) in enumerate(self.arguments):
162            self.arguments[i] = self._expand_paths(argument)
163
164        if (extra_options is None):
165            extra_options = {}
166
167        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
168        """
169        A place to store additional options.
170        Extra top-level options will cause tests to error.
171        """
172
173        if (len(kwargs) > 0):
174            raise ValueError(f"Found unknown CLI test options: '{kwargs}'.")
175
176    def _expand_paths(self, text: str) -> str:
177        """
178        Expand path replacements in testing text.
179        This allows for consistent paths (even absolute paths) in the test text.
180        """
181
182        replacements = [
183            (DATA_DIR_ID, self.data_dir),
184            (TEMP_DIR_ID, self.temp_dir),
185            (BASE_DIR_ID, self.base_dir),
186            (ABS_DATA_DIR_ID, os.path.abspath(self.data_dir)),
187        ]
188
189        for (key, target_dir) in replacements:
190            text = replace_path_pattern(text, key, target_dir)
191
192        return text
193
194    def should_skip(self) -> bool:
195        """ Check if this test should be skipped. """
196
197        return (len(self.skip_reasons) > 0)
198
199    def skip_message(self) -> str:
200        """ Get a message displaying the reasons this test should be skipped. """
201
202        return f"This test has been skipped because of the following: {self.skip_reasons}."
203
204    @staticmethod
205    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
206        """ Load a CLI test file and extract the test info. """
207
208        options, expected_stdout = read_test_file(path)
209
210        options['expected_stdout'] = expected_stdout
211
212        base_dir = os.path.dirname(os.path.abspath(path))
213        temp_dir = os.path.join(base_temp_dir, test_name)
214
215        return CLITestInfo(test_name, base_dir, data_dir, temp_dir, **options)
216
217@typing.runtime_checkable
218class TestMethodWrapperFunction(typing.Protocol):
219    """
220    A function that can be used to wrap/modify a CLI test method before it is attached to the test class.
221    """
222
223    def __call__(self,
224            test_method: typing.Callable,
225            test_info_path: str,
226            ) -> typing.Callable:
227        """
228        Wrap and/or modify the CLI test method before it is attached to the test class.
229        See _get_test_method() for the input method.
230        The returned method will be used in-place of the input one.
231        """
232
233def read_test_file(path: str) -> typing.Tuple[typing.Dict[str, typing.Any], str]:
234    """ Read a test case file and split the output into JSON data and text. """
235
236    json_lines: typing.List[str] = []
237    output_lines: typing.List[str] = []
238
239    text = edq.util.dirent.read_file(path, strip = False)
240
241    accumulator = json_lines
242    for line in text.split("\n"):
243        if (line.strip() == TEST_CASE_SEP):
244            accumulator = output_lines
245            continue
246
247        accumulator.append(line)
248
249    options = edq.util.json.loads(''.join(json_lines))
250    output = "\n".join(output_lines)
251
252    return options, output
253
254def replace_path_pattern(text: str, key: str, target_dir: str) -> str:
255    """ Make any test replacement inside the given string. """
256
257    match = re.search(rf'{key}\(([^)]*)\)', text)
258    if (match is not None):
259        filename = match.group(1)
260
261        # Normalize any path separators.
262        filename = os.path.join(*filename.split('/'))
263
264        if (filename == ''):
265            path = target_dir
266        else:
267            path = os.path.join(target_dir, filename)
268
269        text = text.replace(match.group(0), path)
270
271    return text
272
273def _get_test_method(test_name: str, path: str, data_dir: str) -> typing.Callable:
274    """ Get a test method that represents the test case at the given path. """
275
276    def __method(self: edq.testing.unittest.BaseTest,
277            reraise_exception_types: typing.Union[typing.Tuple[typing.Type], None] = None,
278            **kwargs: typing.Any) -> None:
279        test_info = CLITestInfo.load_path(path, test_name, getattr(self, BASE_TEMP_DIR_ATTR), data_dir)
280
281        # Allow the test class a chance to modify the test info before the test runs.
282        if (hasattr(self, 'modify_cli_test_info')):
283            self.modify_cli_test_info(test_info)
284
285        if (test_info.should_skip()):
286            self.skipTest(test_info.skip_message())
287
288        old_args = sys.argv
289        sys.argv = [test_info.module.__file__] + test_info.arguments
290
291        try:
292            with contextlib.redirect_stdout(io.StringIO()) as stdout_output:
293                with contextlib.redirect_stderr(io.StringIO()) as stderr_output:
294                    test_info.module.main()
295
296            stdout_text = stdout_output.getvalue()
297            stderr_text = stderr_output.getvalue()
298
299            if (test_info.error):
300                self.fail(f"No error was not raised when one was expected ('{str(test_info.expected_stdout)}').")
301        except BaseException as ex:
302            if ((reraise_exception_types is not None) and isinstance(ex, reraise_exception_types)):
303                raise ex
304
305            if (not test_info.error):
306                raise ex
307
308            stdout_text = self.format_error_string(ex)
309
310            stderr_text = ''
311            if (isinstance(ex, SystemExit) and (ex.__context__ is not None)):
312                stderr_text = self.format_error_string(ex.__context__)
313        finally:
314            sys.argv = old_args
315
316        if (not test_info.split_stdout_stderr):
317            if ((len(stdout_text) > 0) and (len(stderr_text) > 0)):
318                stdout_text = f"{stdout_text}\n{OUTPUT_SEP}\n{stderr_text}"
319            elif (len(stderr_text) > 0):
320                stdout_text = stderr_text
321
322        if (test_info.stdout_assertion_func is not None):
323            test_info.stdout_assertion_func(self, test_info.expected_stdout, stdout_text)
324
325        if (test_info.stderr_assertion_func is not None):
326            test_info.stderr_assertion_func(self, test_info.expected_stderr, stderr_text)
327
328    return __method
329
330def add_test_paths(target_class: type, data_dir: str, paths: typing.List[str],
331        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
332    """ Add tests from the given test files. """
333
334    # Attach a temp directory to the testing class so all tests can share a common base temp dir.
335    if (not hasattr(target_class, BASE_TEMP_DIR_ATTR)):
336        setattr(target_class, BASE_TEMP_DIR_ATTR, edq.util.dirent.get_temp_path('edq_cli_test_'))
337
338    for path in sorted(paths):
339        basename = os.path.splitext(os.path.basename(path))[0]
340        if (hasattr(target_class, 'get_test_basename')):
341            basename = getattr(target_class, 'get_test_basename')(path)
342
343        test_name = 'test_cli__' + basename
344
345        try:
346            test_method = _get_test_method(test_name, path, data_dir)
347        except Exception as ex:
348            raise ValueError(f"Failed to parse test case '{path}'.") from ex
349
350        if (test_method_wrapper is not None):
351            test_method = test_method_wrapper(test_method, path)
352
353        setattr(target_class, test_name, test_method)
354
355def discover_test_cases(target_class: type, test_cases_dir: str, data_dir: str,
356        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
357    """ Look in the text cases directory for any test cases and add them as test methods to the test class. """
358
359    paths = list(sorted(glob.glob(os.path.join(test_cases_dir, "**", "*.txt"), recursive = True)))
360    add_test_paths(target_class, data_dir, paths, test_method_wrapper = test_method_wrapper)
TEST_CASE_SEP: str = '---'
OUTPUT_SEP: str = '+++'
DATA_DIR_ID: str = '__DATA_DIR__'
ABS_DATA_DIR_ID: str = '__ABS_DATA_DIR__'
TEMP_DIR_ID: str = '__TEMP_DIR__'
BASE_DIR_ID: str = '__BASE_DIR__'
DEFAULT_ASSERTION_FUNC_NAME: str = 'edq.testing.asserts.content_equals_normalize'
BASE_TEMP_DIR_ATTR: str = '_edq_cli_base_test_dir'
class CLITestInfo:
 49class CLITestInfo:
 50    """ The required information to run a CLI test. """
 51
 52    def __init__(self,
 53            test_name: str,
 54            base_dir: str,
 55            data_dir: str,
 56            temp_dir: str,
 57            cli: typing.Union[str, None] = None,
 58            arguments: typing.Union[typing.List[str], None] = None,
 59            error: bool = False,
 60            platform_skip: typing.Union[str, None] = None,
 61            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 62            stderr_assertion_func: typing.Union[str, None] = None,
 63            expected_stdout: str = '',
 64            expected_stderr: str = '',
 65            split_stdout_stderr: bool = False,
 66            strip_error_output: bool = True,
 67            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 68            **kwargs: typing.Any) -> None:
 69        self.skip_reasons: typing.List[str] = []
 70        """
 71        Reasons that this test will be skipped.
 72        Any entries in this list indicate that the test should be skipped.
 73        """
 74
 75        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
 76        """
 77        A pattern to check if the test should be skipped on the current platform.
 78        Will be used in `re.search()` against `sys.platform`.
 79        """
 80
 81        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
 82            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
 83
 84        self.test_name: str = test_name
 85        """ The name of this test. """
 86
 87        self.base_dir: str = base_dir
 88        """
 89        The base directory for this test (usually the dir the CLI test file lives.
 90        This is the expansion for `__BASE_DIR__` paths.
 91        """
 92
 93        self.data_dir: str = data_dir
 94        """
 95        A directory that additional testing data lives in.
 96        This is the expansion for `__DATA_DIR__` paths.
 97        """
 98
 99        self.temp_dir: str = temp_dir
100        """
101        A temp directory that this test has access to.
102        This is the expansion for `__TEMP_DIR__` paths.
103        """
104
105        edq.util.dirent.mkdir(temp_dir)
106
107        if (cli is None):
108            raise ValueError("Missing CLI module.")
109
110        self.module_name: str = cli
111        """ The name of the module to invoke. """
112
113        self.module: typing.Any = None
114        """ The module to invoke. """
115
116        if (not self.should_skip()):
117            self.module = edq.util.pyimport.import_name(self.module_name)
118
119        if (arguments is None):
120            arguments = []
121
122        self.arguments: typing.List[str] = arguments
123        """ The CLI arguments. """
124
125        self.error: bool = error
126        """ Whether or not this test is expected to be an error (raise an exception). """
127
128        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
129        """ The assertion func to compare the expected and actual stdout of the CLI. """
130
131        if ((stdout_assertion_func is not None) and (not self.should_skip())):
132            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
133
134        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
135        """ The assertion func to compare the expected and actual stderr of the CLI. """
136
137        if ((stderr_assertion_func is not None) and (not self.should_skip())):
138            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
139
140        self.expected_stdout: str = expected_stdout
141        """ The expected stdout. """
142
143        self.expected_stderr: str = expected_stderr
144        """ The expected stderr. """
145
146        if (error and strip_error_output):
147            self.expected_stdout = self.expected_stdout.strip()
148            self.expected_stderr = self.expected_stderr.strip()
149
150        self.split_stdout_stderr: bool = split_stdout_stderr
151        """
152        Split stdout and stderr into different strings for testing.
153        By default, these two will be combined.
154        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
155        Otherwise, only the non-empty one will be present with no separator.
156        Any stdout assertions will be applied to the combined text.
157        """
158
159        # Make any path normalizations over the arguments and expected output.
160        self.expected_stdout = self._expand_paths(self.expected_stdout)
161        self.expected_stderr = self._expand_paths(self.expected_stderr)
162        for (i, argument) in enumerate(self.arguments):
163            self.arguments[i] = self._expand_paths(argument)
164
165        if (extra_options is None):
166            extra_options = {}
167
168        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
169        """
170        A place to store additional options.
171        Extra top-level options will cause tests to error.
172        """
173
174        if (len(kwargs) > 0):
175            raise ValueError(f"Found unknown CLI test options: '{kwargs}'.")
176
177    def _expand_paths(self, text: str) -> str:
178        """
179        Expand path replacements in testing text.
180        This allows for consistent paths (even absolute paths) in the test text.
181        """
182
183        replacements = [
184            (DATA_DIR_ID, self.data_dir),
185            (TEMP_DIR_ID, self.temp_dir),
186            (BASE_DIR_ID, self.base_dir),
187            (ABS_DATA_DIR_ID, os.path.abspath(self.data_dir)),
188        ]
189
190        for (key, target_dir) in replacements:
191            text = replace_path_pattern(text, key, target_dir)
192
193        return text
194
195    def should_skip(self) -> bool:
196        """ Check if this test should be skipped. """
197
198        return (len(self.skip_reasons) > 0)
199
200    def skip_message(self) -> str:
201        """ Get a message displaying the reasons this test should be skipped. """
202
203        return f"This test has been skipped because of the following: {self.skip_reasons}."
204
205    @staticmethod
206    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
207        """ Load a CLI test file and extract the test info. """
208
209        options, expected_stdout = read_test_file(path)
210
211        options['expected_stdout'] = expected_stdout
212
213        base_dir = os.path.dirname(os.path.abspath(path))
214        temp_dir = os.path.join(base_temp_dir, test_name)
215
216        return CLITestInfo(test_name, base_dir, data_dir, temp_dir, **options)

The required information to run a CLI test.

CLITestInfo( test_name: str, base_dir: str, data_dir: str, temp_dir: str, cli: Optional[str] = None, arguments: Optional[List[str]] = None, error: bool = False, platform_skip: Optional[str] = None, stdout_assertion_func: Optional[str] = 'edq.testing.asserts.content_equals_normalize', stderr_assertion_func: Optional[str] = None, expected_stdout: str = '', expected_stderr: str = '', split_stdout_stderr: bool = False, strip_error_output: bool = True, extra_options: Optional[Dict[str, Any]] = None, **kwargs: Any)
 52    def __init__(self,
 53            test_name: str,
 54            base_dir: str,
 55            data_dir: str,
 56            temp_dir: str,
 57            cli: typing.Union[str, None] = None,
 58            arguments: typing.Union[typing.List[str], None] = None,
 59            error: bool = False,
 60            platform_skip: typing.Union[str, None] = None,
 61            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 62            stderr_assertion_func: typing.Union[str, None] = None,
 63            expected_stdout: str = '',
 64            expected_stderr: str = '',
 65            split_stdout_stderr: bool = False,
 66            strip_error_output: bool = True,
 67            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 68            **kwargs: typing.Any) -> None:
 69        self.skip_reasons: typing.List[str] = []
 70        """
 71        Reasons that this test will be skipped.
 72        Any entries in this list indicate that the test should be skipped.
 73        """
 74
 75        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
 76        """
 77        A pattern to check if the test should be skipped on the current platform.
 78        Will be used in `re.search()` against `sys.platform`.
 79        """
 80
 81        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
 82            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
 83
 84        self.test_name: str = test_name
 85        """ The name of this test. """
 86
 87        self.base_dir: str = base_dir
 88        """
 89        The base directory for this test (usually the dir the CLI test file lives.
 90        This is the expansion for `__BASE_DIR__` paths.
 91        """
 92
 93        self.data_dir: str = data_dir
 94        """
 95        A directory that additional testing data lives in.
 96        This is the expansion for `__DATA_DIR__` paths.
 97        """
 98
 99        self.temp_dir: str = temp_dir
100        """
101        A temp directory that this test has access to.
102        This is the expansion for `__TEMP_DIR__` paths.
103        """
104
105        edq.util.dirent.mkdir(temp_dir)
106
107        if (cli is None):
108            raise ValueError("Missing CLI module.")
109
110        self.module_name: str = cli
111        """ The name of the module to invoke. """
112
113        self.module: typing.Any = None
114        """ The module to invoke. """
115
116        if (not self.should_skip()):
117            self.module = edq.util.pyimport.import_name(self.module_name)
118
119        if (arguments is None):
120            arguments = []
121
122        self.arguments: typing.List[str] = arguments
123        """ The CLI arguments. """
124
125        self.error: bool = error
126        """ Whether or not this test is expected to be an error (raise an exception). """
127
128        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
129        """ The assertion func to compare the expected and actual stdout of the CLI. """
130
131        if ((stdout_assertion_func is not None) and (not self.should_skip())):
132            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
133
134        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
135        """ The assertion func to compare the expected and actual stderr of the CLI. """
136
137        if ((stderr_assertion_func is not None) and (not self.should_skip())):
138            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
139
140        self.expected_stdout: str = expected_stdout
141        """ The expected stdout. """
142
143        self.expected_stderr: str = expected_stderr
144        """ The expected stderr. """
145
146        if (error and strip_error_output):
147            self.expected_stdout = self.expected_stdout.strip()
148            self.expected_stderr = self.expected_stderr.strip()
149
150        self.split_stdout_stderr: bool = split_stdout_stderr
151        """
152        Split stdout and stderr into different strings for testing.
153        By default, these two will be combined.
154        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
155        Otherwise, only the non-empty one will be present with no separator.
156        Any stdout assertions will be applied to the combined text.
157        """
158
159        # Make any path normalizations over the arguments and expected output.
160        self.expected_stdout = self._expand_paths(self.expected_stdout)
161        self.expected_stderr = self._expand_paths(self.expected_stderr)
162        for (i, argument) in enumerate(self.arguments):
163            self.arguments[i] = self._expand_paths(argument)
164
165        if (extra_options is None):
166            extra_options = {}
167
168        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
169        """
170        A place to store additional options.
171        Extra top-level options will cause tests to error.
172        """
173
174        if (len(kwargs) > 0):
175            raise ValueError(f"Found unknown CLI test options: '{kwargs}'.")
skip_reasons: List[str]

Reasons that this test will be skipped. Any entries in this list indicate that the test should be skipped.

platform_skip_pattern: Optional[str]

A pattern to check if the test should be skipped on the current platform. Will be used in re.search() against sys.platform.

test_name: str

The name of this test.

base_dir: str

The base directory for this test (usually the dir the CLI test file lives. This is the expansion for __BASE_DIR__ paths.

data_dir: str

A directory that additional testing data lives in. This is the expansion for __DATA_DIR__ paths.

temp_dir: str

A temp directory that this test has access to. This is the expansion for __TEMP_DIR__ paths.

module_name: str

The name of the module to invoke.

module: Any

The module to invoke.

arguments: List[str]

The CLI arguments.

error: bool

Whether or not this test is expected to be an error (raise an exception).

stdout_assertion_func: Optional[edq.testing.asserts.StringComparisonAssertion]

The assertion func to compare the expected and actual stdout of the CLI.

stderr_assertion_func: Optional[edq.testing.asserts.StringComparisonAssertion]

The assertion func to compare the expected and actual stderr of the CLI.

expected_stdout: str

The expected stdout.

expected_stderr: str

The expected stderr.

split_stdout_stderr: bool

Split stdout and stderr into different strings for testing. By default, these two will be combined. If both are non-empty, then they will be joined like: f"{stdout} {OUTPUT_SEP} {stderr}". Otherwise, only the non-empty one will be present with no separator. Any stdout assertions will be applied to the combined text.

extra_options: Optional[Dict[str, Any]]

A place to store additional options. Extra top-level options will cause tests to error.

def should_skip(self) -> bool:
195    def should_skip(self) -> bool:
196        """ Check if this test should be skipped. """
197
198        return (len(self.skip_reasons) > 0)

Check if this test should be skipped.

def skip_message(self) -> str:
200    def skip_message(self) -> str:
201        """ Get a message displaying the reasons this test should be skipped. """
202
203        return f"This test has been skipped because of the following: {self.skip_reasons}."

Get a message displaying the reasons this test should be skipped.

@staticmethod
def load_path( path: str, test_name: str, base_temp_dir: str, data_dir: str) -> CLITestInfo:
205    @staticmethod
206    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
207        """ Load a CLI test file and extract the test info. """
208
209        options, expected_stdout = read_test_file(path)
210
211        options['expected_stdout'] = expected_stdout
212
213        base_dir = os.path.dirname(os.path.abspath(path))
214        temp_dir = os.path.join(base_temp_dir, test_name)
215
216        return CLITestInfo(test_name, base_dir, data_dir, temp_dir, **options)

Load a CLI test file and extract the test info.

@typing.runtime_checkable
class TestMethodWrapperFunction(typing.Protocol):
218@typing.runtime_checkable
219class TestMethodWrapperFunction(typing.Protocol):
220    """
221    A function that can be used to wrap/modify a CLI test method before it is attached to the test class.
222    """
223
224    def __call__(self,
225            test_method: typing.Callable,
226            test_info_path: str,
227            ) -> typing.Callable:
228        """
229        Wrap and/or modify the CLI test method before it is attached to the test class.
230        See _get_test_method() for the input method.
231        The returned method will be used in-place of the input one.
232        """

A function that can be used to wrap/modify a CLI test method before it is attached to the test class.

TestMethodWrapperFunction(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
def read_test_file(path: str) -> Tuple[Dict[str, Any], str]:
234def read_test_file(path: str) -> typing.Tuple[typing.Dict[str, typing.Any], str]:
235    """ Read a test case file and split the output into JSON data and text. """
236
237    json_lines: typing.List[str] = []
238    output_lines: typing.List[str] = []
239
240    text = edq.util.dirent.read_file(path, strip = False)
241
242    accumulator = json_lines
243    for line in text.split("\n"):
244        if (line.strip() == TEST_CASE_SEP):
245            accumulator = output_lines
246            continue
247
248        accumulator.append(line)
249
250    options = edq.util.json.loads(''.join(json_lines))
251    output = "\n".join(output_lines)
252
253    return options, output

Read a test case file and split the output into JSON data and text.

def replace_path_pattern(text: str, key: str, target_dir: str) -> str:
255def replace_path_pattern(text: str, key: str, target_dir: str) -> str:
256    """ Make any test replacement inside the given string. """
257
258    match = re.search(rf'{key}\(([^)]*)\)', text)
259    if (match is not None):
260        filename = match.group(1)
261
262        # Normalize any path separators.
263        filename = os.path.join(*filename.split('/'))
264
265        if (filename == ''):
266            path = target_dir
267        else:
268            path = os.path.join(target_dir, filename)
269
270        text = text.replace(match.group(0), path)
271
272    return text

Make any test replacement inside the given string.

def add_test_paths( target_class: type, data_dir: str, paths: List[str], test_method_wrapper: Optional[TestMethodWrapperFunction] = None) -> None:
331def add_test_paths(target_class: type, data_dir: str, paths: typing.List[str],
332        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
333    """ Add tests from the given test files. """
334
335    # Attach a temp directory to the testing class so all tests can share a common base temp dir.
336    if (not hasattr(target_class, BASE_TEMP_DIR_ATTR)):
337        setattr(target_class, BASE_TEMP_DIR_ATTR, edq.util.dirent.get_temp_path('edq_cli_test_'))
338
339    for path in sorted(paths):
340        basename = os.path.splitext(os.path.basename(path))[0]
341        if (hasattr(target_class, 'get_test_basename')):
342            basename = getattr(target_class, 'get_test_basename')(path)
343
344        test_name = 'test_cli__' + basename
345
346        try:
347            test_method = _get_test_method(test_name, path, data_dir)
348        except Exception as ex:
349            raise ValueError(f"Failed to parse test case '{path}'.") from ex
350
351        if (test_method_wrapper is not None):
352            test_method = test_method_wrapper(test_method, path)
353
354        setattr(target_class, test_name, test_method)

Add tests from the given test files.

def discover_test_cases( target_class: type, test_cases_dir: str, data_dir: str, test_method_wrapper: Optional[TestMethodWrapperFunction] = None) -> None:
356def discover_test_cases(target_class: type, test_cases_dir: str, data_dir: str,
357        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
358    """ Look in the text cases directory for any test cases and add them as test methods to the test class. """
359
360    paths = list(sorted(glob.glob(os.path.join(test_cases_dir, "**", "*.txt"), recursive = True)))
361    add_test_paths(target_class, data_dir, paths, test_method_wrapper = test_method_wrapper)

Look in the text cases directory for any test cases and add them as test methods to the test class.