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)
The maximum number of replacements that will be made with a single test replacement.
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.
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}'.")
Reasons that this test will be skipped. Any entries in this list indicate that the test should be skipped.
A pattern to check if the test should be skipped on the current platform.
Will be used in re.search() against sys.platform.
The base directory for this test (usually the dir the CLI test file lives.
This is the expansion for __BASE_DIR__ paths.
A directory that additional testing data lives in.
This is the expansion for __DATA_DIR__ paths.
A temp directory that this test has access to.
This is the expansion for __TEMP_DIR__ paths.
The assertion func to compare the expected and actual stdout of the CLI.
The assertion func to compare the expected and actual stderr of the CLI.
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.
A place to store additional options. Extra top-level options will cause tests to error.
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.
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.
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.
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.
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)
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.
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.
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.
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.