edq-utils 0.0.3__tar.gz → 0.0.4__tar.gz
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.
- {edq_utils-0.0.3/edq_utils.egg-info → edq_utils-0.0.4}/PKG-INFO +1 -1
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/__init__.py +1 -1
- edq_utils-0.0.4/edq/cli/testing/cli-test.py +42 -0
- edq_utils-0.0.4/edq/cli/version.py +27 -0
- edq_utils-0.0.4/edq/core/argparser.py +112 -0
- edq_utils-0.0.4/edq/core/argparser_test.py +124 -0
- edq_utils-0.0.4/edq/core/log.py +101 -0
- edq_utils-0.0.4/edq/core/version.py +6 -0
- edq_utils-0.0.4/edq/py.typed +0 -0
- edq_utils-0.0.4/edq/testing/asserts.py +66 -0
- edq_utils-0.0.4/edq/testing/cli.py +253 -0
- edq_utils-0.0.4/edq/testing/cli_test.py +8 -0
- edq_utils-0.0.4/edq/testing/testdata/cli/tests/version_base.txt +6 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/testing/unittest.py +2 -2
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/dirent.py +3 -3
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/json.py +12 -12
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/pyimport.py +21 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/pyimport_test.py +36 -0
- edq_utils-0.0.4/edq/util/testdata/dirent-operations/file_empty +0 -0
- edq_utils-0.0.4/edq/util/testdata/dirent-operations/symlink_file_empty +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4/edq_utils.egg-info}/PKG-INFO +1 -1
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/SOURCES.txt +13 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/LICENSE +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/README.md +0 -0
- /edq_utils-0.0.3/edq/py.typed → /edq_utils-0.0.4/edq/cli/__init__.py +0 -0
- /edq_utils-0.0.3/edq/util/testdata/dirent-operations/file_empty → /edq_utils-0.0.4/edq/cli/testing/__init__.py +0 -0
- /edq_utils-0.0.3/edq/util/testdata/dirent-operations/symlink_file_empty → /edq_utils-0.0.4/edq/core/__init__.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/testing/__init__.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/testing/run.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/__init__.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/dirent_test.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/json_test.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/reflection.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/a.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/dir_1/b.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/dir_1/dir_2/c.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_a.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_dir_1/b.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_dir_1/dir_2/c.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/time.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/time_test.py +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/dependency_links.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/requires.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/top_level.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/pyproject.toml +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/requirements-dev.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/requirements.txt +0 -0
- {edq_utils-0.0.3 → edq_utils-0.0.4}/setup.cfg +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# # pylint: disable=invalid-name
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Run specified CLI test files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
import edq.core.argparser
|
|
12
|
+
import edq.testing.cli
|
|
13
|
+
import edq.testing.cli_test
|
|
14
|
+
|
|
15
|
+
def run_cli(args: argparse.Namespace) -> int:
|
|
16
|
+
""" Run the CLI. """
|
|
17
|
+
|
|
18
|
+
edq.testing.cli.add_test_paths(edq.testing.cli_test.CLITest, args.paths)
|
|
19
|
+
|
|
20
|
+
runner = unittest.TextTestRunner(verbosity = 2)
|
|
21
|
+
tests = unittest.defaultTestLoader.loadTestsFromTestCase(edq.testing.cli_test.CLITest)
|
|
22
|
+
results = runner.run(tests)
|
|
23
|
+
|
|
24
|
+
return len(results.errors) + len(results.failures)
|
|
25
|
+
|
|
26
|
+
def main() -> int:
|
|
27
|
+
""" Get a parser, parse the args, and call run. """
|
|
28
|
+
return run_cli(_get_parser().parse_args())
|
|
29
|
+
|
|
30
|
+
def _get_parser() -> edq.core.argparser.Parser:
|
|
31
|
+
""" Get the parser. """
|
|
32
|
+
|
|
33
|
+
parser = edq.core.argparser.get_default_parser(__doc__.strip())
|
|
34
|
+
|
|
35
|
+
parser.add_argument('paths', metavar = 'PATH',
|
|
36
|
+
type = str, nargs = '+',
|
|
37
|
+
help = 'Path to CLI test case files.')
|
|
38
|
+
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
if (__name__ == '__main__'):
|
|
42
|
+
sys.exit(main())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Get the version of the EduLinq Python utils package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import edq.core.argparser
|
|
9
|
+
import edq.core.version
|
|
10
|
+
|
|
11
|
+
def run_cli(args: argparse.Namespace) -> int:
|
|
12
|
+
""" Run the CLI. """
|
|
13
|
+
|
|
14
|
+
print(f"v{edq.core.version.get_version()}")
|
|
15
|
+
return 0
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
""" Get a parser, parse the args, and call run. """
|
|
19
|
+
return run_cli(_get_parser().parse_args())
|
|
20
|
+
|
|
21
|
+
def _get_parser() -> edq.core.argparser.Parser:
|
|
22
|
+
""" Get the parser. """
|
|
23
|
+
|
|
24
|
+
return edq.core.argparser.get_default_parser(__doc__.strip())
|
|
25
|
+
|
|
26
|
+
if (__name__ == '__main__'):
|
|
27
|
+
sys.exit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A place to handle common CLI arguments.
|
|
3
|
+
"parsers" in this file are always assumed to be argparse parsers.
|
|
4
|
+
|
|
5
|
+
The general idea is that callers can register callbacks to be called before and after parsing CLI arguments.
|
|
6
|
+
Pre-callbacks are generally intended to add arguments to the parser,
|
|
7
|
+
while post-callbacks are generally intended to act on the results of parsing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
import edq.core.log
|
|
14
|
+
|
|
15
|
+
@typing.runtime_checkable
|
|
16
|
+
class PreParseFunction(typing.Protocol):
|
|
17
|
+
"""
|
|
18
|
+
A function that can be called before parsing arguments.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __call__(self, parser: argparse.ArgumentParser, extra_state: typing.Dict[str, typing.Any]) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Prepare a parser for parsing.
|
|
24
|
+
This is generally used for adding your module's arguments to the parser,
|
|
25
|
+
for example a logging module may add arguments to set a logging level.
|
|
26
|
+
|
|
27
|
+
The extra state is shared between all pre-parse functions
|
|
28
|
+
and will be placed in the final parsed output under `_pre_extra_state_`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@typing.runtime_checkable
|
|
32
|
+
class PostParseFunction(typing.Protocol):
|
|
33
|
+
"""
|
|
34
|
+
A function that can be called after parsing arguments.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __call__(self,
|
|
38
|
+
parser: argparse.ArgumentParser,
|
|
39
|
+
args: argparse.Namespace,
|
|
40
|
+
extra_state: typing.Dict[str, typing.Any]) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Take actions after arguments are parsed.
|
|
43
|
+
This is generally used for initializing your module with options,
|
|
44
|
+
for example a logging module may set a logging level.
|
|
45
|
+
|
|
46
|
+
The extra state is shared between all post-parse functions
|
|
47
|
+
and will be placed in the final parsed output under `_post_extra_state_`.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
class Parser(argparse.ArgumentParser):
|
|
51
|
+
"""
|
|
52
|
+
Extend an argparse parser to call the pre and post functions.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
56
|
+
super().__init__(*args, **kwargs)
|
|
57
|
+
|
|
58
|
+
self._pre_parse_callbacks: typing.Dict[str, PreParseFunction] = {}
|
|
59
|
+
self._post_parse_callbacks: typing.Dict[str, PostParseFunction] = {}
|
|
60
|
+
|
|
61
|
+
def register_callbacks(self,
|
|
62
|
+
key: str,
|
|
63
|
+
pre_parse_callback: typing.Union[PreParseFunction, None] = None,
|
|
64
|
+
post_parse_callback: typing.Union[PostParseFunction, None] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Register callback functions to run before/after argument parsing.
|
|
68
|
+
Any existing callbacks under the specified key will be replaced.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
if (pre_parse_callback is not None):
|
|
72
|
+
self._pre_parse_callbacks[key] = pre_parse_callback
|
|
73
|
+
|
|
74
|
+
if (post_parse_callback is not None):
|
|
75
|
+
self._post_parse_callbacks[key] = post_parse_callback
|
|
76
|
+
|
|
77
|
+
def parse_args(self, # type: ignore[override]
|
|
78
|
+
*args: typing.Any,
|
|
79
|
+
skip_keys: typing.Union[typing.List[str], None] = None,
|
|
80
|
+
**kwargs: typing.Any) -> argparse.Namespace:
|
|
81
|
+
if (skip_keys is None):
|
|
82
|
+
skip_keys = []
|
|
83
|
+
|
|
84
|
+
# Call pre-parse callbacks.
|
|
85
|
+
pre_extra_state: typing.Dict[str, typing.Any] = {}
|
|
86
|
+
for (key, pre_parse_callback) in self._pre_parse_callbacks.items():
|
|
87
|
+
if (key not in skip_keys):
|
|
88
|
+
pre_parse_callback(self, pre_extra_state)
|
|
89
|
+
|
|
90
|
+
# Parse the args.
|
|
91
|
+
parsed_args = super().parse_args(*args, **kwargs)
|
|
92
|
+
|
|
93
|
+
# Call post-parse callbacks.
|
|
94
|
+
post_extra_state: typing.Dict[str, typing.Any] = {}
|
|
95
|
+
for (key, post_parse_callback) in self._post_parse_callbacks.items():
|
|
96
|
+
if (key not in skip_keys):
|
|
97
|
+
post_parse_callback(self, parsed_args, post_extra_state)
|
|
98
|
+
|
|
99
|
+
# Attach the additional state to the args.
|
|
100
|
+
setattr(parsed_args, '_pre_extra_state_', pre_extra_state)
|
|
101
|
+
setattr(parsed_args, '_post_extra_state_', post_extra_state)
|
|
102
|
+
|
|
103
|
+
return parsed_args # type: ignore[no-any-return]
|
|
104
|
+
|
|
105
|
+
def get_default_parser(description: str) -> Parser:
|
|
106
|
+
""" Get a parser with the default callbacks already attached. """
|
|
107
|
+
|
|
108
|
+
parser = Parser(description = description)
|
|
109
|
+
|
|
110
|
+
parser.register_callbacks('log', edq.core.log.set_cli_args, edq.core.log.init_from_args)
|
|
111
|
+
|
|
112
|
+
return parser
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
|
|
3
|
+
import edq.core.argparser
|
|
4
|
+
import edq.testing.unittest
|
|
5
|
+
|
|
6
|
+
class TestArgParser(edq.testing.unittest.BaseTest):
|
|
7
|
+
""" Test argument parsing. """
|
|
8
|
+
|
|
9
|
+
def test_callbacks_base(self):
|
|
10
|
+
""" Test the argument parsing callbacks. """
|
|
11
|
+
|
|
12
|
+
# [(parse text, [(key, pre, post), ...], skip keys, expected (as dict)), ...]
|
|
13
|
+
test_cases = [
|
|
14
|
+
# Empty
|
|
15
|
+
(
|
|
16
|
+
"",
|
|
17
|
+
[],
|
|
18
|
+
[],
|
|
19
|
+
{
|
|
20
|
+
'_pre_extra_state_': {},
|
|
21
|
+
'_post_extra_state_': {},
|
|
22
|
+
},
|
|
23
|
+
),
|
|
24
|
+
|
|
25
|
+
# Single Callbacks
|
|
26
|
+
(
|
|
27
|
+
"",
|
|
28
|
+
[
|
|
29
|
+
('test', functools.partial(_pre_callback_append, value = 1), functools.partial(_post_callback_append, value = 2)),
|
|
30
|
+
],
|
|
31
|
+
[],
|
|
32
|
+
{
|
|
33
|
+
'_pre_extra_state_': {
|
|
34
|
+
'append': [1],
|
|
35
|
+
},
|
|
36
|
+
'_post_extra_state_': {
|
|
37
|
+
'append': [2],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
# Double Callbacks
|
|
43
|
+
(
|
|
44
|
+
"",
|
|
45
|
+
[
|
|
46
|
+
('test1', functools.partial(_pre_callback_append, value = 1), functools.partial(_post_callback_append, value = 2)),
|
|
47
|
+
('test2', functools.partial(_pre_callback_append, value = 3), functools.partial(_post_callback_append, value = 4)),
|
|
48
|
+
],
|
|
49
|
+
[],
|
|
50
|
+
{
|
|
51
|
+
'_pre_extra_state_': {
|
|
52
|
+
'append': [1, 3],
|
|
53
|
+
},
|
|
54
|
+
'_post_extra_state_': {
|
|
55
|
+
'append': [2, 4],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
),
|
|
59
|
+
|
|
60
|
+
# Split Callbacks
|
|
61
|
+
(
|
|
62
|
+
"",
|
|
63
|
+
[
|
|
64
|
+
('test1', functools.partial(_pre_callback_append, value = 1), None),
|
|
65
|
+
('test2', None, functools.partial(_post_callback_append, value = 4)),
|
|
66
|
+
],
|
|
67
|
+
[],
|
|
68
|
+
{
|
|
69
|
+
'_pre_extra_state_': {
|
|
70
|
+
'append': [1],
|
|
71
|
+
},
|
|
72
|
+
'_post_extra_state_': {
|
|
73
|
+
'append': [4],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
),
|
|
77
|
+
|
|
78
|
+
# Override Callbacks
|
|
79
|
+
(
|
|
80
|
+
"",
|
|
81
|
+
[
|
|
82
|
+
('test', functools.partial(_pre_callback_append, value = 1), functools.partial(_post_callback_append, value = 2)),
|
|
83
|
+
('test', functools.partial(_pre_callback_append, value = 3), functools.partial(_post_callback_append, value = 4)),
|
|
84
|
+
],
|
|
85
|
+
[],
|
|
86
|
+
{
|
|
87
|
+
'_pre_extra_state_': {
|
|
88
|
+
'append': [3],
|
|
89
|
+
},
|
|
90
|
+
'_post_extra_state_': {
|
|
91
|
+
'append': [4],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
for (i, test_case) in enumerate(test_cases):
|
|
98
|
+
(text, registrations, skip_keys, expected) = test_case
|
|
99
|
+
|
|
100
|
+
with self.subTest(msg = f"Case {i} ('{text}'):"):
|
|
101
|
+
parser = edq.core.argparser.Parser(f"Case {i}")
|
|
102
|
+
for (key, pre, post) in registrations:
|
|
103
|
+
parser.register_callbacks(key, pre, post)
|
|
104
|
+
|
|
105
|
+
args = parser.parse_args(text.split(), skip_keys = skip_keys)
|
|
106
|
+
|
|
107
|
+
actual = vars(args)
|
|
108
|
+
self.assertJSONDictEqual(expected, actual)
|
|
109
|
+
|
|
110
|
+
def _pre_callback_append(parser, extra_state, key = 'append', value = None) -> None:
|
|
111
|
+
""" Append the given value into the extra state. """
|
|
112
|
+
|
|
113
|
+
if (key not in extra_state):
|
|
114
|
+
extra_state[key] = []
|
|
115
|
+
|
|
116
|
+
extra_state[key].append(value)
|
|
117
|
+
|
|
118
|
+
def _post_callback_append(parser, args, extra_state, key = 'append', value = None) -> None:
|
|
119
|
+
""" Append the given value into the extra state. """
|
|
120
|
+
|
|
121
|
+
if (key not in extra_state):
|
|
122
|
+
extra_state[key] = []
|
|
123
|
+
|
|
124
|
+
extra_state[key].append(value)
|
|
@@ -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()
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
|
|
41
|
+
def content_equals_raw(test: edq.testing.unittest.BaseTest, expected: str, actual: str, **kwargs: typing.Any) -> None:
|
|
42
|
+
""" Check for equality using a simple string comparison. """
|
|
43
|
+
|
|
44
|
+
test.assertEqual(expected, actual)
|
|
45
|
+
|
|
46
|
+
def content_equals_normalize(test: edq.testing.unittest.BaseTest, expected: str, actual: str, **kwargs: typing.Any) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Perform some standard text normalizations (see TEXT_NORMALIZATIONS) before using simple string comparison.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
for (regex, replacement) in TEXT_NORMALIZATIONS:
|
|
52
|
+
expected = re.sub(regex, replacement, expected, flags = re.MULTILINE)
|
|
53
|
+
actual = re.sub(regex, replacement, actual, flags = re.MULTILINE)
|
|
54
|
+
|
|
55
|
+
content_equals_raw(test, expected, actual)
|
|
56
|
+
|
|
57
|
+
def has_content_100(test: edq.testing.unittest.BaseTest, expected: str, actual: str, **kwargs: typing.Any) -> None:
|
|
58
|
+
""" Check the that output has at least 100 characters. """
|
|
59
|
+
|
|
60
|
+
return has_content(test, expected, actual, min_length = 100)
|
|
61
|
+
|
|
62
|
+
def has_content(test: edq.testing.unittest.BaseTest, expected: str, actual: str, min_length: int = 100) -> None:
|
|
63
|
+
""" Ensure that the output has content of at least some length. """
|
|
64
|
+
|
|
65
|
+
message = f"Output does not meet minimum length of {min_length}, it is only {len(actual)}."
|
|
66
|
+
test.assertTrue((len(actual) >= min_length), msg = message)
|
|
@@ -0,0 +1,253 @@
|
|
|
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 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
|
+
The expected output or any argument can reference the test's current temp or data dirs with `__TEMP_DIR__()` or `__DATA_DIR__()`, respectively.
|
|
13
|
+
An optional slash-separated path can be used as an argument to reference a path within those base directories.
|
|
14
|
+
For example, `__DATA_DIR__(foo/bar.txt)` references `bar.txt` inside the `foo` directory inside the data directory.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import glob
|
|
19
|
+
import io
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import typing
|
|
24
|
+
|
|
25
|
+
import edq.testing.asserts
|
|
26
|
+
import edq.testing.unittest
|
|
27
|
+
import edq.util.dirent
|
|
28
|
+
import edq.util.json
|
|
29
|
+
import edq.util.pyimport
|
|
30
|
+
|
|
31
|
+
THIS_DIR: str = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
|
|
32
|
+
BASE_TESTDATA_DIR: str = os.path.join(THIS_DIR, "testdata", "cli")
|
|
33
|
+
TEST_CASES_DIR: str = os.path.join(BASE_TESTDATA_DIR, "tests")
|
|
34
|
+
DATA_DIR: str = os.path.join(BASE_TESTDATA_DIR, "data")
|
|
35
|
+
|
|
36
|
+
TEST_CASE_SEP: str = '---'
|
|
37
|
+
DATA_DIR_ID: str = '__DATA_DIR__'
|
|
38
|
+
TEMP_DIR_ID: str = '__TEMP_DIR__'
|
|
39
|
+
REL_DIR_ID: str = '__REL_DIR__'
|
|
40
|
+
|
|
41
|
+
DEFAULT_ASSERTION_FUNC_NAME: str = 'edq.testing.asserts.content_equals_normalize'
|
|
42
|
+
|
|
43
|
+
BASE_TEMP_DIR_ATTR: str = '_edq_cli_base_test_dir'
|
|
44
|
+
|
|
45
|
+
class CLITestInfo:
|
|
46
|
+
""" The required information to run a CLI test. """
|
|
47
|
+
|
|
48
|
+
def __init__(self,
|
|
49
|
+
test_name: str,
|
|
50
|
+
base_dir: str,
|
|
51
|
+
temp_dir: str,
|
|
52
|
+
cli: typing.Union[str, None] = None,
|
|
53
|
+
arguments: typing.Union[typing.List[str], None] = None,
|
|
54
|
+
error: bool = False,
|
|
55
|
+
platform_skip: typing.Union[str, None] = None,
|
|
56
|
+
stdout_assertion_func: typing.Union[str, None] = DEFAULT_ASSERTION_FUNC_NAME,
|
|
57
|
+
stderr_assertion_func: typing.Union[str, None] = None,
|
|
58
|
+
expected_stdout: str = '',
|
|
59
|
+
expected_stderr: str = '',
|
|
60
|
+
strip_error_output: bool = True,
|
|
61
|
+
**kwargs: typing.Any) -> None:
|
|
62
|
+
self.test_name: str = test_name
|
|
63
|
+
""" The name of this test. """
|
|
64
|
+
|
|
65
|
+
self.base_dir: str = base_dir
|
|
66
|
+
""" The base directory for this test (usually the dir the CLI test file lives. """
|
|
67
|
+
|
|
68
|
+
self.temp_dir: str = temp_dir
|
|
69
|
+
""" A temp directory that this test has access to. """
|
|
70
|
+
|
|
71
|
+
edq.util.dirent.mkdir(temp_dir)
|
|
72
|
+
|
|
73
|
+
if (cli is None):
|
|
74
|
+
raise ValueError("Missing CLI module.")
|
|
75
|
+
|
|
76
|
+
self.module_name: str = cli
|
|
77
|
+
""" The name of the module to invoke. """
|
|
78
|
+
|
|
79
|
+
self.module: typing.Any = edq.util.pyimport.import_name(self.module_name)
|
|
80
|
+
""" The module to invoke. """
|
|
81
|
+
|
|
82
|
+
if (arguments is None):
|
|
83
|
+
arguments = []
|
|
84
|
+
|
|
85
|
+
self.arguments: typing.List[str] = arguments
|
|
86
|
+
""" The CLI arguments. """
|
|
87
|
+
|
|
88
|
+
self.error: bool = error
|
|
89
|
+
""" Whether or not this test is expected to be an error (raise an exception). """
|
|
90
|
+
|
|
91
|
+
self.platform_skip: typing.Union[str, None] = platform_skip
|
|
92
|
+
""" If the current platform matches this regular expression, then the test will be skipped. """
|
|
93
|
+
|
|
94
|
+
self.stdout_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
|
|
95
|
+
""" The assertion func to compare the expected and actual stdout of the CLI. """
|
|
96
|
+
|
|
97
|
+
if (stdout_assertion_func is not None):
|
|
98
|
+
self.stdout_assertion_func = edq.util.pyimport.fetch(stdout_assertion_func)
|
|
99
|
+
|
|
100
|
+
self.stderr_assertion_func: typing.Union[edq.testing.asserts.StringComparisonAssertion, None] = None
|
|
101
|
+
""" The assertion func to compare the expected and actual stderr of the CLI. """
|
|
102
|
+
|
|
103
|
+
if (stderr_assertion_func is not None):
|
|
104
|
+
self.stderr_assertion_func = edq.util.pyimport.fetch(stderr_assertion_func)
|
|
105
|
+
|
|
106
|
+
self.expected_stdout: str = expected_stdout
|
|
107
|
+
""" The expected stdout. """
|
|
108
|
+
|
|
109
|
+
self.expected_stderr: str = expected_stderr
|
|
110
|
+
""" The expected stderr. """
|
|
111
|
+
|
|
112
|
+
if (error and strip_error_output):
|
|
113
|
+
self.expected_stdout = self.expected_stdout.strip()
|
|
114
|
+
self.expected_stderr = self.expected_stderr.strip()
|
|
115
|
+
|
|
116
|
+
# Make any path normalizations over the arguments and expected output.
|
|
117
|
+
self.expected_stdout = self._expand_paths(self.expected_stdout)
|
|
118
|
+
self.expected_stderr = self._expand_paths(self.expected_stderr)
|
|
119
|
+
for (i, argument) in enumerate(self.arguments):
|
|
120
|
+
self.arguments[i] = self._expand_paths(argument)
|
|
121
|
+
|
|
122
|
+
def _expand_paths(self, text: str) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Expand path replacements in testing text.
|
|
125
|
+
This allows for consistent paths (even absolute paths) in the test text.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
replacements = [
|
|
129
|
+
(DATA_DIR_ID, DATA_DIR),
|
|
130
|
+
(TEMP_DIR_ID, self.temp_dir),
|
|
131
|
+
(REL_DIR_ID, self.base_dir),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
for (key, target_dir) in replacements:
|
|
135
|
+
text = replace_path_pattern(text, key, target_dir)
|
|
136
|
+
|
|
137
|
+
return text
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def load_path(path: str, test_name: str, base_temp_dir: str) -> 'CLITestInfo':
|
|
141
|
+
""" Load a CLI test file and extract the test info. """
|
|
142
|
+
|
|
143
|
+
options, expected_stdout = read_test_file(path)
|
|
144
|
+
|
|
145
|
+
options['expected_stdout'] = expected_stdout
|
|
146
|
+
|
|
147
|
+
base_dir = os.path.dirname(os.path.abspath(path))
|
|
148
|
+
temp_dir = os.path.join(base_temp_dir, test_name)
|
|
149
|
+
|
|
150
|
+
return CLITestInfo(test_name, base_dir, temp_dir, **options)
|
|
151
|
+
|
|
152
|
+
def read_test_file(path: str) -> typing.Tuple[typing.Dict[str, typing.Any], str]:
|
|
153
|
+
""" Read a test case file and split the output into JSON data and text. """
|
|
154
|
+
|
|
155
|
+
json_lines: typing.List[str] = []
|
|
156
|
+
output_lines: typing.List[str] = []
|
|
157
|
+
|
|
158
|
+
text = edq.util.dirent.read_file(path, strip = False)
|
|
159
|
+
|
|
160
|
+
accumulator = json_lines
|
|
161
|
+
for line in text.split("\n"):
|
|
162
|
+
if (line.strip() == TEST_CASE_SEP):
|
|
163
|
+
accumulator = output_lines
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
accumulator.append(line)
|
|
167
|
+
|
|
168
|
+
options = edq.util.json.loads(''.join(json_lines))
|
|
169
|
+
output = "\n".join(output_lines)
|
|
170
|
+
|
|
171
|
+
return options, output
|
|
172
|
+
|
|
173
|
+
def replace_path_pattern(text: str, key: str, target_dir: str) -> str:
|
|
174
|
+
""" Make any test replacement inside the given string. """
|
|
175
|
+
|
|
176
|
+
match = re.search(rf'{key}\(([^)]*)\)', text)
|
|
177
|
+
if (match is not None):
|
|
178
|
+
filename = match.group(1)
|
|
179
|
+
|
|
180
|
+
# Normalize any path separators.
|
|
181
|
+
filename = os.path.join(*filename.split('/'))
|
|
182
|
+
|
|
183
|
+
if (filename == ''):
|
|
184
|
+
path = target_dir
|
|
185
|
+
else:
|
|
186
|
+
path = os.path.join(target_dir, filename)
|
|
187
|
+
|
|
188
|
+
text = text.replace(match.group(0), path)
|
|
189
|
+
|
|
190
|
+
return text
|
|
191
|
+
|
|
192
|
+
def _get_test_method(test_name: str, path: str) -> typing.Callable:
|
|
193
|
+
""" Get a test method that represents the test case at the given path. """
|
|
194
|
+
|
|
195
|
+
def __method(self: edq.testing.unittest.BaseTest) -> None:
|
|
196
|
+
test_info = CLITestInfo.load_path(path, test_name, getattr(self, BASE_TEMP_DIR_ATTR))
|
|
197
|
+
|
|
198
|
+
if ((test_info.platform_skip is not None) and re.search(test_info.platform_skip, sys.platform)):
|
|
199
|
+
self.skipTest(f"Test is not available on {sys.platform}.")
|
|
200
|
+
|
|
201
|
+
old_args = sys.argv
|
|
202
|
+
sys.argv = [test_info.module.__file__] + test_info.arguments
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
with contextlib.redirect_stdout(io.StringIO()) as stdout_output:
|
|
206
|
+
with contextlib.redirect_stderr(io.StringIO()) as stderr_output:
|
|
207
|
+
test_info.module.main()
|
|
208
|
+
|
|
209
|
+
stdout_text = stdout_output.getvalue()
|
|
210
|
+
stderr_text = stderr_output.getvalue()
|
|
211
|
+
|
|
212
|
+
if (test_info.error):
|
|
213
|
+
self.fail(f"No error was not raised when one was expected ('{str(test_info.expected_stdout)}').")
|
|
214
|
+
except BaseException as ex:
|
|
215
|
+
if (not test_info.error):
|
|
216
|
+
raise ex
|
|
217
|
+
|
|
218
|
+
stdout_text = self.format_error_string(ex)
|
|
219
|
+
|
|
220
|
+
stderr_text = ''
|
|
221
|
+
if (isinstance(ex, SystemExit) and (ex.__context__ is not None)):
|
|
222
|
+
stderr_text = self.format_error_string(ex.__context__)
|
|
223
|
+
finally:
|
|
224
|
+
sys.argv = old_args
|
|
225
|
+
|
|
226
|
+
if (test_info.stdout_assertion_func is not None):
|
|
227
|
+
test_info.stdout_assertion_func(self, test_info.expected_stdout, stdout_text)
|
|
228
|
+
|
|
229
|
+
if (test_info.stderr_assertion_func is not None):
|
|
230
|
+
test_info.stderr_assertion_func(self, test_info.expected_stderr, stderr_text)
|
|
231
|
+
|
|
232
|
+
return __method
|
|
233
|
+
|
|
234
|
+
def add_test_paths(target_class: type, paths: typing.List[str]) -> None:
|
|
235
|
+
""" Add tests from the given test files. """
|
|
236
|
+
|
|
237
|
+
# Attach a temp directory to the testing class so all tests can share a common base temp dir.
|
|
238
|
+
if (not hasattr(target_class, BASE_TEMP_DIR_ATTR)):
|
|
239
|
+
setattr(target_class, BASE_TEMP_DIR_ATTR, edq.util.dirent.get_temp_path('edq_cli_test_'))
|
|
240
|
+
|
|
241
|
+
for path in sorted(paths):
|
|
242
|
+
test_name = 'test_cli__' + os.path.splitext(os.path.basename(path))[0]
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
setattr(target_class, test_name, _get_test_method(test_name, path))
|
|
246
|
+
except Exception as ex:
|
|
247
|
+
raise ValueError(f"Failed to parse test case '{path}'.") from ex
|
|
248
|
+
|
|
249
|
+
def discover_test_cases(target_class: type) -> None:
|
|
250
|
+
""" Look in the text cases directory for any test cases and add them as test methods to the test class. """
|
|
251
|
+
|
|
252
|
+
paths = list(sorted(glob.glob(os.path.join(TEST_CASES_DIR, "**", "*.txt"), recursive = True)))
|
|
253
|
+
add_test_paths(target_class, paths)
|
|
@@ -14,7 +14,7 @@ class BaseTest(unittest.TestCase):
|
|
|
14
14
|
maxDiff = None
|
|
15
15
|
""" Don't limit the size of diffs. """
|
|
16
16
|
|
|
17
|
-
def assertJSONDictEqual(self, a:
|
|
17
|
+
def assertJSONDictEqual(self, a: typing.Dict[str, typing.Any], b: typing.Dict[str, typing.Any]) -> None: # pylint: disable=invalid-name
|
|
18
18
|
"""
|
|
19
19
|
Call assertDictEqual(), but supply a message containing the full JSON representation of the arguments.
|
|
20
20
|
"""
|
|
@@ -24,7 +24,7 @@ class BaseTest(unittest.TestCase):
|
|
|
24
24
|
|
|
25
25
|
super().assertDictEqual(a, b, FORMAT_STR % (a_json, b_json))
|
|
26
26
|
|
|
27
|
-
def assertJSONListEqual(self, a:
|
|
27
|
+
def assertJSONListEqual(self, a: typing.List[typing.Any], b: typing.List[typing.Any]) -> None: # pylint: disable=invalid-name
|
|
28
28
|
"""
|
|
29
29
|
Call assertListEqual(), but supply a message containing the full JSON representation of the arguments.
|
|
30
30
|
"""
|
|
@@ -115,7 +115,7 @@ def remove(path: str) -> None:
|
|
|
115
115
|
else:
|
|
116
116
|
raise ValueError(f"Unknown type of dirent: '{path}'.")
|
|
117
117
|
|
|
118
|
-
def same(a: str, b: str):
|
|
118
|
+
def same(a: str, b: str) -> bool:
|
|
119
119
|
"""
|
|
120
120
|
Check if two paths represent the same dirent.
|
|
121
121
|
If either (or both) paths do not exist, false will be returned.
|
|
@@ -251,7 +251,7 @@ def write_file(
|
|
|
251
251
|
raw_path: str, contents: typing.Union[str, None],
|
|
252
252
|
strip: bool = True, newline: bool = True,
|
|
253
253
|
encoding: str = DEFAULT_ENCODING,
|
|
254
|
-
no_clobber = False) -> None:
|
|
254
|
+
no_clobber: bool = False) -> None:
|
|
255
255
|
"""
|
|
256
256
|
Write the contents of a file.
|
|
257
257
|
If clobbering, any existing dirent will be removed before write.
|
|
@@ -290,7 +290,7 @@ def read_file_bytes(raw_path: str) -> bytes:
|
|
|
290
290
|
|
|
291
291
|
def write_file_bytes(
|
|
292
292
|
raw_path: str, contents: typing.Union[bytes, None],
|
|
293
|
-
no_clobber = False) -> None:
|
|
293
|
+
no_clobber: bool = False) -> None:
|
|
294
294
|
"""
|
|
295
295
|
Write the contents of a file as bytes.
|
|
296
296
|
If clobbering, any existing dirent will be removed before write.
|
|
@@ -47,7 +47,7 @@ class DictConverter(abc.ABC):
|
|
|
47
47
|
if (type(self) != type(other)): # pylint: disable=unidiomatic-typecheck
|
|
48
48
|
return False
|
|
49
49
|
|
|
50
|
-
return self.to_dict() == other.to_dict() # type: ignore[attr-defined]
|
|
50
|
+
return bool(self.to_dict() == other.to_dict()) # type: ignore[attr-defined]
|
|
51
51
|
|
|
52
52
|
def __str__(self) -> str:
|
|
53
53
|
return dumps(self)
|
|
@@ -68,11 +68,11 @@ def _custom_handle(value: typing.Any) -> typing.Union[typing.Dict[str, typing.An
|
|
|
68
68
|
return str(value)
|
|
69
69
|
|
|
70
70
|
if (hasattr(value, '__dict__')):
|
|
71
|
-
return vars(value)
|
|
71
|
+
return dict(vars(value))
|
|
72
72
|
|
|
73
73
|
raise ValueError(f"Could not JSON serialize object: '{value}'.")
|
|
74
74
|
|
|
75
|
-
def load(file_obj: typing.TextIO, strict: bool = False, **kwargs) -> typing.
|
|
75
|
+
def load(file_obj: typing.TextIO, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
|
|
76
76
|
"""
|
|
77
77
|
Load a file object/handler as JSON.
|
|
78
78
|
If strict is set, then use standard Python JSON,
|
|
@@ -84,7 +84,7 @@ def load(file_obj: typing.TextIO, strict: bool = False, **kwargs) -> typing.Dict
|
|
|
84
84
|
|
|
85
85
|
return json5.load(file_obj, **kwargs)
|
|
86
86
|
|
|
87
|
-
def loads(text: str, strict: bool = False, **kwargs) -> typing.
|
|
87
|
+
def loads(text: str, strict: bool = False, **kwargs: typing.Any) -> typing.Any:
|
|
88
88
|
"""
|
|
89
89
|
Load a string as JSON.
|
|
90
90
|
If strict is set, then use standard Python JSON,
|
|
@@ -100,7 +100,7 @@ def load_path(
|
|
|
100
100
|
path: str,
|
|
101
101
|
strict: bool = False,
|
|
102
102
|
encoding: str = edq.util.dirent.DEFAULT_ENCODING,
|
|
103
|
-
**kwargs) -> typing.
|
|
103
|
+
**kwargs: typing.Any) -> typing.Any:
|
|
104
104
|
"""
|
|
105
105
|
Load a file path as JSON.
|
|
106
106
|
If strict is set, then use standard Python JSON,
|
|
@@ -113,30 +113,30 @@ def load_path(
|
|
|
113
113
|
except Exception as ex:
|
|
114
114
|
raise ValueError(f"Failed to read JSON file '{path}'.") from ex
|
|
115
115
|
|
|
116
|
-
def loads_object(text: str, cls: typing.Type[DictConverter], **kwargs) -> DictConverter:
|
|
116
|
+
def loads_object(text: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
|
|
117
117
|
""" Load a JSON string into an object (which is a subclass of DictConverter). """
|
|
118
118
|
|
|
119
119
|
data = loads(text, **kwargs)
|
|
120
120
|
if (not isinstance(data, dict)):
|
|
121
121
|
raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
|
|
122
122
|
|
|
123
|
-
return cls.from_dict(data)
|
|
123
|
+
return cls.from_dict(data) # type: ignore[no-any-return]
|
|
124
124
|
|
|
125
|
-
def load_object_path(path: str, cls: typing.Type[DictConverter], **kwargs) -> DictConverter:
|
|
125
|
+
def load_object_path(path: str, cls: typing.Type[DictConverter], **kwargs: typing.Any) -> DictConverter:
|
|
126
126
|
""" Load a JSON file into an object (which is a subclass of DictConverter). """
|
|
127
127
|
|
|
128
128
|
data = load_path(path, **kwargs)
|
|
129
129
|
if (not isinstance(data, dict)):
|
|
130
130
|
raise ValueError(f"JSON to load into an object is not a dict, found '{type(data)}'.")
|
|
131
131
|
|
|
132
|
-
return cls.from_dict(data)
|
|
132
|
+
return cls.from_dict(data) # type: ignore[no-any-return]
|
|
133
133
|
|
|
134
134
|
def dump(
|
|
135
135
|
data: typing.Any,
|
|
136
136
|
file_obj: typing.TextIO,
|
|
137
137
|
default: typing.Union[typing.Callable, None] = _custom_handle,
|
|
138
138
|
sort_keys: bool = True,
|
|
139
|
-
**kwargs) -> None:
|
|
139
|
+
**kwargs: typing.Any) -> None:
|
|
140
140
|
""" Dump an object as a JSON file object. """
|
|
141
141
|
|
|
142
142
|
json.dump(data, file_obj, default = default, sort_keys = sort_keys, **kwargs)
|
|
@@ -145,7 +145,7 @@ def dumps(
|
|
|
145
145
|
data: typing.Any,
|
|
146
146
|
default: typing.Union[typing.Callable, None] = _custom_handle,
|
|
147
147
|
sort_keys: bool = True,
|
|
148
|
-
**kwargs) -> str:
|
|
148
|
+
**kwargs: typing.Any) -> str:
|
|
149
149
|
""" Dump an object as a JSON string. """
|
|
150
150
|
|
|
151
151
|
return json.dumps(data, default = default, sort_keys = sort_keys, **kwargs)
|
|
@@ -156,7 +156,7 @@ def dump_path(
|
|
|
156
156
|
default: typing.Union[typing.Callable, None] = _custom_handle,
|
|
157
157
|
sort_keys: bool = True,
|
|
158
158
|
encoding: str = edq.util.dirent.DEFAULT_ENCODING,
|
|
159
|
-
**kwargs) -> None:
|
|
159
|
+
**kwargs: typing.Any) -> None:
|
|
160
160
|
""" Dump an object as a JSON file. """
|
|
161
161
|
|
|
162
162
|
with open(path, 'w', encoding = encoding) as file:
|
|
@@ -71,3 +71,24 @@ def import_name(module_name: str, cache: bool = True) -> typing.Any:
|
|
|
71
71
|
_import_cache[cache_key] = module
|
|
72
72
|
|
|
73
73
|
return module
|
|
74
|
+
|
|
75
|
+
def fetch(name: str) -> typing.Any:
|
|
76
|
+
"""
|
|
77
|
+
Fetch an entity inside of a module.
|
|
78
|
+
Note that the target is not a module, but an attribute/object inside of the module.
|
|
79
|
+
The provided name should be fully qualified.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
parts = name.strip().rsplit('.', 1)
|
|
83
|
+
if (len(parts) != 2):
|
|
84
|
+
raise ValueError(f"Target name of fetch must be fully qualified, got '{name}'.")
|
|
85
|
+
|
|
86
|
+
module_name = parts[0]
|
|
87
|
+
short_name = parts[1]
|
|
88
|
+
|
|
89
|
+
module = import_name(module_name)
|
|
90
|
+
|
|
91
|
+
if (not hasattr(module, short_name)):
|
|
92
|
+
raise ValueError(f"Module '{module_name}' does not have attribute '{short_name}'.")
|
|
93
|
+
|
|
94
|
+
return getattr(module, short_name)
|
|
@@ -81,3 +81,39 @@ class TestPyImport(edq.testing.unittest.BaseTest):
|
|
|
81
81
|
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
82
82
|
|
|
83
83
|
self.assertIsNotNone(module)
|
|
84
|
+
|
|
85
|
+
def test_fetch_base(self):
|
|
86
|
+
""" Test fetching an attribute from a module. """
|
|
87
|
+
|
|
88
|
+
# [(name, error substring), ...]
|
|
89
|
+
test_cases = [
|
|
90
|
+
# Standard Module
|
|
91
|
+
('edq.util.pyimport.fetch', None),
|
|
92
|
+
|
|
93
|
+
# Errors
|
|
94
|
+
('', 'Target name of fetch must be fully qualified'),
|
|
95
|
+
('edq', 'Target name of fetch must be fully qualified'),
|
|
96
|
+
('ZZZ.aaa', 'Unable to locate module'),
|
|
97
|
+
('edq.ZZZ.aaa', 'Unable to locate module'),
|
|
98
|
+
('edq.util.pyimport.ZZZ', 'does not have attribute'),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for (i, test_case) in enumerate(test_cases):
|
|
102
|
+
(name, error_substring) = test_case
|
|
103
|
+
|
|
104
|
+
with self.subTest(msg = f"Case {i} ('{name}'):"):
|
|
105
|
+
try:
|
|
106
|
+
target = edq.util.pyimport.fetch(name)
|
|
107
|
+
except Exception as ex:
|
|
108
|
+
error_string = self.format_error_string(ex)
|
|
109
|
+
if (error_substring is None):
|
|
110
|
+
self.fail(f"Unexpected error: '{error_string}'.")
|
|
111
|
+
|
|
112
|
+
self.assertIn(error_substring, error_string, 'Error is not as expected.')
|
|
113
|
+
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if (error_substring is not None):
|
|
117
|
+
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
118
|
+
|
|
119
|
+
self.assertIsNotNone(target)
|
|
File without changes
|
|
File without changes
|
|
@@ -5,9 +5,22 @@ requirements-dev.txt
|
|
|
5
5
|
requirements.txt
|
|
6
6
|
edq/__init__.py
|
|
7
7
|
edq/py.typed
|
|
8
|
+
edq/cli/__init__.py
|
|
9
|
+
edq/cli/version.py
|
|
10
|
+
edq/cli/testing/__init__.py
|
|
11
|
+
edq/cli/testing/cli-test.py
|
|
12
|
+
edq/core/__init__.py
|
|
13
|
+
edq/core/argparser.py
|
|
14
|
+
edq/core/argparser_test.py
|
|
15
|
+
edq/core/log.py
|
|
16
|
+
edq/core/version.py
|
|
8
17
|
edq/testing/__init__.py
|
|
18
|
+
edq/testing/asserts.py
|
|
19
|
+
edq/testing/cli.py
|
|
20
|
+
edq/testing/cli_test.py
|
|
9
21
|
edq/testing/run.py
|
|
10
22
|
edq/testing/unittest.py
|
|
23
|
+
edq/testing/testdata/cli/tests/version_base.txt
|
|
11
24
|
edq/util/__init__.py
|
|
12
25
|
edq/util/dirent.py
|
|
13
26
|
edq/util/dirent_test.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_dir_1/dir_2/c.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|