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.

Files changed (48) hide show
  1. {edq_utils-0.0.3/edq_utils.egg-info → edq_utils-0.0.4}/PKG-INFO +1 -1
  2. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/__init__.py +1 -1
  3. edq_utils-0.0.4/edq/cli/testing/cli-test.py +42 -0
  4. edq_utils-0.0.4/edq/cli/version.py +27 -0
  5. edq_utils-0.0.4/edq/core/argparser.py +112 -0
  6. edq_utils-0.0.4/edq/core/argparser_test.py +124 -0
  7. edq_utils-0.0.4/edq/core/log.py +101 -0
  8. edq_utils-0.0.4/edq/core/version.py +6 -0
  9. edq_utils-0.0.4/edq/py.typed +0 -0
  10. edq_utils-0.0.4/edq/testing/asserts.py +66 -0
  11. edq_utils-0.0.4/edq/testing/cli.py +253 -0
  12. edq_utils-0.0.4/edq/testing/cli_test.py +8 -0
  13. edq_utils-0.0.4/edq/testing/testdata/cli/tests/version_base.txt +6 -0
  14. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/testing/unittest.py +2 -2
  15. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/dirent.py +3 -3
  16. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/json.py +12 -12
  17. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/pyimport.py +21 -0
  18. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/pyimport_test.py +36 -0
  19. edq_utils-0.0.4/edq/util/testdata/dirent-operations/file_empty +0 -0
  20. edq_utils-0.0.4/edq/util/testdata/dirent-operations/symlink_file_empty +0 -0
  21. {edq_utils-0.0.3 → edq_utils-0.0.4/edq_utils.egg-info}/PKG-INFO +1 -1
  22. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/SOURCES.txt +13 -0
  23. {edq_utils-0.0.3 → edq_utils-0.0.4}/LICENSE +0 -0
  24. {edq_utils-0.0.3 → edq_utils-0.0.4}/README.md +0 -0
  25. /edq_utils-0.0.3/edq/py.typed → /edq_utils-0.0.4/edq/cli/__init__.py +0 -0
  26. /edq_utils-0.0.3/edq/util/testdata/dirent-operations/file_empty → /edq_utils-0.0.4/edq/cli/testing/__init__.py +0 -0
  27. /edq_utils-0.0.3/edq/util/testdata/dirent-operations/symlink_file_empty → /edq_utils-0.0.4/edq/core/__init__.py +0 -0
  28. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/testing/__init__.py +0 -0
  29. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/testing/run.py +0 -0
  30. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/__init__.py +0 -0
  31. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/dirent_test.py +0 -0
  32. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/json_test.py +0 -0
  33. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/reflection.py +0 -0
  34. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/a.txt +0 -0
  35. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/dir_1/b.txt +0 -0
  36. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/dir_1/dir_2/c.txt +0 -0
  37. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_a.txt +0 -0
  38. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_dir_1/b.txt +0 -0
  39. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/testdata/dirent-operations/symlink_dir_1/dir_2/c.txt +0 -0
  40. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/time.py +0 -0
  41. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq/util/time_test.py +0 -0
  42. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/dependency_links.txt +0 -0
  43. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/requires.txt +0 -0
  44. {edq_utils-0.0.3 → edq_utils-0.0.4}/edq_utils.egg-info/top_level.txt +0 -0
  45. {edq_utils-0.0.3 → edq_utils-0.0.4}/pyproject.toml +0 -0
  46. {edq_utils-0.0.3 → edq_utils-0.0.4}/requirements-dev.txt +0 -0
  47. {edq_utils-0.0.3 → edq_utils-0.0.4}/requirements.txt +0 -0
  48. {edq_utils-0.0.3 → edq_utils-0.0.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edq-utils
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Common utilities used by EduLinq Python projects.
5
5
  Author-email: Eriq Augustine <eriq@edulinq.org>
6
6
  License: MIT License
@@ -2,4 +2,4 @@
2
2
  General Python tools used by several EduLinq projects.
3
3
  """
4
4
 
5
- __version__ = '0.0.3'
5
+ __version__ = '0.0.4'
@@ -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()
@@ -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,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)
@@ -0,0 +1,8 @@
1
+ import edq.testing.cli
2
+ import edq.testing.unittest
3
+
4
+ class CLITest(edq.testing.unittest.BaseTest):
5
+ """ Test CLI invocations. """
6
+
7
+ # Populate CLITest with all the test methods.
8
+ edq.testing.cli.discover_test_cases(CLITest)
@@ -0,0 +1,6 @@
1
+ {
2
+ "cli": "edq.cli.version",
3
+ "arguments": [],
4
+ }
5
+ ---
6
+ <VERSION>
@@ -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: dict, b: dict) -> None: # pylint: disable=invalid-name
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: list, b: list) -> None: # pylint: disable=invalid-name
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.Dict[str, typing.Any]:
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.Dict[str, typing.Any]:
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.Dict[str, typing.Any]:
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edq-utils
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: Common utilities used by EduLinq Python projects.
5
5
  Author-email: Eriq Augustine <eriq@edulinq.org>
6
6
  License: MIT License
@@ -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