edq-utils 0.1.9__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.
- edq/__init__.py +5 -0
- edq/cli/__init__.py +0 -0
- edq/cli/config/__init__.py +3 -0
- edq/cli/config/list.py +69 -0
- edq/cli/http/__init__.py +3 -0
- edq/cli/http/exchange-server.py +71 -0
- edq/cli/http/send-exchange.py +45 -0
- edq/cli/http/verify-exchanges.py +38 -0
- edq/cli/testing/__init__.py +3 -0
- edq/cli/testing/cli-test.py +49 -0
- edq/cli/version.py +28 -0
- edq/core/__init__.py +0 -0
- edq/core/argparser.py +137 -0
- edq/core/argparser_test.py +124 -0
- edq/core/config.py +268 -0
- edq/core/config_test.py +1038 -0
- edq/core/log.py +101 -0
- edq/core/version.py +6 -0
- edq/procedure/__init__.py +0 -0
- edq/procedure/verify_exchanges.py +85 -0
- edq/py.typed +0 -0
- edq/testing/__init__.py +3 -0
- edq/testing/asserts.py +65 -0
- edq/testing/cli.py +360 -0
- edq/testing/cli_test.py +15 -0
- edq/testing/httpserver.py +578 -0
- edq/testing/httpserver_test.py +424 -0
- edq/testing/run.py +142 -0
- edq/testing/testdata/cli/data/configs/empty/edq-config.json +1 -0
- edq/testing/testdata/cli/data/configs/simple-1/edq-config.json +4 -0
- edq/testing/testdata/cli/data/configs/simple-2/edq-config.json +3 -0
- edq/testing/testdata/cli/data/configs/value-number/edq-config.json +3 -0
- edq/testing/testdata/cli/tests/config/list/config_list_base.txt +16 -0
- edq/testing/testdata/cli/tests/config/list/config_list_config_value_number.txt +10 -0
- edq/testing/testdata/cli/tests/config/list/config_list_ignore_config.txt +14 -0
- edq/testing/testdata/cli/tests/config/list/config_list_no_config.txt +8 -0
- edq/testing/testdata/cli/tests/config/list/config_list_show_origin.txt +13 -0
- edq/testing/testdata/cli/tests/config/list/config_list_skip_header.txt +10 -0
- edq/testing/testdata/cli/tests/help_base.txt +9 -0
- edq/testing/testdata/cli/tests/platform_skip.txt +5 -0
- edq/testing/testdata/cli/tests/version_base.txt +6 -0
- edq/testing/testdata/http/exchanges/simple.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
- edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
- edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
- edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
- edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
- edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
- edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
- edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
- edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
- edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
- edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
- edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
- edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
- edq/testing/testdata/http/files/a.txt +1 -0
- edq/testing/testdata/http/files/tiny.png +0 -0
- edq/testing/unittest.py +88 -0
- edq/util/__init__.py +3 -0
- edq/util/dirent.py +340 -0
- edq/util/dirent_test.py +979 -0
- edq/util/encoding.py +18 -0
- edq/util/hash.py +41 -0
- edq/util/hash_test.py +89 -0
- edq/util/json.py +180 -0
- edq/util/json_test.py +228 -0
- edq/util/net.py +1008 -0
- edq/util/parse.py +33 -0
- edq/util/pyimport.py +94 -0
- edq/util/pyimport_test.py +119 -0
- edq/util/reflection.py +32 -0
- edq/util/time.py +75 -0
- edq/util/time_test.py +107 -0
- edq_utils-0.1.9.dist-info/METADATA +164 -0
- edq_utils-0.1.9.dist-info/RECORD +83 -0
- edq_utils-0.1.9.dist-info/WHEEL +5 -0
- edq_utils-0.1.9.dist-info/licenses/LICENSE +21 -0
- edq_utils-0.1.9.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
|
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
|
edq/testing/__init__.py
ADDED
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)
|
edq/testing/cli_test.py
ADDED
|
@@ -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)
|