edq-utils 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of edq-utils might be problematic. Click here for more details.

Files changed (88) hide show
  1. edq/__init__.py +5 -0
  2. edq/cli/__init__.py +0 -0
  3. edq/cli/__main__.py +17 -0
  4. edq/cli/config/__init__.py +3 -0
  5. edq/cli/config/__main__.py +15 -0
  6. edq/cli/config/list.py +69 -0
  7. edq/cli/http/__init__.py +3 -0
  8. edq/cli/http/__main__.py +15 -0
  9. edq/cli/http/exchange-server.py +71 -0
  10. edq/cli/http/send-exchange.py +45 -0
  11. edq/cli/http/verify-exchanges.py +38 -0
  12. edq/cli/testing/__init__.py +3 -0
  13. edq/cli/testing/__main__.py +15 -0
  14. edq/cli/testing/cli-test.py +49 -0
  15. edq/cli/version.py +28 -0
  16. edq/core/__init__.py +0 -0
  17. edq/core/argparser.py +137 -0
  18. edq/core/argparser_test.py +124 -0
  19. edq/core/config.py +268 -0
  20. edq/core/config_test.py +1038 -0
  21. edq/core/log.py +101 -0
  22. edq/core/version.py +6 -0
  23. edq/procedure/__init__.py +0 -0
  24. edq/procedure/verify_exchanges.py +85 -0
  25. edq/py.typed +0 -0
  26. edq/testing/__init__.py +3 -0
  27. edq/testing/asserts.py +65 -0
  28. edq/testing/cli.py +360 -0
  29. edq/testing/cli_test.py +15 -0
  30. edq/testing/httpserver.py +578 -0
  31. edq/testing/httpserver_test.py +424 -0
  32. edq/testing/run.py +142 -0
  33. edq/testing/testdata/cli/data/configs/empty/edq-config.json +1 -0
  34. edq/testing/testdata/cli/data/configs/simple-1/edq-config.json +4 -0
  35. edq/testing/testdata/cli/data/configs/simple-2/edq-config.json +3 -0
  36. edq/testing/testdata/cli/data/configs/value-number/edq-config.json +3 -0
  37. edq/testing/testdata/cli/tests/config/list/config_list_base.txt +16 -0
  38. edq/testing/testdata/cli/tests/config/list/config_list_config_value_number.txt +10 -0
  39. edq/testing/testdata/cli/tests/config/list/config_list_ignore_config.txt +14 -0
  40. edq/testing/testdata/cli/tests/config/list/config_list_no_config.txt +8 -0
  41. edq/testing/testdata/cli/tests/config/list/config_list_show_origin.txt +13 -0
  42. edq/testing/testdata/cli/tests/config/list/config_list_skip_header.txt +10 -0
  43. edq/testing/testdata/cli/tests/help_base.txt +9 -0
  44. edq/testing/testdata/cli/tests/platform_skip.txt +5 -0
  45. edq/testing/testdata/cli/tests/version_base.txt +6 -0
  46. edq/testing/testdata/http/exchanges/simple.httpex.json +5 -0
  47. edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
  48. edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
  49. edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
  50. edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
  51. edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
  52. edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
  53. edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
  54. edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
  55. edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
  56. edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
  57. edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
  58. edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
  59. edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
  60. edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
  61. edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
  62. edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
  63. edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
  64. edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
  65. edq/testing/testdata/http/files/a.txt +1 -0
  66. edq/testing/testdata/http/files/tiny.png +0 -0
  67. edq/testing/unittest.py +88 -0
  68. edq/util/__init__.py +3 -0
  69. edq/util/cli.py +151 -0
  70. edq/util/dirent.py +346 -0
  71. edq/util/dirent_test.py +1004 -0
  72. edq/util/encoding.py +18 -0
  73. edq/util/hash.py +41 -0
  74. edq/util/hash_test.py +89 -0
  75. edq/util/json.py +180 -0
  76. edq/util/json_test.py +228 -0
  77. edq/util/net.py +1047 -0
  78. edq/util/parse.py +33 -0
  79. edq/util/pyimport.py +94 -0
  80. edq/util/pyimport_test.py +119 -0
  81. edq/util/reflection.py +32 -0
  82. edq/util/time.py +75 -0
  83. edq/util/time_test.py +107 -0
  84. edq_utils-0.2.3.dist-info/METADATA +164 -0
  85. edq_utils-0.2.3.dist-info/RECORD +88 -0
  86. edq_utils-0.2.3.dist-info/WHEEL +5 -0
  87. edq_utils-0.2.3.dist-info/licenses/LICENSE +21 -0
  88. edq_utils-0.2.3.dist-info/top_level.txt +1 -0
edq/core/log.py ADDED
@@ -0,0 +1,101 @@
1
+ import argparse
2
+ import logging
3
+ import typing
4
+
5
+ DEFAULT_LOGGING_LEVEL: str = logging.getLevelName(logging.INFO)
6
+ DEFAULT_LOGGING_FORMAT: str = '%(asctime)s [%(levelname)-8s] - %(filename)s:%(lineno)s -- %(message)s'
7
+
8
+ LEVELS: typing.List[str] = [
9
+ 'TRACE',
10
+ logging.getLevelName(logging.DEBUG),
11
+ logging.getLevelName(logging.INFO),
12
+ logging.getLevelName(logging.WARNING),
13
+ logging.getLevelName(logging.ERROR),
14
+ logging.getLevelName(logging.CRITICAL),
15
+ ]
16
+
17
+ def init(level: str = DEFAULT_LOGGING_LEVEL, log_format: str = DEFAULT_LOGGING_FORMAT,
18
+ warn_loggers: typing.Union[typing.List[str], None] = None,
19
+ **kwargs: typing.Any) -> None:
20
+ """
21
+ Initialize or re-initialize the logging infrastructure.
22
+ The list of warning loggers is a list of identifiers for loggers (usually third-party) to move up to warning on init.
23
+ """
24
+
25
+ # Add trace.
26
+ _add_logging_level('TRACE', logging.DEBUG - 5)
27
+
28
+ logging.basicConfig(level = level, format = log_format, force = True)
29
+
30
+ if (warn_loggers is not None):
31
+ for warn_logger in warn_loggers:
32
+ logging.getLogger(warn_logger).setLevel(logging.WARNING)
33
+
34
+ logging.trace("Logging initialized with level '%s'.", level) # type: ignore[attr-defined]
35
+
36
+ def set_cli_args(parser: argparse.ArgumentParser, extra_state: typing.Dict[str, typing.Any]) -> None:
37
+ """
38
+ Set common CLI arguments.
39
+ This is a sibling to init_from_args(), as the arguments set here can be interpreted there.
40
+ """
41
+
42
+ parser.add_argument('--log-level', dest = 'log_level',
43
+ action = 'store', type = str, default = logging.getLevelName(logging.INFO),
44
+ choices = LEVELS,
45
+ help = 'Set the logging level (default: %(default)s).')
46
+
47
+ parser.add_argument('--quiet', dest = 'quiet',
48
+ action = 'store_true', default = False,
49
+ help = 'Set the logging level to warning (overrides --log-level) (default: %(default)s).')
50
+
51
+ parser.add_argument('--debug', dest = 'debug',
52
+ action = 'store_true', default = False,
53
+ help = 'Set the logging level to debug (overrides --log-level and --quiet) (default: %(default)s).')
54
+
55
+ def init_from_args(
56
+ parser: argparse.ArgumentParser,
57
+ args: argparse.Namespace,
58
+ extra_state: typing.Dict[str, typing.Any]) -> None:
59
+ """
60
+ Take in args from a parser that was passed to set_cli_args(),
61
+ and call init() with the appropriate arguments.
62
+ """
63
+
64
+ level = args.log_level
65
+
66
+ if (args.quiet):
67
+ level = logging.getLevelName(logging.WARNING)
68
+
69
+ if (args.debug):
70
+ level = logging.getLevelName(logging.DEBUG)
71
+
72
+ init(level)
73
+
74
+ def _add_logging_level(level_name: str, level_number: int, method_name: typing.Union[str, None] = None) -> None:
75
+ """
76
+ Add a new logging level.
77
+
78
+ See https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 .
79
+ """
80
+
81
+ if (method_name is None):
82
+ method_name = level_name.lower()
83
+
84
+ # Level has already been defined.
85
+ if hasattr(logging, level_name):
86
+ return
87
+
88
+ def log_for_level(self: typing.Any, message: str, *args: typing.Any, **kwargs: typing.Any) -> None:
89
+ if self.isEnabledFor(level_number):
90
+ self._log(level_number, message, args, **kwargs)
91
+
92
+ def log_to_root(message: str, *args: typing.Any, **kwargs: typing.Any) -> None:
93
+ logging.log(level_number, message, *args, **kwargs)
94
+
95
+ logging.addLevelName(level_number, level_name)
96
+ setattr(logging, level_name, level_number)
97
+ setattr(logging.getLoggerClass(), method_name, log_for_level)
98
+ setattr(logging, method_name, log_to_root)
99
+
100
+ # Load the default logging when this module is loaded.
101
+ init()
edq/core/version.py ADDED
@@ -0,0 +1,6 @@
1
+ import edq
2
+
3
+ def get_version() -> str:
4
+ """ Get the version for the EduLinq Python utils. """
5
+
6
+ return edq.__version__
File without changes
@@ -0,0 +1,85 @@
1
+ """
2
+ Verify that exchanges sent to a given server have the same response.
3
+ """
4
+
5
+ import glob
6
+ import logging
7
+ import os
8
+ import typing
9
+ import unittest
10
+
11
+ import edq.testing.unittest
12
+ import edq.util.net
13
+
14
+ class ExchangeVerification(edq.testing.unittest.BaseTest):
15
+ """ Verify that exchanges match their content. """
16
+
17
+ def run(paths: typing.List[str], server: str) -> int:
18
+ """ Run exchange verification. """
19
+
20
+ exchange_paths = _collect_exchange_paths(paths)
21
+
22
+ _attach_tests(exchange_paths, server)
23
+
24
+ runner = unittest.TextTestRunner(verbosity = 2)
25
+ tests = unittest.defaultTestLoader.loadTestsFromTestCase(ExchangeVerification)
26
+ results = runner.run(tests)
27
+
28
+ return len(results.errors) + len(results.failures)
29
+
30
+ def _attach_tests(
31
+ paths: typing.List[str],
32
+ server: str,
33
+ extension: str = edq.util.net.DEFAULT_HTTP_EXCHANGE_EXTENSION,
34
+ ) -> None:
35
+ """ Create tests for each path and attach them to the ExchangeVerification class. """
36
+
37
+ common_prefix = os.path.commonprefix(paths)
38
+
39
+ for path in paths:
40
+ name = path.replace(common_prefix, '').replace(extension, '')
41
+ test_name = f"test_verify_exchange__{name}"
42
+
43
+ setattr(ExchangeVerification, test_name, _get_test_method(path, server))
44
+
45
+ def _get_test_method(path: str, server: str,
46
+ match_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
47
+ ) -> typing.Callable:
48
+ """ Create a test method for the given path. """
49
+
50
+ if (match_options is None):
51
+ match_options = {}
52
+
53
+ def __method(self: edq.testing.unittest.BaseTest) -> None:
54
+ exchange = edq.util.net.HTTPExchange.from_path(path)
55
+ response, body = exchange.make_request(server, raise_for_status = False, **match_options)
56
+
57
+ match, hint = exchange.match_response(response, override_body = body, **match_options)
58
+ if (not match):
59
+ raise AssertionError(f"Exchange does not match: '{hint}'.")
60
+
61
+ return __method
62
+
63
+ def _collect_exchange_paths(
64
+ paths: typing.List[str],
65
+ extension: str = edq.util.net.DEFAULT_HTTP_EXCHANGE_EXTENSION,
66
+ ) -> typing.List[str]:
67
+ """ Collect exchange files by matching extensions and descending dirs. """
68
+
69
+ final_paths = []
70
+
71
+ for path in paths:
72
+ path = os.path.abspath(path)
73
+
74
+ if (os.path.isfile(path)):
75
+ if (path.endswith(extension)):
76
+ final_paths.append(path)
77
+ else:
78
+ logging.warning("Path does not look like an exchange file: '%s'.", path)
79
+ else:
80
+ dirent_paths = glob.glob(os.path.join(path, "**", f"*{extension}"), recursive = True)
81
+ for dirent_path in dirent_paths:
82
+ final_paths.append(dirent_path)
83
+
84
+ final_paths.sort()
85
+ return final_paths
edq/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """
2
+ Testing infrastructure.
3
+ """
edq/testing/asserts.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ More complex testing assertions.
3
+ Often used as output checks in CLI tests.
4
+ """
5
+
6
+ import re
7
+ import typing
8
+
9
+ import edq.testing.unittest
10
+
11
+ TRACEBACK_LINE_REGEX: str = r'^\s*File "[^"]+", line \d+,.*$\n.*$(\n\s*[\^~]+\s*$)?'
12
+ TRACEBACK_LINE_REPLACEMENT: str = '<TRACEBACK_LINE>'
13
+
14
+ TEXT_NORMALIZATIONS: typing.List[typing.Tuple[str, str]] = [
15
+ (r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+ \[\S+ *\] - .*\.py:\d+ -- ', '<LOG_PREFIX> -- '),
16
+ (r'\d+\.\d+ seconds', '<DURATION_SECONDS>'),
17
+ (r'\bv\d+\.\d+\.\d+\b', '<VERSION>'),
18
+ (r'^\s*File "[^"]+", line \d+,.*$\n.*$(\n\s*[\^~]+\s*$)?', '<TRACEBACK_LINE>'),
19
+ (rf'{TRACEBACK_LINE_REPLACEMENT}(\n{TRACEBACK_LINE_REPLACEMENT})*', '<TRACEBACK>'),
20
+ ]
21
+ """
22
+ Normalization to make to the CLI output.
23
+ Formatted as: [(regex, replacement), ...]
24
+ """
25
+
26
+ @typing.runtime_checkable
27
+ class StringComparisonAssertion(typing.Protocol):
28
+ """
29
+ A function that can be used as a comparison assertion for a test.
30
+ """
31
+
32
+ def __call__(self,
33
+ test: edq.testing.unittest.BaseTest,
34
+ expected: str, actual: str,
35
+ **kwargs: typing.Any) -> None:
36
+ """
37
+ Perform an assertion between expected and actual data.
38
+ """
39
+
40
+ def content_equals_raw(test: edq.testing.unittest.BaseTest, expected: str, actual: str, **kwargs: typing.Any) -> None:
41
+ """ Check for equality using a simple string comparison. """
42
+
43
+ test.assertEqual(expected, actual)
44
+
45
+ def content_equals_normalize(test: edq.testing.unittest.BaseTest, expected: str, actual: str, **kwargs: typing.Any) -> None:
46
+ """
47
+ Perform some standard text normalizations (see TEXT_NORMALIZATIONS) before using simple string comparison.
48
+ """
49
+
50
+ for (regex, replacement) in TEXT_NORMALIZATIONS:
51
+ expected = re.sub(regex, replacement, expected, flags = re.MULTILINE)
52
+ actual = re.sub(regex, replacement, actual, flags = re.MULTILINE)
53
+
54
+ content_equals_raw(test, expected, actual)
55
+
56
+ def has_content_100(test: edq.testing.unittest.BaseTest, expected: str, actual: str, **kwargs: typing.Any) -> None:
57
+ """ Check the that output has at least 100 characters. """
58
+
59
+ return has_content(test, expected, actual, min_length = 100)
60
+
61
+ def has_content(test: edq.testing.unittest.BaseTest, expected: str, actual: str, min_length: int = 100) -> None:
62
+ """ Ensure that the output has content of at least some length. """
63
+
64
+ message = f"Output does not meet minimum length of {min_length}, it is only {len(actual)}."
65
+ test.assertTrue((len(actual) >= min_length), msg = message)
edq/testing/cli.py ADDED
@@ -0,0 +1,360 @@
1
+ """
2
+ Infrastructure for testing CLI tools using a JSON file which describes a test case,
3
+ which is essentially an invocation of a CLI tool and the expected output.
4
+
5
+ The test case file must be a `.txt` file that live in the test cases dir.
6
+ The file contains two parts (separated by a line with just TEST_CASE_SEP):
7
+ the first part which is a JSON object (see below for available keys),
8
+ and a second part which is the expected text output (stdout).
9
+ For the keys of the JSON section, see the defaulted arguments to CLITestInfo.
10
+ The options JSON will be splatted into CLITestInfo's constructor.
11
+
12
+ If a test class implements a method with the signature `modify_cli_test_info(self, test_info: CLITestInfo) -> None`,
13
+ then this method will be called with the test info right after the test info is read from disk.
14
+
15
+ If a test class implements a class method with the signature `get_test_basename(cls, path: str) -> str`,
16
+ then this method will be called to create the base name for the test case at the given path.
17
+
18
+ The expected output or any argument can reference the test's current temp or data dirs with `__TEMP_DIR__()` or `__DATA_DIR__()`, respectively.
19
+ An optional slash-separated path can be used as an argument to reference a path within those base directories.
20
+ For example, `__DATA_DIR__(foo/bar.txt)` references `bar.txt` inside the `foo` directory inside the data directory.
21
+ """
22
+
23
+ import contextlib
24
+ import glob
25
+ import io
26
+ import os
27
+ import re
28
+ import sys
29
+ import typing
30
+
31
+ import edq.testing.asserts
32
+ import edq.testing.unittest
33
+ import edq.util.dirent
34
+ import edq.util.json
35
+ import edq.util.pyimport
36
+
37
+ TEST_CASE_SEP: str = '---'
38
+ OUTPUT_SEP: str = '+++'
39
+ DATA_DIR_ID: str = '__DATA_DIR__'
40
+ ABS_DATA_DIR_ID: str = '__ABS_DATA_DIR__'
41
+ TEMP_DIR_ID: str = '__TEMP_DIR__'
42
+ BASE_DIR_ID: str = '__BASE_DIR__'
43
+
44
+ DEFAULT_ASSERTION_FUNC_NAME: str = 'edq.testing.asserts.content_equals_normalize'
45
+
46
+ BASE_TEMP_DIR_ATTR: str = '_edq_cli_base_test_dir'
47
+
48
+ class CLITestInfo:
49
+ """ The required information to run a CLI test. """
50
+
51
+ def __init__(self,
52
+ test_name: str,
53
+ base_dir: str,
54
+ data_dir: str,
55
+ temp_dir: str,
56
+ cli: typing.Union[str, None] = None,
57
+ arguments: typing.Union[typing.List[str], None] = None,
58
+ error: bool = False,
59
+ platform_skip: typing.Union[str, None] = None,
60
+ stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
61
+ stderr_assertion_func: typing.Union[str, None] = None,
62
+ expected_stdout: str = '',
63
+ expected_stderr: str = '',
64
+ split_stdout_stderr: bool = False,
65
+ strip_error_output: bool = True,
66
+ extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
67
+ **kwargs: typing.Any) -> None:
68
+ self.skip_reasons: typing.List[str] = []
69
+ """
70
+ Reasons that this test will be skipped.
71
+ Any entries in this list indicate that the test should be skipped.
72
+ """
73
+
74
+ self.platform_skip_pattern: typing.Union[str, None] = platform_skip
75
+ """
76
+ A pattern to check if the test should be skipped on the current platform.
77
+ Will be used in `re.search()` against `sys.platform`.
78
+ """
79
+
80
+ if ((platform_skip is not None) and re.search(platform_skip, sys.platform)):
81
+ self.skip_reasons.append(f"not available on platform: '{sys.platform}'")
82
+
83
+ self.test_name: str = test_name
84
+ """ The name of this test. """
85
+
86
+ self.base_dir: str = base_dir
87
+ """
88
+ The base directory for this test (usually the dir the CLI test file lives.
89
+ This is the expansion for `__BASE_DIR__` paths.
90
+ """
91
+
92
+ self.data_dir: str = data_dir
93
+ """
94
+ A directory that additional testing data lives in.
95
+ This is the expansion for `__DATA_DIR__` paths.
96
+ """
97
+
98
+ self.temp_dir: str = temp_dir
99
+ """
100
+ A temp directory that this test has access to.
101
+ This is the expansion for `__TEMP_DIR__` paths.
102
+ """
103
+
104
+ edq.util.dirent.mkdir(temp_dir)
105
+
106
+ if (cli is None):
107
+ raise ValueError("Missing CLI module.")
108
+
109
+ self.module_name: str = cli
110
+ """ The name of the module to invoke. """
111
+
112
+ self.module: typing.Any = None
113
+ """ The module to invoke. """
114
+
115
+ if (not self.should_skip()):
116
+ self.module = edq.util.pyimport.import_name(self.module_name)
117
+
118
+ if (arguments is None):
119
+ arguments = []
120
+
121
+ self.arguments: typing.List[str] = arguments
122
+ """ The CLI arguments. """
123
+
124
+ self.error: bool = error
125
+ """ Whether or not this test is expected to be an error (raise an exception). """
126
+
127
+ self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
128
+ """ The assertion func to compare the expected and actual stdout of the CLI. """
129
+
130
+ if ((stdout_assertion_func is not None) and (not self.should_skip())):
131
+ self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
132
+
133
+ self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
134
+ """ The assertion func to compare the expected and actual stderr of the CLI. """
135
+
136
+ if ((stderr_assertion_func is not None) and (not self.should_skip())):
137
+ self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
138
+
139
+ self.expected_stdout: str = expected_stdout
140
+ """ The expected stdout. """
141
+
142
+ self.expected_stderr: str = expected_stderr
143
+ """ The expected stderr. """
144
+
145
+ if (error and strip_error_output):
146
+ self.expected_stdout = self.expected_stdout.strip()
147
+ self.expected_stderr = self.expected_stderr.strip()
148
+
149
+ self.split_stdout_stderr: bool = split_stdout_stderr
150
+ """
151
+ Split stdout and stderr into different strings for testing.
152
+ By default, these two will be combined.
153
+ If both are non-empty, then they will be joined like: f"{stdout}\n{OUTPUT_SEP}\n{stderr}".
154
+ Otherwise, only the non-empty one will be present with no separator.
155
+ Any stdout assertions will be applied to the combined text.
156
+ """
157
+
158
+ # Make any path normalizations over the arguments and expected output.
159
+ self.expected_stdout = self._expand_paths(self.expected_stdout)
160
+ self.expected_stderr = self._expand_paths(self.expected_stderr)
161
+ for (i, argument) in enumerate(self.arguments):
162
+ self.arguments[i] = self._expand_paths(argument)
163
+
164
+ if (extra_options is None):
165
+ extra_options = {}
166
+
167
+ self.extra_options: typing.Union[typing.Dict[str, typing.Any], None] = extra_options
168
+ """
169
+ A place to store additional options.
170
+ Extra top-level options will cause tests to error.
171
+ """
172
+
173
+ if (len(kwargs) > 0):
174
+ raise ValueError(f"Found unknown CLI test options: '{kwargs}'.")
175
+
176
+ def _expand_paths(self, text: str) -> str:
177
+ """
178
+ Expand path replacements in testing text.
179
+ This allows for consistent paths (even absolute paths) in the test text.
180
+ """
181
+
182
+ replacements = [
183
+ (DATA_DIR_ID, self.data_dir),
184
+ (TEMP_DIR_ID, self.temp_dir),
185
+ (BASE_DIR_ID, self.base_dir),
186
+ (ABS_DATA_DIR_ID, os.path.abspath(self.data_dir)),
187
+ ]
188
+
189
+ for (key, target_dir) in replacements:
190
+ text = replace_path_pattern(text, key, target_dir)
191
+
192
+ return text
193
+
194
+ def should_skip(self) -> bool:
195
+ """ Check if this test should be skipped. """
196
+
197
+ return (len(self.skip_reasons) > 0)
198
+
199
+ def skip_message(self) -> str:
200
+ """ Get a message displaying the reasons this test should be skipped. """
201
+
202
+ return f"This test has been skipped because of the following: {self.skip_reasons}."
203
+
204
+ @staticmethod
205
+ def load_path(path: str, test_name: str, base_temp_dir: str, data_dir: str) -> 'CLITestInfo':
206
+ """ Load a CLI test file and extract the test info. """
207
+
208
+ options, expected_stdout = read_test_file(path)
209
+
210
+ options['expected_stdout'] = expected_stdout
211
+
212
+ base_dir = os.path.dirname(os.path.abspath(path))
213
+ temp_dir = os.path.join(base_temp_dir, test_name)
214
+
215
+ return CLITestInfo(test_name, base_dir, data_dir, temp_dir, **options)
216
+
217
+ @typing.runtime_checkable
218
+ class TestMethodWrapperFunction(typing.Protocol):
219
+ """
220
+ A function that can be used to wrap/modify a CLI test method before it is attached to the test class.
221
+ """
222
+
223
+ def __call__(self,
224
+ test_method: typing.Callable,
225
+ test_info_path: str,
226
+ ) -> typing.Callable:
227
+ """
228
+ Wrap and/or modify the CLI test method before it is attached to the test class.
229
+ See _get_test_method() for the input method.
230
+ The returned method will be used in-place of the input one.
231
+ """
232
+
233
+ def read_test_file(path: str) -> typing.Tuple[typing.Dict[str, typing.Any], str]:
234
+ """ Read a test case file and split the output into JSON data and text. """
235
+
236
+ json_lines: typing.List[str] = []
237
+ output_lines: typing.List[str] = []
238
+
239
+ text = edq.util.dirent.read_file(path, strip = False)
240
+
241
+ accumulator = json_lines
242
+ for line in text.split("\n"):
243
+ if (line.strip() == TEST_CASE_SEP):
244
+ accumulator = output_lines
245
+ continue
246
+
247
+ accumulator.append(line)
248
+
249
+ options = edq.util.json.loads(''.join(json_lines))
250
+ output = "\n".join(output_lines)
251
+
252
+ return options, output
253
+
254
+ def replace_path_pattern(text: str, key: str, target_dir: str) -> str:
255
+ """ Make any test replacement inside the given string. """
256
+
257
+ match = re.search(rf'{key}\(([^)]*)\)', text)
258
+ if (match is not None):
259
+ filename = match.group(1)
260
+
261
+ # Normalize any path separators.
262
+ filename = os.path.join(*filename.split('/'))
263
+
264
+ if (filename == ''):
265
+ path = target_dir
266
+ else:
267
+ path = os.path.join(target_dir, filename)
268
+
269
+ text = text.replace(match.group(0), path)
270
+
271
+ return text
272
+
273
+ def _get_test_method(test_name: str, path: str, data_dir: str) -> typing.Callable:
274
+ """ Get a test method that represents the test case at the given path. """
275
+
276
+ def __method(self: edq.testing.unittest.BaseTest,
277
+ reraise_exception_types: typing.Union[typing.Tuple[typing.Type], None] = None,
278
+ **kwargs: typing.Any) -> None:
279
+ test_info = CLITestInfo.load_path(path, test_name, getattr(self, BASE_TEMP_DIR_ATTR), data_dir)
280
+
281
+ # Allow the test class a chance to modify the test info before the test runs.
282
+ if (hasattr(self, 'modify_cli_test_info')):
283
+ self.modify_cli_test_info(test_info)
284
+
285
+ if (test_info.should_skip()):
286
+ self.skipTest(test_info.skip_message())
287
+
288
+ old_args = sys.argv
289
+ sys.argv = [test_info.module.__file__] + test_info.arguments
290
+
291
+ try:
292
+ with contextlib.redirect_stdout(io.StringIO()) as stdout_output:
293
+ with contextlib.redirect_stderr(io.StringIO()) as stderr_output:
294
+ test_info.module.main()
295
+
296
+ stdout_text = stdout_output.getvalue()
297
+ stderr_text = stderr_output.getvalue()
298
+
299
+ if (test_info.error):
300
+ self.fail(f"No error was not raised when one was expected ('{str(test_info.expected_stdout)}').")
301
+ except BaseException as ex:
302
+ if ((reraise_exception_types is not None) and isinstance(ex, reraise_exception_types)):
303
+ raise ex
304
+
305
+ if (not test_info.error):
306
+ raise ex
307
+
308
+ stdout_text = self.format_error_string(ex)
309
+
310
+ stderr_text = ''
311
+ if (isinstance(ex, SystemExit) and (ex.__context__ is not None)):
312
+ stderr_text = self.format_error_string(ex.__context__)
313
+ finally:
314
+ sys.argv = old_args
315
+
316
+ if (not test_info.split_stdout_stderr):
317
+ if ((len(stdout_text) > 0) and (len(stderr_text) > 0)):
318
+ stdout_text = f"{stdout_text}\n{OUTPUT_SEP}\n{stderr_text}"
319
+ elif (len(stderr_text) > 0):
320
+ stdout_text = stderr_text
321
+
322
+ if (test_info.stdout_assertion_func is not None):
323
+ test_info.stdout_assertion_func(self, test_info.expected_stdout, stdout_text)
324
+
325
+ if (test_info.stderr_assertion_func is not None):
326
+ test_info.stderr_assertion_func(self, test_info.expected_stderr, stderr_text)
327
+
328
+ return __method
329
+
330
+ def add_test_paths(target_class: type, data_dir: str, paths: typing.List[str],
331
+ test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
332
+ """ Add tests from the given test files. """
333
+
334
+ # Attach a temp directory to the testing class so all tests can share a common base temp dir.
335
+ if (not hasattr(target_class, BASE_TEMP_DIR_ATTR)):
336
+ setattr(target_class, BASE_TEMP_DIR_ATTR, edq.util.dirent.get_temp_path('edq_cli_test_'))
337
+
338
+ for path in sorted(paths):
339
+ basename = os.path.splitext(os.path.basename(path))[0]
340
+ if (hasattr(target_class, 'get_test_basename')):
341
+ basename = getattr(target_class, 'get_test_basename')(path)
342
+
343
+ test_name = 'test_cli__' + basename
344
+
345
+ try:
346
+ test_method = _get_test_method(test_name, path, data_dir)
347
+ except Exception as ex:
348
+ raise ValueError(f"Failed to parse test case '{path}'.") from ex
349
+
350
+ if (test_method_wrapper is not None):
351
+ test_method = test_method_wrapper(test_method, path)
352
+
353
+ setattr(target_class, test_name, test_method)
354
+
355
+ def discover_test_cases(target_class: type, test_cases_dir: str, data_dir: str,
356
+ test_method_wrapper: typing.Union[TestMethodWrapperFunction, None] = None) -> None:
357
+ """ Look in the text cases directory for any test cases and add them as test methods to the test class. """
358
+
359
+ paths = list(sorted(glob.glob(os.path.join(test_cases_dir, "**", "*.txt"), recursive = True)))
360
+ add_test_paths(target_class, data_dir, paths, test_method_wrapper = test_method_wrapper)
@@ -0,0 +1,15 @@
1
+ import os
2
+
3
+ import edq.testing.cli
4
+ import edq.testing.unittest
5
+
6
+ THIS_DIR: str = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
7
+ BASE_TESTDATA_DIR: str = os.path.join(THIS_DIR, "testdata", "cli")
8
+ TEST_CASES_DIR: str = os.path.join(BASE_TESTDATA_DIR, "tests")
9
+ DATA_DIR: str = os.path.join(BASE_TESTDATA_DIR, "data")
10
+
11
+ class CLITest(edq.testing.unittest.BaseTest):
12
+ """ Test CLI invocations. """
13
+
14
+ # Populate CLITest with all the test methods.
15
+ edq.testing.cli.discover_test_cases(CLITest, TEST_CASES_DIR, DATA_DIR)