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. Otherwise, the file's basename will be used.

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

A function used to perform setup or teardown around a CLI test.

SetupTeardownFunction(*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)
class CLITestInfo:
 69class CLITestInfo:
 70    """ The required information to run a CLI test. """
 71
 72    def __init__(self,
 73            test_name: str,
 74            base_dir: str,
 75            data_dir: str,
 76            temp_dir: str,
 77            work_dir: typing.Union[str, None] = None,
 78            setup_func: typing.Union[str, None] = None,
 79            teardown_func: typing.Union[str, None] = None,
 80            cli: typing.Union[str, None] = None,
 81            arguments: typing.Union[typing.List[str], None] = None,
 82            error: bool = False,
 83            platform_skip: typing.Union[str, None] = None,
 84            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 85            stderr_assertion_func: typing.Union[str, None] = None,
 86            expected_stdout: str = '',
 87            expected_stderr: str = '',
 88            split_stdout_stderr: bool = False,
 89            strip_error_output: bool = True,
 90            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 91            **kwargs: typing.Any,
 92            ) -> None:
 93        self.skip_reasons: typing.List[str] = []
 94        """
 95        Reasons that this test will be skipped.
 96        Any entries in this list indicate that the test should be skipped.
 97        """
 98
 99        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
100        """
101        A pattern to check if the test should be skipped on the current platform.
102        Will be used in `re.search()` against `sys.platform`.
103        """
104
105        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
106            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
107
108        self.test_name: str = test_name
109        """ The name of this test. """
110
111        self.base_dir: str = base_dir
112        """
113        The base directory for this test (usually the dir the CLI test file lives.
114        This is the expansion for `__BASE_DIR__` paths.
115        """
116
117        self.data_dir: str = data_dir
118        """
119        A directory that additional testing data lives in.
120        This is the expansion for `__DATA_DIR__` paths.
121        """
122
123        self.temp_dir: str = temp_dir
124        """
125        A temp directory that this test has access to.
126        This is the expansion for `__TEMP_DIR__` paths.
127        """
128
129        edq.util.dirent.mkdir(temp_dir)
130
131        if (work_dir is None):
132            work_dir = os.getcwd()
133        else:
134            work_dir = self._process_text(work_dir)
135
136        self.work_dir: str = work_dir
137        """ The directory the test runs from. """
138
139        self.setup_func: typing.Union[SetupTeardownFunction, None] = None
140        """ The function to run before the test to setup. """
141
142        if (setup_func is not None):
143            self.setup_func = edq.util.pyimport.fetch(setup_func)
144
145        self.teardown_func: typing.Union[SetupTeardownFunction, None] = None
146        """ The function to run after the test to cleanup. """
147
148        if (teardown_func is not None):
149            self.teardown_func = edq.util.pyimport.fetch(teardown_func)
150
151        if (cli is None):
152            raise ValueError("Missing CLI module.")
153
154        self.module_name: str = cli
155        """ The name of the module to invoke. """
156
157        self.module: typing.Any = None
158        """ The module to invoke. """
159
160        if (not self.should_skip()):
161            self.module = edq.util.pyimport.import_name(self.module_name)
162
163        if (arguments is None):
164            arguments = []
165
166        self.arguments: typing.List[str] = arguments
167        """ The CLI arguments. """
168
169        self.error: bool = error
170        """ Whether or not this test is expected to be an error (raise an exception). """
171
172        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
173        """ The assertion func to compare the expected and actual stdout of the CLI. """
174
175        if ((stdout_assertion_func is not None) and (not self.should_skip())):
176            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
177
178        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
179        """ The assertion func to compare the expected and actual stderr of the CLI. """
180
181        if ((stderr_assertion_func is not None) and (not self.should_skip())):
182            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
183
184        self.expected_stdout: str = expected_stdout
185        """ The expected stdout. """
186
187        self.expected_stderr: str = expected_stderr
188        """ The expected stderr. """
189
190        if (error and strip_error_output):
191            self.expected_stdout = self.expected_stdout.strip()
192            self.expected_stderr = self.expected_stderr.strip()
193
194        self.split_stdout_stderr: bool = split_stdout_stderr
195        """
196        Split stdout and stderr into different strings for testing.
197        By default, these two will be combined.
198        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
199        Otherwise, only the non-empty one will be present with no separator.
200        Any stdout assertions will be applied to the combined text.
201        """
202
203        # Make any path normalizations over the arguments and expected output.
204        self.expected_stdout = self._process_text(self.expected_stdout)
205        self.expected_stderr = self._process_text(self.expected_stderr)
206        for (i, argument) in enumerate(self.arguments):
207            self.arguments[i] = self._process_text(argument)
208
209        if (extra_options is None):
210            extra_options = {}
211
212        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
213        """
214        A place to store additional options.
215        Extra top-level options will cause tests to error.
216        """
217
218        if (len(kwargs) > 0):
219            raise ValueError(f"Found unknown CLI test options: '{kwargs}'.")
220
221    def _process_text(self, text: str) -> str:
222        """
223        Process text with and desired replacements.
224
225        This will expand path replacements in testing text.
226        This allows for consistent paths (even absolute paths) in the test text.
227        """
228
229        text_replacements = [
230            (OS_PATH_SEP, os.sep),
231        ]
232
233        for (target, replacement) in text_replacements:
234            text = text.replace(target, replacement)
235
236        path_replacements = [
237            (DATA_DIR_ID, self.data_dir, False),
238            (TEMP_DIR_ID, self.temp_dir, False),
239            (BASE_DIR_ID, self.base_dir, False),
240            (ABS_DATA_DIR_ID, self.data_dir, True),
241        ]
242
243        for (key, target_dir, normalize) in path_replacements:
244            text = replace_path_pattern(text, key, target_dir, normalize_path = normalize)
245
246        return text
247
248    def should_skip(self) -> bool:
249        """ Check if this test should be skipped. """
250
251        return (len(self.skip_reasons) > 0)
252
253    def skip_message(self) -> str:
254        """ Get a message displaying the reasons this test should be skipped. """
255
256        return f"This test has been skipped because of the following: {self.skip_reasons}."
257
258    @staticmethod
259    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
260        """ Load a CLI test file and extract the test info. """
261
262        options, expected_stdout = read_test_file(path)
263
264        options['expected_stdout'] = expected_stdout
265
266        base_dir = os.path.dirname(os.path.abspath(path))
267        temp_dir = os.path.join(base_temp_dir, test_name)
268
269        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, work_dir: Optional[str] = None, setup_func: Optional[str] = None, teardown_func: Optional[str] = None, 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)
 72    def __init__(self,
 73            test_name: str,
 74            base_dir: str,
 75            data_dir: str,
 76            temp_dir: str,
 77            work_dir: typing.Union[str, None] = None,
 78            setup_func: typing.Union[str, None] = None,
 79            teardown_func: typing.Union[str, None] = None,
 80            cli: typing.Union[str, None] = None,
 81            arguments: typing.Union[typing.List[str], None] = None,
 82            error: bool = False,
 83            platform_skip: typing.Union[str, None] = None,
 84            stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
 85            stderr_assertion_func: typing.Union[str, None] = None,
 86            expected_stdout: str = '',
 87            expected_stderr: str = '',
 88            split_stdout_stderr: bool = False,
 89            strip_error_output: bool = True,
 90            extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 91            **kwargs: typing.Any,
 92            ) -> None:
 93        self.skip_reasons: typing.List[str] = []
 94        """
 95        Reasons that this test will be skipped.
 96        Any entries in this list indicate that the test should be skipped.
 97        """
 98
 99        self.platform_skip_pattern: typing.Union[str, None] = platform_skip
100        """
101        A pattern to check if the test should be skipped on the current platform.
102        Will be used in `re.search()` against `sys.platform`.
103        """
104
105        if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
106            self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
107
108        self.test_name: str = test_name
109        """ The name of this test. """
110
111        self.base_dir: str = base_dir
112        """
113        The base directory for this test (usually the dir the CLI test file lives.
114        This is the expansion for `__BASE_DIR__` paths.
115        """
116
117        self.data_dir: str = data_dir
118        """
119        A directory that additional testing data lives in.
120        This is the expansion for `__DATA_DIR__` paths.
121        """
122
123        self.temp_dir: str = temp_dir
124        """
125        A temp directory that this test has access to.
126        This is the expansion for `__TEMP_DIR__` paths.
127        """
128
129        edq.util.dirent.mkdir(temp_dir)
130
131        if (work_dir is None):
132            work_dir = os.getcwd()
133        else:
134            work_dir = self._process_text(work_dir)
135
136        self.work_dir: str = work_dir
137        """ The directory the test runs from. """
138
139        self.setup_func: typing.Union[SetupTeardownFunction, None] = None
140        """ The function to run before the test to setup. """
141
142        if (setup_func is not None):
143            self.setup_func = edq.util.pyimport.fetch(setup_func)
144
145        self.teardown_func: typing.Union[SetupTeardownFunction, None] = None
146        """ The function to run after the test to cleanup. """
147
148        if (teardown_func is not None):
149            self.teardown_func = edq.util.pyimport.fetch(teardown_func)
150
151        if (cli is None):
152            raise ValueError("Missing CLI module.")
153
154        self.module_name: str = cli
155        """ The name of the module to invoke. """
156
157        self.module: typing.Any = None
158        """ The module to invoke. """
159
160        if (not self.should_skip()):
161            self.module = edq.util.pyimport.import_name(self.module_name)
162
163        if (arguments is None):
164            arguments = []
165
166        self.arguments: typing.List[str] = arguments
167        """ The CLI arguments. """
168
169        self.error: bool = error
170        """ Whether or not this test is expected to be an error (raise an exception). """
171
172        self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
173        """ The assertion func to compare the expected and actual stdout of the CLI. """
174
175        if ((stdout_assertion_func is not None) and (not self.should_skip())):
176            self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
177
178        self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
179        """ The assertion func to compare the expected and actual stderr of the CLI. """
180
181        if ((stderr_assertion_func is not None) and (not self.should_skip())):
182            self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
183
184        self.expected_stdout: str = expected_stdout
185        """ The expected stdout. """
186
187        self.expected_stderr: str = expected_stderr
188        """ The expected stderr. """
189
190        if (error and strip_error_output):
191            self.expected_stdout = self.expected_stdout.strip()
192            self.expected_stderr = self.expected_stderr.strip()
193
194        self.split_stdout_stderr: bool = split_stdout_stderr
195        """
196        Split stdout and stderr into different strings for testing.
197        By default, these two will be combined.
198        If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
199        Otherwise, only the non-empty one will be present with no separator.
200        Any stdout assertions will be applied to the combined text.
201        """
202
203        # Make any path normalizations over the arguments and expected output.
204        self.expected_stdout = self._process_text(self.expected_stdout)
205        self.expected_stderr = self._process_text(self.expected_stderr)
206        for (i, argument) in enumerate(self.arguments):
207            self.arguments[i] = self._process_text(argument)
208
209        if (extra_options is None):
210            extra_options = {}
211
212        self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
213        """
214        A place to store additional options.
215        Extra top-level options will cause tests to error.
216        """
217
218        if (len(kwargs) > 0):
219            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.

work_dir: str

The directory the test runs from.

setup_func: Optional[SetupTeardownFunction]

The function to run before the test to setup.

teardown_func: Optional[SetupTeardownFunction]

The function to run after the test to cleanup.

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:
248    def should_skip(self) -> bool:
249        """ Check if this test should be skipped. """
250
251        return (len(self.skip_reasons) > 0)

Check if this test should be skipped.

def skip_message(self) -> str:
253    def skip_message(self) -> str:
254        """ Get a message displaying the reasons this test should be skipped. """
255
256        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:
258    @staticmethod
259    def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
260        """ Load a CLI test file and extract the test info. """
261
262        options, expected_stdout = read_test_file(path)
263
264        options['expected_stdout'] = expected_stdout
265
266        base_dir = os.path.dirname(os.path.abspath(path))
267        temp_dir = os.path.join(base_temp_dir, test_name)
268
269        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):
271@typing.runtime_checkable
272class TestMethodWrapperFunction(typing.Protocol):
273    """
274    A function that can be used to wrap/modify a CLI test method before it is attached to the test class.
275    """
276
277    def __call__(self,
278            test_method: typing.Callable,
279            test_info_path: str,
280            ) -> typing.Callable:
281        """
282        Wrap and/or modify the CLI test method before it is attached to the test class.
283        See _get_test_method() for the input method.
284        The returned method will be used in-place of the input one.
285        """

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]:
287def read_test_file(path: str) -> typing.Tuple[typing.Dict[str, typing.Any], str]:
288    """ Read a test case file and split the output into JSON data and text. """
289
290    json_lines: typing.List[str] = []
291    output_lines: typing.List[str] = []
292
293    text = edq.util.dirent.read_file(path, strip = False)
294
295    accumulator = json_lines
296    switched_accumulator = False
297
298    for line in text.split("\n"):
299        if ((not switched_accumulator) and (line.strip() == TEST_CASE_SEP)):
300            accumulator = output_lines
301            switched_accumulator = True
302            continue
303
304        accumulator.append(line)
305
306    options = edq.util.json.loads(''.join(json_lines))
307    output = "\n".join(output_lines)
308
309    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:
311def replace_path_pattern(text: str, key: str, target_dir: str, normalize_path: bool = False) -> str:
312    """ Make any test replacement inside the given string. """
313
314    for _ in range(REPLACE_LIMIT):
315        match = re.search(rf'{key}\(([^)]*)\)', text)
316        if (match is None):
317            break
318
319        filename = match.group(1)
320
321        # Normalize any path separators.
322        filename = os.path.join(*filename.split('/'))
323
324        if (filename == ''):
325            path = target_dir
326        else:
327            path = os.path.join(target_dir, filename)
328
329        if (normalize_path):
330            path = os.path.abspath(path)
331
332        text = text.replace(match.group(0), path)
333
334    return text

Make any test replacement inside the given string.

def compute_ancestor_basename(path: str, cli_tests_dir: str) -> str:
336def compute_ancestor_basename(path: str, cli_tests_dir: str) -> str:
337    """
338    Get the test's name based off of its filename and location.
339    A useful function to use in get_test_basename().
340    """
341
342    path = os.path.abspath(path)
343
344    name = os.path.splitext(os.path.basename(path))[0]
345
346    # Clean drive identifiers (for Windows).
347    cli_tests_dir_path = os.path.splitdrive(os.path.abspath(cli_tests_dir))[1]
348    path = os.path.splitdrive(path)[1]
349
350    ancestors = os.path.dirname(path).replace(cli_tests_dir_path, '')
351    prefix = ancestors.replace(os.sep, '_')
352
353    if (prefix.startswith('_')):
354        prefix = prefix.replace('_', '', 1)
355
356    if (len(prefix) > 0):
357        name = f"{prefix}_{name}"
358
359    return name

Get the test's name based off of its filename and location. A useful function to use in get_test_basename().

def add_test_paths( target_class: type, data_dir: str, paths: List[str], test_method_wrapper: Optional[TestMethodWrapperFunction] = None) -> None:
430def add_test_paths(target_class: type, data_dir: str, paths: typing.List[str],
431        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
432    """ Add tests from the given test files. """
433
434    # Attach a temp directory to the testing class so all tests can share a common base temp dir.
435    if (not hasattr(target_class, BASE_TEMP_DIR_ATTR)):
436        setattr(target_class, BASE_TEMP_DIR_ATTR, edq.util.dirent.get_temp_path('edq_cli_test_'))
437
438    for path in sorted(paths):
439        basename = os.path.splitext(os.path.basename(path))[0]
440        if (hasattr(target_class, 'get_test_basename')):
441            basename = getattr(target_class, 'get_test_basename')(path)
442
443        test_name = 'test_cli__' + basename
444
445        try:
446            test_method = _get_test_method(test_name, path, data_dir)
447        except Exception as ex:
448            raise ValueError(f"Failed to parse test case '{path}'.") from ex
449
450        if (test_method_wrapper is not None):
451            test_method = test_method_wrapper(test_method, path)
452
453        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:
455def discover_test_cases(target_class: type, test_cases_dir: str, data_dir: str,
456        test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
457    """ Look in the text cases directory for any test cases and add them as test methods to the test class. """
458
459    paths = list(sorted(glob.glob(os.path.join(test_cases_dir, "**", "*.txt"), recursive = True)))
460    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.