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

The maximum number of replacements that will be made with a single test replacement.

DEFAULT_ASSERTION_FUNC_NAME: str = 'edq.testing.asserts.content_equals_normalize'
BASE_TEMP_DIR_ATTR: str = '_edq_cli_base_test_dir'
class CLITestInfo:
 52class CLITestInfo:
 53    """ The required information to run a CLI test. """
 54
 55    def __init__(self,
 56            test_name: str,
 57            base_dir: str,
 58            data_dir: str,
 59            temp_dir: str,
 60            cli: typing.Union[str, None] = None,
 61            arguments: typing.Union[typing.List[str], None] = None,
 62            error: bool = False,
 63            platform_skip: typing.Union[str, None] = None,
 64            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 65            stderr_assertion_func: typing.Union[str, None] = None,
 66            expected_stdout: str = '',
 67            expected_stderr: str = '',
 68            split_stdout_stderr: bool = False,
 69            strip_error_output: bool = True,
 70            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 71            **kwargs: typing.Any) -> None:
 72        self.skip_reasons: typing.List[str] = []
 73        """
 74        Reasons that this test will be skipped.
 75        Any entries in this list indicate that the test should be skipped.
 76        """
 77
 78        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
 79        """
 80        A pattern to check if the test should be skipped on the current platform.
 81        Will be used in `re.search()` against `sys.platform`.
 82        """
 83
 84        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
 85            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
 86
 87        self.test_name: str = test_name
 88        """ The name of this test. """
 89
 90        self.base_dir: str = base_dir
 91        """
 92        The base directory for this test (usually the dir the CLI test file lives.
 93        This is the expansion for `__BASE_DIR__` paths.
 94        """
 95
 96        self.data_dir: str = data_dir
 97        """
 98        A directory that additional testing data lives in.
 99        This is the expansion for `__DATA_DIR__` paths.
100        """
101
102        self.temp_dir: str = temp_dir
103        """
104        A temp directory that this test has access to.
105        This is the expansion for `__TEMP_DIR__` paths.
106        """
107
108        edq.util.dirent.mkdir(temp_dir)
109
110        if (cli is None):
111            raise ValueError("Missing CLI module.")
112
113        self.module_name: str = cli
114        """ The name of the module to invoke. """
115
116        self.module: typing.Any = None
117        """ The module to invoke. """
118
119        if (not self.should_skip()):
120            self.module = edq.util.pyimport.import_name(self.module_name)
121
122        if (arguments is None):
123            arguments = []
124
125        self.arguments: typing.List[str] = arguments
126        """ The CLI arguments. """
127
128        self.error: bool = error
129        """ Whether or not this test is expected to be an error (raise an exception). """
130
131        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
132        """ The assertion func to compare the expected and actual stdout of the CLI. """
133
134        if ((stdout_assertion_func is not None) and (not self.should_skip())):
135            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
136
137        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
138        """ The assertion func to compare the expected and actual stderr of the CLI. """
139
140        if ((stderr_assertion_func is not None) and (not self.should_skip())):
141            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
142
143        self.expected_stdout: str = expected_stdout
144        """ The expected stdout. """
145
146        self.expected_stderr: str = expected_stderr
147        """ The expected stderr. """
148
149        if (error and strip_error_output):
150            self.expected_stdout = self.expected_stdout.strip()
151            self.expected_stderr = self.expected_stderr.strip()
152
153        self.split_stdout_stderr: bool = split_stdout_stderr
154        """
155        Split stdout and stderr into different strings for testing.
156        By default, these two will be combined.
157        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
158        Otherwise, only the non-empty one will be present with no separator.
159        Any stdout assertions will be applied to the combined text.
160        """
161
162        # Make any path normalizations over the arguments and expected output.
163        self.expected_stdout = self._expand_paths(self.expected_stdout)
164        self.expected_stderr = self._expand_paths(self.expected_stderr)
165        for (i, argument) in enumerate(self.arguments):
166            self.arguments[i] = self._expand_paths(argument)
167
168        if (extra_options is None):
169            extra_options = {}
170
171        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
172        """
173        A place to store additional options.
174        Extra top-level options will cause tests to error.
175        """
176
177        if (len(kwargs) > 0):
178            raise ValueError(f"Found unknown CLI test options: '{kwargs}'.")
179
180    def _expand_paths(self, text: str) -> str:
181        """
182        Expand path replacements in testing text.
183        This allows for consistent paths (even absolute paths) in the test text.
184        """
185
186        replacements = [
187            (DATA_DIR_ID, self.data_dir, False),
188            (TEMP_DIR_ID, self.temp_dir, False),
189            (BASE_DIR_ID, self.base_dir, False),
190            (ABS_DATA_DIR_ID, self.data_dir, True),
191        ]
192
193        for (key, target_dir, normalize) in replacements:
194            text = replace_path_pattern(text, key, target_dir, normalize_path = normalize)
195
196        return text
197
198    def should_skip(self) -> bool:
199        """ Check if this test should be skipped. """
200
201        return (len(self.skip_reasons) > 0)
202
203    def skip_message(self) -> str:
204        """ Get a message displaying the reasons this test should be skipped. """
205
206        return f"This test has been skipped because of the following: {self.skip_reasons}."
207
208    @staticmethod
209    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
210        """ Load a CLI test file and extract the test info. """
211
212        options, expected_stdout = read_test_file(path)
213
214        options['expected_stdout'] = expected_stdout
215
216        base_dir = os.path.dirname(os.path.abspath(path))
217        temp_dir = os.path.join(base_temp_dir, test_name)
218
219        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)
 55    def __init__(self,
 56            test_name: str,
 57            base_dir: str,
 58            data_dir: str,
 59            temp_dir: str,
 60            cli: typing.Union[str, None] = None,
 61            arguments: typing.Union[typing.List[str], None] = None,
 62            error: bool = False,
 63            platform_skip: typing.Union[str, None] = None,
 64            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 65            stderr_assertion_func: typing.Union[str, None] = None,
 66            expected_stdout: str = '',
 67            expected_stderr: str = '',
 68            split_stdout_stderr: bool = False,
 69            strip_error_output: bool = True,
 70            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 71            **kwargs: typing.Any) -> None:
 72        self.skip_reasons: typing.List[str] = []
 73        """
 74        Reasons that this test will be skipped.
 75        Any entries in this list indicate that the test should be skipped.
 76        """
 77
 78        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
 79        """
 80        A pattern to check if the test should be skipped on the current platform.
 81        Will be used in `re.search()` against `sys.platform`.
 82        """
 83
 84        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
 85            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
 86
 87        self.test_name: str = test_name
 88        """ The name of this test. """
 89
 90        self.base_dir: str = base_dir
 91        """
 92        The base directory for this test (usually the dir the CLI test file lives.
 93        This is the expansion for `__BASE_DIR__` paths.
 94        """
 95
 96        self.data_dir: str = data_dir
 97        """
 98        A directory that additional testing data lives in.
 99        This is the expansion for `__DATA_DIR__` paths.
100        """
101
102        self.temp_dir: str = temp_dir
103        """
104        A temp directory that this test has access to.
105        This is the expansion for `__TEMP_DIR__` paths.
106        """
107
108        edq.util.dirent.mkdir(temp_dir)
109
110        if (cli is None):
111            raise ValueError("Missing CLI module.")
112
113        self.module_name: str = cli
114        """ The name of the module to invoke. """
115
116        self.module: typing.Any = None
117        """ The module to invoke. """
118
119        if (not self.should_skip()):
120            self.module = edq.util.pyimport.import_name(self.module_name)
121
122        if (arguments is None):
123            arguments = []
124
125        self.arguments: typing.List[str] = arguments
126        """ The CLI arguments. """
127
128        self.error: bool = error
129        """ Whether or not this test is expected to be an error (raise an exception). """
130
131        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
132        """ The assertion func to compare the expected and actual stdout of the CLI. """
133
134        if ((stdout_assertion_func is not None) and (not self.should_skip())):
135            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
136
137        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
138        """ The assertion func to compare the expected and actual stderr of the CLI. """
139
140        if ((stderr_assertion_func is not None) and (not self.should_skip())):
141            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
142
143        self.expected_stdout: str = expected_stdout
144        """ The expected stdout. """
145
146        self.expected_stderr: str = expected_stderr
147        """ The expected stderr. """
148
149        if (error and strip_error_output):
150            self.expected_stdout = self.expected_stdout.strip()
151            self.expected_stderr = self.expected_stderr.strip()
152
153        self.split_stdout_stderr: bool = split_stdout_stderr
154        """
155        Split stdout and stderr into different strings for testing.
156        By default, these two will be combined.
157        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
158        Otherwise, only the non-empty one will be present with no separator.
159        Any stdout assertions will be applied to the combined text.
160        """
161
162        # Make any path normalizations over the arguments and expected output.
163        self.expected_stdout = self._expand_paths(self.expected_stdout)
164        self.expected_stderr = self._expand_paths(self.expected_stderr)
165        for (i, argument) in enumerate(self.arguments):
166            self.arguments[i] = self._expand_paths(argument)
167
168        if (extra_options is None):
169            extra_options = {}
170
171        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
172        """
173        A place to store additional options.
174        Extra top-level options will cause tests to error.
175        """
176
177        if (len(kwargs) > 0):
178            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:
198    def should_skip(self) -> bool:
199        """ Check if this test should be skipped. """
200
201        return (len(self.skip_reasons) > 0)

Check if this test should be skipped.

def skip_message(self) -> str:
203    def skip_message(self) -> str:
204        """ Get a message displaying the reasons this test should be skipped. """
205
206        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:
208    @staticmethod
209    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
210        """ Load a CLI test file and extract the test info. """
211
212        options, expected_stdout = read_test_file(path)
213
214        options['expected_stdout'] = expected_stdout
215
216        base_dir = os.path.dirname(os.path.abspath(path))
217        temp_dir = os.path.join(base_temp_dir, test_name)
218
219        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):
221@typing.runtime_checkable
222class TestMethodWrapperFunction(typing.Protocol):
223    """
224    A function that can be used to wrap/modify a CLI test method before it is attached to the test class.
225    """
226
227    def __call__(self,
228            test_method: typing.Callable,
229            test_info_path: str,
230            ) -> typing.Callable:
231        """
232        Wrap and/or modify the CLI test method before it is attached to the test class.
233        See _get_test_method() for the input method.
234        The returned method will be used in-place of the input one.
235        """

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]:
237def read_test_file(path: str) -> typing.Tuple[typing.Dict[str, typing.Any], str]:
238    """ Read a test case file and split the output into JSON data and text. """
239
240    json_lines: typing.List[str] = []
241    output_lines: typing.List[str] = []
242
243    text = edq.util.dirent.read_file(path, strip = False)
244
245    accumulator = json_lines
246    switched_accumulator = False
247
248    for line in text.split("\n"):
249        if ((not switched_accumulator) and (line.strip() == TEST_CASE_SEP)):
250            accumulator = output_lines
251            switched_accumulator = True
252            continue
253
254        accumulator.append(line)
255
256    options = edq.util.json.loads(''.join(json_lines))
257    output = "\n".join(output_lines)
258
259    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, normalize_path: bool = False) -> str:
261def replace_path_pattern(text: str, key: str, target_dir: str, normalize_path: bool = False) -> str:
262    """ Make any test replacement inside the given string. """
263
264    for _ in range(REPLACE_LIMIT):
265        match = re.search(rf'{key}\(([^)]*)\)', text)
266        if (match is None):
267            break
268
269        filename = match.group(1)
270
271        # Normalize any path separators.
272        filename = os.path.join(*filename.split('/'))
273
274        if (filename == ''):
275            path = target_dir
276        else:
277            path = os.path.join(target_dir, filename)
278
279        if (normalize_path):
280            path = os.path.abspath(path)
281
282        text = text.replace(match.group(0), path)
283
284    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:
343def add_test_paths(target_class: type, data_dir: str, paths: typing.List[str],
344        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
345    """ Add tests from the given test files. """
346
347    # Attach a temp directory to the testing class so all tests can share a common base temp dir.
348    if (not hasattr(target_class, BASE_TEMP_DIR_ATTR)):
349        setattr(target_class, BASE_TEMP_DIR_ATTR, edq.util.dirent.get_temp_path('edq_cli_test_'))
350
351    for path in sorted(paths):
352        basename = os.path.splitext(os.path.basename(path))[0]
353        if (hasattr(target_class, 'get_test_basename')):
354            basename = getattr(target_class, 'get_test_basename')(path)
355
356        test_name = 'test_cli__' + basename
357
358        try:
359            test_method = _get_test_method(test_name, path, data_dir)
360        except Exception as ex:
361            raise ValueError(f"Failed to parse test case '{path}'.") from ex
362
363        if (test_method_wrapper is not None):
364            test_method = test_method_wrapper(test_method, path)
365
366        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:
368def discover_test_cases(target_class: type, test_cases_dir: str, data_dir: str,
369        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
370    """ Look in the text cases directory for any test cases and add them as test methods to the test class. """
371
372    paths = list(sorted(glob.glob(os.path.join(test_cases_dir, "**", "*.txt"), recursive = True)))
373    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.