edq-utils 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of edq-utils might be problematic. Click here for more details.
- edq/__init__.py +5 -1
- edq/cli/__init__.py +0 -0
- edq/cli/testing/__init__.py +0 -0
- edq/cli/testing/cli-test.py +42 -0
- edq/cli/version.py +27 -0
- edq/core/__init__.py +0 -0
- edq/core/argparser.py +112 -0
- edq/core/argparser_test.py +124 -0
- edq/core/log.py +101 -0
- edq/core/version.py +6 -0
- edq/py.typed +0 -0
- edq/testing/__init__.py +3 -0
- edq/testing/asserts.py +66 -0
- edq/testing/cli.py +253 -0
- edq/testing/cli_test.py +8 -0
- edq/testing/run.py +34 -13
- edq/testing/testdata/cli/tests/version_base.txt +6 -0
- edq/testing/unittest.py +10 -3
- edq/util/__init__.py +3 -0
- edq/util/dirent.py +37 -3
- edq/util/dirent_test.py +120 -3
- edq/util/json.py +92 -9
- edq/util/json_test.py +109 -2
- edq/util/pyimport.py +21 -0
- edq/util/pyimport_test.py +36 -0
- {edq_utils-0.0.2.dist-info → edq_utils-0.0.4.dist-info}/METADATA +2 -1
- edq_utils-0.0.4.dist-info/RECORD +41 -0
- edq_utils-0.0.2.dist-info/RECORD +0 -26
- {edq_utils-0.0.2.dist-info → edq_utils-0.0.4.dist-info}/WHEEL +0 -0
- {edq_utils-0.0.2.dist-info → edq_utils-0.0.4.dist-info}/licenses/LICENSE +0 -0
- {edq_utils-0.0.2.dist-info → edq_utils-0.0.4.dist-info}/top_level.txt +0 -0
edq/testing/cli.py
ADDED
|
@@ -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)
|
edq/testing/cli_test.py
ADDED
edq/testing/run.py
CHANGED
|
@@ -11,10 +11,6 @@ import sys
|
|
|
11
11
|
import typing
|
|
12
12
|
import unittest
|
|
13
13
|
|
|
14
|
-
THIS_DIR: str = os.path.join(os.path.dirname(os.path.realpath(__file__)))
|
|
15
|
-
BASE_PACKAGE_DIR: str = os.path.join(THIS_DIR, '..')
|
|
16
|
-
PROJECT_ROOT_DIR: str = os.path.join(BASE_PACKAGE_DIR, '..')
|
|
17
|
-
|
|
18
14
|
DEFAULT_TEST_FILENAME_PATTERN: str = '*_test.py'
|
|
19
15
|
|
|
20
16
|
def _collect_tests(suite: typing.Union[unittest.TestCase, unittest.suite.TestSuite]) -> typing.List[unittest.TestCase]:
|
|
@@ -41,13 +37,22 @@ def run(args: argparse.Namespace) -> int:
|
|
|
41
37
|
Will raise if tests fail to load (e.g. syntax errors) and a suggested exit code otherwise.
|
|
42
38
|
"""
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
if (args.work_dir is not None):
|
|
41
|
+
os.chdir(args.work_dir)
|
|
42
|
+
|
|
43
|
+
if (args.path_additions is not None):
|
|
44
|
+
for path in args.path_additions:
|
|
45
|
+
sys.path.append(path)
|
|
46
|
+
|
|
47
|
+
if (args.test_dirs is None):
|
|
48
|
+
args.test_dirs = ['.']
|
|
47
49
|
|
|
48
50
|
runner = unittest.TextTestRunner(verbosity = 3)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
test_cases = []
|
|
52
|
+
|
|
53
|
+
for test_dir in args.test_dirs:
|
|
54
|
+
discovered_suite = unittest.TestLoader().discover(test_dir, pattern = args.filename_pattern)
|
|
55
|
+
test_cases += _collect_tests(discovered_suite)
|
|
51
56
|
|
|
52
57
|
tests = unittest.suite.TestSuite()
|
|
53
58
|
|
|
@@ -64,27 +69,43 @@ def run(args: argparse.Namespace) -> int:
|
|
|
64
69
|
faults = len(result.errors) + len(result.failures)
|
|
65
70
|
|
|
66
71
|
if (not result.wasSuccessful()):
|
|
67
|
-
# This value will be used as an exit status, so
|
|
72
|
+
# This value will be used as an exit status, so it should not be larger than a byte.
|
|
68
73
|
# (Some higher values are used specially, so just keep it at a round number.)
|
|
69
74
|
return max(1, min(faults, 100))
|
|
70
75
|
|
|
71
76
|
return 0
|
|
72
77
|
|
|
73
78
|
def main() -> int:
|
|
79
|
+
""" Parse the CLI arguments and run tests. """
|
|
80
|
+
|
|
74
81
|
args = _get_parser().parse_args()
|
|
75
82
|
return run(args)
|
|
76
83
|
|
|
77
84
|
def _get_parser() -> argparse.ArgumentParser:
|
|
85
|
+
""" Build a parser for CLI arguments. """
|
|
86
|
+
|
|
78
87
|
parser = argparse.ArgumentParser(description = 'Run unit tests discovered in this project.')
|
|
79
88
|
|
|
80
|
-
parser.add_argument('
|
|
81
|
-
action = 'store', type = str, default =
|
|
82
|
-
help = '
|
|
89
|
+
parser.add_argument('--work-dir', dest = 'work_dir',
|
|
90
|
+
action = 'store', type = str, default = os.getcwd(),
|
|
91
|
+
help = 'Set the working directory when running tests, defaults to the current working directory (%(default)s).')
|
|
92
|
+
|
|
93
|
+
parser.add_argument('--tests-dir', dest = 'test_dirs',
|
|
94
|
+
action = 'append',
|
|
95
|
+
help = 'Discover tests from these directories. Defaults to the current directory.')
|
|
96
|
+
|
|
97
|
+
parser.add_argument('--add-path', dest = 'path_additions',
|
|
98
|
+
action = 'append',
|
|
99
|
+
help = 'If supplied, add this path the sys.path before running tests.')
|
|
83
100
|
|
|
84
101
|
parser.add_argument('--filename-pattern', dest = 'filename_pattern',
|
|
85
102
|
action = 'store', type = str, default = DEFAULT_TEST_FILENAME_PATTERN,
|
|
86
103
|
help = 'The pattern to use to find test files (default: %(default)s).')
|
|
87
104
|
|
|
105
|
+
parser.add_argument('pattern',
|
|
106
|
+
action = 'store', type = str, default = None, nargs = '?',
|
|
107
|
+
help = 'If supplied, only tests with names matching this pattern will be run. This pattern is used directly in re.search().')
|
|
108
|
+
|
|
88
109
|
return parser
|
|
89
110
|
|
|
90
111
|
if __name__ == '__main__':
|
edq/testing/unittest.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import json
|
|
2
1
|
import typing
|
|
3
2
|
import unittest
|
|
4
3
|
|
|
@@ -15,13 +14,21 @@ class BaseTest(unittest.TestCase):
|
|
|
15
14
|
maxDiff = None
|
|
16
15
|
""" Don't limit the size of diffs. """
|
|
17
16
|
|
|
18
|
-
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
|
+
"""
|
|
19
|
+
Call assertDictEqual(), but supply a message containing the full JSON representation of the arguments.
|
|
20
|
+
"""
|
|
21
|
+
|
|
19
22
|
a_json = edq.util.json.dumps(a, indent = 4)
|
|
20
23
|
b_json = edq.util.json.dumps(b, indent = 4)
|
|
21
24
|
|
|
22
25
|
super().assertDictEqual(a, b, FORMAT_STR % (a_json, b_json))
|
|
23
26
|
|
|
24
|
-
def assertJSONListEqual(self, a:
|
|
27
|
+
def assertJSONListEqual(self, a: typing.List[typing.Any], b: typing.List[typing.Any]) -> None: # pylint: disable=invalid-name
|
|
28
|
+
"""
|
|
29
|
+
Call assertListEqual(), but supply a message containing the full JSON representation of the arguments.
|
|
30
|
+
"""
|
|
31
|
+
|
|
25
32
|
a_json = edq.util.json.dumps(a, indent = 4)
|
|
26
33
|
b_json = edq.util.json.dumps(b, indent = 4)
|
|
27
34
|
|
edq/util/__init__.py
CHANGED
edq/util/dirent.py
CHANGED
|
@@ -13,6 +13,7 @@ import atexit
|
|
|
13
13
|
import os
|
|
14
14
|
import shutil
|
|
15
15
|
import tempfile
|
|
16
|
+
import typing
|
|
16
17
|
import uuid
|
|
17
18
|
|
|
18
19
|
DEFAULT_ENCODING: str = 'utf-8'
|
|
@@ -114,7 +115,7 @@ def remove(path: str) -> None:
|
|
|
114
115
|
else:
|
|
115
116
|
raise ValueError(f"Unknown type of dirent: '{path}'.")
|
|
116
117
|
|
|
117
|
-
def same(a: str, b: str):
|
|
118
|
+
def same(a: str, b: str) -> bool:
|
|
118
119
|
"""
|
|
119
120
|
Check if two paths represent the same dirent.
|
|
120
121
|
If either (or both) paths do not exist, false will be returned.
|
|
@@ -247,10 +248,10 @@ def read_file(raw_path: str, strip: bool = True, encoding: str = DEFAULT_ENCODIN
|
|
|
247
248
|
return contents
|
|
248
249
|
|
|
249
250
|
def write_file(
|
|
250
|
-
raw_path: str, contents: str,
|
|
251
|
+
raw_path: str, contents: typing.Union[str, None],
|
|
251
252
|
strip: bool = True, newline: bool = True,
|
|
252
253
|
encoding: str = DEFAULT_ENCODING,
|
|
253
|
-
no_clobber = False) -> None:
|
|
254
|
+
no_clobber: bool = False) -> None:
|
|
254
255
|
"""
|
|
255
256
|
Write the contents of a file.
|
|
256
257
|
If clobbering, any existing dirent will be removed before write.
|
|
@@ -276,6 +277,39 @@ def write_file(
|
|
|
276
277
|
with open(path, 'w', encoding = encoding) as file:
|
|
277
278
|
file.write(contents)
|
|
278
279
|
|
|
280
|
+
def read_file_bytes(raw_path: str) -> bytes:
|
|
281
|
+
""" Read the contents of a file as bytes. """
|
|
282
|
+
|
|
283
|
+
path = os.path.abspath(raw_path)
|
|
284
|
+
|
|
285
|
+
if (not exists(path)):
|
|
286
|
+
raise ValueError(f"Source of read bytes does not exist: '{raw_path}'.")
|
|
287
|
+
|
|
288
|
+
with open(path, 'rb') as file:
|
|
289
|
+
return file.read()
|
|
290
|
+
|
|
291
|
+
def write_file_bytes(
|
|
292
|
+
raw_path: str, contents: typing.Union[bytes, None],
|
|
293
|
+
no_clobber: bool = False) -> None:
|
|
294
|
+
"""
|
|
295
|
+
Write the contents of a file as bytes.
|
|
296
|
+
If clobbering, any existing dirent will be removed before write.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
path = os.path.abspath(raw_path)
|
|
300
|
+
|
|
301
|
+
if (exists(path)):
|
|
302
|
+
if (no_clobber):
|
|
303
|
+
raise ValueError(f"Destination of write bytes already exists: '{raw_path}'.")
|
|
304
|
+
|
|
305
|
+
remove(path)
|
|
306
|
+
|
|
307
|
+
if (contents is None):
|
|
308
|
+
contents = b''
|
|
309
|
+
|
|
310
|
+
with open(path, 'wb') as file:
|
|
311
|
+
file.write(contents)
|
|
312
|
+
|
|
279
313
|
def contains_path(parent: str, child: str) -> bool:
|
|
280
314
|
"""
|
|
281
315
|
Check if the parent path contains the child path.
|
edq/util/dirent_test.py
CHANGED
|
@@ -92,6 +92,124 @@ class TestDirent(edq.testing.unittest.BaseTest):
|
|
|
92
92
|
actual = edq.util.dirent.contains_path(parent, child)
|
|
93
93
|
self.assertEqual(expected, actual)
|
|
94
94
|
|
|
95
|
+
def test_read_write_file_bytes_base(self):
|
|
96
|
+
""" Test reading and writing a file as bytes. """
|
|
97
|
+
|
|
98
|
+
# [(path, write kwargs, read kwargs, write contents, expected contents, error substring), ...]
|
|
99
|
+
# All conent should be strings that will be encoded.
|
|
100
|
+
test_cases = [
|
|
101
|
+
# Base
|
|
102
|
+
(
|
|
103
|
+
"test.txt",
|
|
104
|
+
{},
|
|
105
|
+
{},
|
|
106
|
+
"test",
|
|
107
|
+
"test",
|
|
108
|
+
None,
|
|
109
|
+
),
|
|
110
|
+
|
|
111
|
+
# Empty Write
|
|
112
|
+
(
|
|
113
|
+
"test.txt",
|
|
114
|
+
{},
|
|
115
|
+
{},
|
|
116
|
+
"",
|
|
117
|
+
"",
|
|
118
|
+
None,
|
|
119
|
+
),
|
|
120
|
+
|
|
121
|
+
# None Write
|
|
122
|
+
(
|
|
123
|
+
"test.txt",
|
|
124
|
+
{},
|
|
125
|
+
{},
|
|
126
|
+
None,
|
|
127
|
+
"",
|
|
128
|
+
None,
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
# Clobber
|
|
132
|
+
(
|
|
133
|
+
"a.txt",
|
|
134
|
+
{},
|
|
135
|
+
{},
|
|
136
|
+
"test",
|
|
137
|
+
"test",
|
|
138
|
+
None,
|
|
139
|
+
),
|
|
140
|
+
(
|
|
141
|
+
"dir_1",
|
|
142
|
+
{},
|
|
143
|
+
{},
|
|
144
|
+
"test",
|
|
145
|
+
"test",
|
|
146
|
+
None,
|
|
147
|
+
),
|
|
148
|
+
(
|
|
149
|
+
"symlink_a.txt",
|
|
150
|
+
{},
|
|
151
|
+
{},
|
|
152
|
+
"test",
|
|
153
|
+
"test",
|
|
154
|
+
None,
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
# No Clobber
|
|
158
|
+
(
|
|
159
|
+
"a.txt",
|
|
160
|
+
{'no_clobber': True},
|
|
161
|
+
{},
|
|
162
|
+
"test",
|
|
163
|
+
"test",
|
|
164
|
+
'already exists',
|
|
165
|
+
),
|
|
166
|
+
(
|
|
167
|
+
"dir_1",
|
|
168
|
+
{'no_clobber': True},
|
|
169
|
+
{},
|
|
170
|
+
"test",
|
|
171
|
+
"test",
|
|
172
|
+
'already exists',
|
|
173
|
+
),
|
|
174
|
+
(
|
|
175
|
+
"symlink_a.txt",
|
|
176
|
+
{'no_clobber': True},
|
|
177
|
+
{},
|
|
178
|
+
"test",
|
|
179
|
+
"test",
|
|
180
|
+
'already exists',
|
|
181
|
+
),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
for (i, test_case) in enumerate(test_cases):
|
|
185
|
+
(path, write_options, read_options, write_contents, expected_contents, error_substring) = test_case
|
|
186
|
+
|
|
187
|
+
with self.subTest(msg = f"Case {i} ('{path}'):"):
|
|
188
|
+
temp_dir = self._prep_temp_dir()
|
|
189
|
+
path = os.path.join(temp_dir, path)
|
|
190
|
+
|
|
191
|
+
if (write_contents is not None):
|
|
192
|
+
write_contents = bytes(write_contents, edq.util.dirent.DEFAULT_ENCODING)
|
|
193
|
+
|
|
194
|
+
expected_contents = bytes(expected_contents, edq.util.dirent.DEFAULT_ENCODING)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
edq.util.dirent.write_file_bytes(path, write_contents, **write_options)
|
|
198
|
+
actual_contents = edq.util.dirent.read_file_bytes(path, **read_options)
|
|
199
|
+
except Exception as ex:
|
|
200
|
+
error_string = self.format_error_string(ex)
|
|
201
|
+
if (error_substring is None):
|
|
202
|
+
self.fail(f"Unexpected error: '{error_string}'.")
|
|
203
|
+
|
|
204
|
+
self.assertIn(error_substring, error_string, 'Error is not as expected.')
|
|
205
|
+
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if (error_substring is not None):
|
|
209
|
+
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
210
|
+
|
|
211
|
+
self.assertEqual(expected_contents, actual_contents)
|
|
212
|
+
|
|
95
213
|
def test_read_write_file_base(self):
|
|
96
214
|
""" Test reading and writing a file. """
|
|
97
215
|
|
|
@@ -157,7 +275,7 @@ class TestDirent(edq.testing.unittest.BaseTest):
|
|
|
157
275
|
None,
|
|
158
276
|
),
|
|
159
277
|
|
|
160
|
-
# None
|
|
278
|
+
# None Write
|
|
161
279
|
(
|
|
162
280
|
"test.txt",
|
|
163
281
|
{'newline': False},
|
|
@@ -225,7 +343,6 @@ class TestDirent(edq.testing.unittest.BaseTest):
|
|
|
225
343
|
|
|
226
344
|
with self.subTest(msg = f"Case {i} ('{path}'):"):
|
|
227
345
|
temp_dir = self._prep_temp_dir()
|
|
228
|
-
|
|
229
346
|
path = os.path.join(temp_dir, path)
|
|
230
347
|
|
|
231
348
|
try:
|
|
@@ -243,7 +360,7 @@ class TestDirent(edq.testing.unittest.BaseTest):
|
|
|
243
360
|
if (error_substring is not None):
|
|
244
361
|
self.fail(f"Did not get expected error: '{error_substring}'.")
|
|
245
362
|
|
|
246
|
-
|
|
363
|
+
self.assertEqual(expected_contents, actual_contents)
|
|
247
364
|
|
|
248
365
|
def test_copy_contents_base(self):
|
|
249
366
|
"""
|