edq-utils 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. edq/__init__.py +5 -0
  2. edq/cli/__init__.py +0 -0
  3. edq/cli/config/__init__.py +3 -0
  4. edq/cli/config/list.py +69 -0
  5. edq/cli/http/__init__.py +3 -0
  6. edq/cli/http/exchange-server.py +71 -0
  7. edq/cli/http/send-exchange.py +45 -0
  8. edq/cli/http/verify-exchanges.py +38 -0
  9. edq/cli/testing/__init__.py +3 -0
  10. edq/cli/testing/cli-test.py +49 -0
  11. edq/cli/version.py +28 -0
  12. edq/core/__init__.py +0 -0
  13. edq/core/argparser.py +137 -0
  14. edq/core/argparser_test.py +124 -0
  15. edq/core/config.py +268 -0
  16. edq/core/config_test.py +1038 -0
  17. edq/core/log.py +101 -0
  18. edq/core/version.py +6 -0
  19. edq/procedure/__init__.py +0 -0
  20. edq/procedure/verify_exchanges.py +85 -0
  21. edq/py.typed +0 -0
  22. edq/testing/__init__.py +3 -0
  23. edq/testing/asserts.py +65 -0
  24. edq/testing/cli.py +360 -0
  25. edq/testing/cli_test.py +15 -0
  26. edq/testing/httpserver.py +578 -0
  27. edq/testing/httpserver_test.py +424 -0
  28. edq/testing/run.py +142 -0
  29. edq/testing/testdata/cli/data/configs/empty/edq-config.json +1 -0
  30. edq/testing/testdata/cli/data/configs/simple-1/edq-config.json +4 -0
  31. edq/testing/testdata/cli/data/configs/simple-2/edq-config.json +3 -0
  32. edq/testing/testdata/cli/data/configs/value-number/edq-config.json +3 -0
  33. edq/testing/testdata/cli/tests/config/list/config_list_base.txt +16 -0
  34. edq/testing/testdata/cli/tests/config/list/config_list_config_value_number.txt +10 -0
  35. edq/testing/testdata/cli/tests/config/list/config_list_ignore_config.txt +14 -0
  36. edq/testing/testdata/cli/tests/config/list/config_list_no_config.txt +8 -0
  37. edq/testing/testdata/cli/tests/config/list/config_list_show_origin.txt +13 -0
  38. edq/testing/testdata/cli/tests/config/list/config_list_skip_header.txt +10 -0
  39. edq/testing/testdata/cli/tests/help_base.txt +9 -0
  40. edq/testing/testdata/cli/tests/platform_skip.txt +5 -0
  41. edq/testing/testdata/cli/tests/version_base.txt +6 -0
  42. edq/testing/testdata/http/exchanges/simple.httpex.json +5 -0
  43. edq/testing/testdata/http/exchanges/simple_anchor.httpex.json +5 -0
  44. edq/testing/testdata/http/exchanges/simple_file.httpex.json +10 -0
  45. edq/testing/testdata/http/exchanges/simple_file_binary.httpex.json +10 -0
  46. edq/testing/testdata/http/exchanges/simple_file_get_params.httpex.json +14 -0
  47. edq/testing/testdata/http/exchanges/simple_file_multiple.httpex.json +13 -0
  48. edq/testing/testdata/http/exchanges/simple_file_name.httpex.json +11 -0
  49. edq/testing/testdata/http/exchanges/simple_file_post_multiple.httpex.json +13 -0
  50. edq/testing/testdata/http/exchanges/simple_file_post_params.httpex.json +14 -0
  51. edq/testing/testdata/http/exchanges/simple_headers.httpex.json +8 -0
  52. edq/testing/testdata/http/exchanges/simple_jsonresponse_dict.httpex.json +7 -0
  53. edq/testing/testdata/http/exchanges/simple_jsonresponse_list.httpex.json +9 -0
  54. edq/testing/testdata/http/exchanges/simple_params.httpex.json +9 -0
  55. edq/testing/testdata/http/exchanges/simple_post.httpex.json +5 -0
  56. edq/testing/testdata/http/exchanges/simple_post_params.httpex.json +9 -0
  57. edq/testing/testdata/http/exchanges/simple_post_urlparams.httpex.json +5 -0
  58. edq/testing/testdata/http/exchanges/simple_urlparams.httpex.json +5 -0
  59. edq/testing/testdata/http/exchanges/specialcase_listparams_explicit.httpex.json +8 -0
  60. edq/testing/testdata/http/exchanges/specialcase_listparams_url.httpex.json +5 -0
  61. edq/testing/testdata/http/files/a.txt +1 -0
  62. edq/testing/testdata/http/files/tiny.png +0 -0
  63. edq/testing/unittest.py +88 -0
  64. edq/util/__init__.py +3 -0
  65. edq/util/dirent.py +340 -0
  66. edq/util/dirent_test.py +979 -0
  67. edq/util/encoding.py +18 -0
  68. edq/util/hash.py +41 -0
  69. edq/util/hash_test.py +89 -0
  70. edq/util/json.py +180 -0
  71. edq/util/json_test.py +228 -0
  72. edq/util/net.py +1008 -0
  73. edq/util/parse.py +33 -0
  74. edq/util/pyimport.py +94 -0
  75. edq/util/pyimport_test.py +119 -0
  76. edq/util/reflection.py +32 -0
  77. edq/util/time.py +75 -0
  78. edq/util/time_test.py +107 -0
  79. edq_utils-0.1.9.dist-info/METADATA +164 -0
  80. edq_utils-0.1.9.dist-info/RECORD +83 -0
  81. edq_utils-0.1.9.dist-info/WHEEL +5 -0
  82. edq_utils-0.1.9.dist-info/licenses/LICENSE +21 -0
  83. edq_utils-0.1.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple#a",
4
+ "response_body": "simple_anchor"
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "files": [
5
+ {
6
+ "path": "../files/a.txt"
7
+ }
8
+ ],
9
+ "response_body": "simple_file"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "files": [
5
+ {
6
+ "path": "../files/tiny.png"
7
+ }
8
+ ],
9
+ "response_body": "simple_file_binary"
10
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "parameters": {
5
+ "a": "1",
6
+ "b": "2"
7
+ },
8
+ "files": [
9
+ {
10
+ "path": "../files/a.txt"
11
+ }
12
+ ],
13
+ "response_body": "simple_file_get_params"
14
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "files": [
5
+ {
6
+ "path": "../files/a.txt"
7
+ },
8
+ {
9
+ "path": "../files/tiny.png"
10
+ }
11
+ ],
12
+ "response_body": "simple_file_multiple"
13
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "files": [
5
+ {
6
+ "path": "../files/a.txt",
7
+ "name": "foo.txt"
8
+ }
9
+ ],
10
+ "response_body": "simple_file_name"
11
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "method": "POST",
3
+ "url": "simple",
4
+ "files": [
5
+ {
6
+ "path": "../files/a.txt"
7
+ },
8
+ {
9
+ "path": "../files/tiny.png"
10
+ }
11
+ ],
12
+ "response_body": "simple_file_post_multiple"
13
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "method": "POST",
3
+ "url": "simple",
4
+ "parameters": {
5
+ "a": "1",
6
+ "b": "2"
7
+ },
8
+ "files": [
9
+ {
10
+ "path": "../files/a.txt"
11
+ }
12
+ ],
13
+ "response_body": "simple_file_post_params"
14
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "headers": {
5
+ "a": "1"
6
+ },
7
+ "response_body": "simple_headers"
8
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple/jsonresponse/dict",
4
+ "response_body": {
5
+ "a": 1
6
+ }
7
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple/jsonresponse/list",
4
+ "response_body": [
5
+ {
6
+ "a": 1
7
+ }
8
+ ]
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "parameters": {
5
+ "a": "1",
6
+ "b": "2"
7
+ },
8
+ "response_body": "simple_params"
9
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "method": "POST",
3
+ "url": "simple",
4
+ "response_body": "simple_post"
5
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "method": "POST",
3
+ "url": "simple",
4
+ "parameters": {
5
+ "a": "1",
6
+ "b": "2"
7
+ },
8
+ "response_body": "simple_post_params"
9
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "method": "POST",
3
+ "url": "simple?c=3&d=4",
4
+ "response_body": "simple_post_urlparams"
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple?c=3&d=4",
4
+ "response_body": "simple_urlparams"
5
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple",
4
+ "parameters": {
5
+ "a": ["1", "2"]
6
+ },
7
+ "response_body": "specialcase_listparams_explicit"
8
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "method": "GET",
3
+ "url": "simple?b=1&b=2",
4
+ "response_body": "specialcase_listparams_url"
5
+ }
@@ -0,0 +1 @@
1
+ a
Binary file
@@ -0,0 +1,88 @@
1
+ import typing
2
+ import unittest
3
+
4
+ import edq.util.json
5
+ import edq.util.reflection
6
+
7
+ FORMAT_STR: str = "\n--- Expected ---\n%s\n--- Actual ---\n%s\n---\n"
8
+
9
+ class BaseTest(unittest.TestCase):
10
+ """
11
+ A base class for unit tests.
12
+ """
13
+
14
+ maxDiff = None
15
+ """ Don't limit the size of diffs. """
16
+
17
+ def assertJSONEqual(self, a: typing.Any, b: typing.Any, message: typing.Union[str, None] = None) -> None: # pylint: disable=invalid-name
18
+ """
19
+ Like unittest.TestCase.assertEqual(),
20
+ but uses a default assertion message containing the full JSON representation of the arguments.
21
+ """
22
+
23
+ a_json = edq.util.json.dumps(a, indent = 4)
24
+ b_json = edq.util.json.dumps(b, indent = 4)
25
+
26
+ if (message is None):
27
+ message = FORMAT_STR % (a_json, b_json)
28
+
29
+ super().assertEqual(a, b, msg = message)
30
+
31
+ def assertJSONDictEqual(self, a: typing.Any, b: typing.Any, message: typing.Union[str, None] = None) -> None: # pylint: disable=invalid-name
32
+ """
33
+ Like unittest.TestCase.assertDictEqual(),
34
+ but will try to convert each comparison argument to a dict if it is not already,
35
+ and uses a default assertion message containing the full JSON representation of the arguments.
36
+ """
37
+
38
+ if (not isinstance(a, dict)):
39
+ if (isinstance(a, edq.util.json.DictConverter)):
40
+ a = a.to_dict()
41
+ else:
42
+ a = vars(a)
43
+
44
+ if (not isinstance(b, dict)):
45
+ if (isinstance(b, edq.util.json.DictConverter)):
46
+ b = b.to_dict()
47
+ else:
48
+ b = vars(b)
49
+
50
+ a_json = edq.util.json.dumps(a, indent = 4)
51
+ b_json = edq.util.json.dumps(b, indent = 4)
52
+
53
+ if (message is None):
54
+ message = FORMAT_STR % (a_json, b_json)
55
+
56
+ super().assertDictEqual(a, b, msg = message)
57
+
58
+ def assertJSONListEqual(self, a: typing.List[typing.Any], b: typing.List[typing.Any], message: typing.Union[str, None] = None) -> None: # pylint: disable=invalid-name
59
+ """
60
+ Call assertDictEqual(), but supply a default message containing the full JSON representation of the arguments.
61
+ """
62
+
63
+ a_json = edq.util.json.dumps(a, indent = 4)
64
+ b_json = edq.util.json.dumps(b, indent = 4)
65
+
66
+ if (message is None):
67
+ message = FORMAT_STR % (a_json, b_json)
68
+
69
+ super().assertListEqual(a, b, msg = message)
70
+
71
+ def format_error_string(self, ex: typing.Union[BaseException, None]) -> str:
72
+ """
73
+ Format an error string from an exception so it can be checked for testing.
74
+ The type of the error will be included,
75
+ and any nested errors will be joined together.
76
+ """
77
+
78
+ parts = []
79
+
80
+ while (ex is not None):
81
+ type_name = edq.util.reflection.get_qualified_name(ex)
82
+ message = str(ex)
83
+
84
+ parts.append(f"{type_name}: {message}")
85
+
86
+ ex = ex.__cause__
87
+
88
+ return "; ".join(parts)
edq/util/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ Low-level utilities.
3
+ """
edq/util/dirent.py ADDED
@@ -0,0 +1,340 @@
1
+ """
2
+ Operations relating to directory entries (dirents).
3
+
4
+ These operations are designed for clarity and compatibility, not performance.
5
+
6
+ Only directories, files, and links will be handled.
7
+ Other types of dirents may result in an error being raised.
8
+
9
+ In general, all recursive operations do not follow symlinks by default and instead treat the link as a file.
10
+ """
11
+
12
+ import atexit
13
+ import os
14
+ import shutil
15
+ import tempfile
16
+ import typing
17
+ import uuid
18
+
19
+ DEFAULT_ENCODING: str = 'utf-8'
20
+ """ The default encoding that will be used when reading and writing. """
21
+
22
+ DEPTH_LIMIT: int = 10000
23
+
24
+ def exists(path: str) -> bool:
25
+ """
26
+ Check if a path exists.
27
+ This will transparently call os.path.lexists(),
28
+ which will include broken links.
29
+ """
30
+
31
+ return os.path.lexists(path)
32
+
33
+ def get_temp_path(prefix: str = '', suffix: str = '', rm: bool = True) -> str:
34
+ """
35
+ Get a path to a valid (but not currently existing) temp dirent.
36
+ If rm is True, then the dirent will be attempted to be deleted on exit
37
+ (no error will occur if the path is not there).
38
+ """
39
+
40
+ path = None
41
+ while ((path is None) or exists(path)):
42
+ path = os.path.join(tempfile.gettempdir(), prefix + str(uuid.uuid4()) + suffix)
43
+
44
+ path = os.path.realpath(path)
45
+
46
+ if (rm):
47
+ atexit.register(remove, path)
48
+
49
+ return path
50
+
51
+ def get_temp_dir(prefix: str = '', suffix: str = '', rm: bool = True) -> str:
52
+ """
53
+ Get a temp directory.
54
+ The directory will exist when returned.
55
+ """
56
+
57
+ path = get_temp_path(prefix = prefix, suffix = suffix, rm = rm)
58
+ mkdir(path)
59
+ return path
60
+
61
+ def mkdir(raw_path: str) -> None:
62
+ """
63
+ Make a directory (including any required parent directories).
64
+ Does not complain if the directory (or parents) already exist
65
+ (this includes if the directory or parents are links to directories).
66
+ """
67
+
68
+ path = os.path.abspath(raw_path)
69
+
70
+ if (exists(path)):
71
+ if (os.path.isdir(path)):
72
+ return
73
+
74
+ raise ValueError(f"Target of mkdir already exists, and is not a dir: '{raw_path}'.")
75
+
76
+ _check_parent_dirs(raw_path)
77
+
78
+ os.makedirs(path, exist_ok = True)
79
+
80
+ def _check_parent_dirs(raw_path: str) -> None:
81
+ """
82
+ Check all parents to ensure that they are all dirs (or don't exist).
83
+ This is naturally handled by os.makedirs(),
84
+ but the error messages are not consistent between POSIX and Windows.
85
+ """
86
+
87
+ path = os.path.abspath(raw_path)
88
+
89
+ parent_path = path
90
+ for _ in range(DEPTH_LIMIT):
91
+ new_parent_path = os.path.dirname(parent_path)
92
+ if (parent_path == new_parent_path):
93
+ # We have reached root (are our own parent).
94
+ return
95
+
96
+ parent_path = new_parent_path
97
+
98
+ if (os.path.exists(parent_path) and (not os.path.isdir(parent_path))):
99
+ raise ValueError(f"Target of mkdir contains parent ('{os.path.basename(parent_path)}') that exists and is not a dir: '{raw_path}'.")
100
+
101
+ raise ValueError("Depth limit reached.")
102
+
103
+ def remove(path: str) -> None:
104
+ """
105
+ Remove the given path.
106
+ The path can be of any type (dir, file, link),
107
+ and does not need to exist.
108
+ """
109
+
110
+ if (not exists(path)):
111
+ return
112
+
113
+ if (os.path.isfile(path) or os.path.islink(path)):
114
+ os.remove(path)
115
+ elif (os.path.isdir(path)):
116
+ shutil.rmtree(path)
117
+ else:
118
+ raise ValueError(f"Unknown type of dirent: '{path}'.")
119
+
120
+ def same(a: str, b: str) -> bool:
121
+ """
122
+ Check if two paths represent the same dirent.
123
+ If either (or both) paths do not exist, false will be returned.
124
+ If either paths are links, they are resolved before checking
125
+ (so a link and the target file are considered the "same").
126
+ """
127
+
128
+ return (exists(a) and exists(b) and os.path.samefile(a, b))
129
+
130
+ def move(raw_source: str, raw_dest: str, no_clobber: bool = False) -> None:
131
+ """
132
+ Move the source dirent to the given destination.
133
+ Any existing destination will be removed before moving.
134
+ """
135
+
136
+ source = os.path.abspath(raw_source)
137
+ dest = os.path.abspath(raw_dest)
138
+
139
+ if (not exists(source)):
140
+ raise ValueError(f"Source of move does not exist: '{raw_source}'.")
141
+
142
+ # If dest is a dir, then resolve the path.
143
+ if (os.path.isdir(dest)):
144
+ dest = os.path.abspath(os.path.join(dest, os.path.basename(source)))
145
+
146
+ # Skip if this is self.
147
+ if (same(source, dest)):
148
+ return
149
+
150
+ # Check for clobber.
151
+ if (exists(dest)):
152
+ if (no_clobber):
153
+ raise ValueError(f"Destination of move already exists: '{raw_dest}'.")
154
+
155
+ remove(dest)
156
+
157
+ # Create any required parents.
158
+ os.makedirs(os.path.dirname(dest), exist_ok = True)
159
+
160
+ shutil.move(source, dest)
161
+
162
+ def copy(raw_source: str, raw_dest: str, no_clobber: bool = False) -> None:
163
+ """
164
+ Copy a dirent or directory to a destination.
165
+
166
+ The destination will be overwritten if it exists (and no_clobber is false).
167
+ For copying the contents of a directory INTO another directory, use copy_contents().
168
+
169
+ No copy is made if the source and dest refer to the same dirent.
170
+ """
171
+
172
+ source = os.path.abspath(raw_source)
173
+ dest = os.path.abspath(raw_dest)
174
+
175
+ if (same(source, dest)):
176
+ return
177
+
178
+ if (not exists(source)):
179
+ raise ValueError(f"Source of copy does not exist: '{raw_source}'.")
180
+
181
+ if (exists(dest)):
182
+ if (no_clobber):
183
+ raise ValueError(f"Destination of copy already exists: '{raw_dest}'.")
184
+
185
+ if (contains_path(dest, source)):
186
+ raise ValueError(f"Destination of copy cannot contain the source. Destination: '{raw_dest}', Source: '{raw_source}'.")
187
+
188
+ remove(dest)
189
+
190
+ mkdir(os.path.dirname(dest))
191
+
192
+ if (os.path.islink(source)):
193
+ # shutil.copy2() can generally handle (broken) links, but Windows is inconsistent (between 3.11 and 3.12) on link handling.
194
+ link_target = os.readlink(source)
195
+ os.symlink(link_target, dest)
196
+ elif (os.path.isfile(source)):
197
+ shutil.copy2(source, dest, follow_symlinks = False)
198
+ elif (os.path.isdir(source)):
199
+ mkdir(dest)
200
+
201
+ for child in sorted(os.listdir(source)):
202
+ copy(os.path.join(raw_source, child), os.path.join(raw_dest, child))
203
+ else:
204
+ raise ValueError(f"Source of copy is not a dir, fie, or link: '{raw_source}'.")
205
+
206
+ def copy_contents(raw_source: str, raw_dest: str, no_clobber: bool = False) -> None:
207
+ """
208
+ Copy a file or the contents of a directory (excluding the top-level directory itself) into a destination.
209
+ If the destination exists, it must be a directory.
210
+
211
+ The source and destination should not be the same file.
212
+
213
+ For a file, this is equivalent to `mkdir -p dest && cp source dest`
214
+ For a dir, this is equivalent to `mkdir -p dest && cp -r source/* dest`
215
+ """
216
+
217
+ source = os.path.abspath(raw_source)
218
+ dest = os.path.abspath(raw_dest)
219
+
220
+ if (same(source, dest)):
221
+ raise ValueError(f"Source and destination of contents copy cannot be the same: '{raw_source}'.")
222
+
223
+ if (exists(dest) and (not os.path.isdir(dest))):
224
+ raise ValueError(f"Destination of contents copy exists and is not a dir: '{raw_dest}'.")
225
+
226
+ mkdir(dest)
227
+
228
+ if (os.path.isfile(source) or os.path.islink(source)):
229
+ copy(source, os.path.join(dest, os.path.basename(source)), no_clobber = no_clobber)
230
+ elif (os.path.isdir(source)):
231
+ for child in sorted(os.listdir(source)):
232
+ copy(os.path.join(raw_source, child), os.path.join(raw_dest, child), no_clobber = no_clobber)
233
+ else:
234
+ raise ValueError(f"Source of contents copy is not a dir, fie, or link: '{raw_source}'.")
235
+
236
+ def read_file(raw_path: str, strip: bool = True, encoding: str = DEFAULT_ENCODING) -> str:
237
+ """ Read the contents of a file. """
238
+
239
+ path = os.path.abspath(raw_path)
240
+
241
+ if (not exists(path)):
242
+ raise ValueError(f"Source of read does not exist: '{raw_path}'.")
243
+
244
+ with open(path, 'r', encoding = encoding) as file:
245
+ contents = file.read()
246
+
247
+ if (strip):
248
+ contents = contents.strip()
249
+
250
+ return contents
251
+
252
+ def write_file(
253
+ raw_path: str, contents: typing.Union[str, None],
254
+ strip: bool = True, newline: bool = True,
255
+ encoding: str = DEFAULT_ENCODING,
256
+ no_clobber: bool = False) -> None:
257
+ """
258
+ Write the contents of a file.
259
+ If clobbering, any existing dirent will be removed before write.
260
+ """
261
+
262
+ path = os.path.abspath(raw_path)
263
+
264
+ if (exists(path)):
265
+ if (no_clobber):
266
+ raise ValueError(f"Destination of write already exists: '{raw_path}'.")
267
+
268
+ remove(path)
269
+
270
+ if (contents is None):
271
+ contents = ''
272
+
273
+ if (strip):
274
+ contents = contents.strip()
275
+
276
+ if (newline):
277
+ contents += "\n"
278
+
279
+ with open(path, 'w', encoding = encoding) as file:
280
+ file.write(contents)
281
+
282
+ def read_file_bytes(raw_path: str) -> bytes:
283
+ """ Read the contents of a file as bytes. """
284
+
285
+ path = os.path.abspath(raw_path)
286
+
287
+ if (not exists(path)):
288
+ raise ValueError(f"Source of read bytes does not exist: '{raw_path}'.")
289
+
290
+ with open(path, 'rb') as file:
291
+ return file.read()
292
+
293
+ def write_file_bytes(
294
+ raw_path: str, contents: typing.Union[bytes, None],
295
+ no_clobber: bool = False) -> None:
296
+ """
297
+ Write the contents of a file as bytes.
298
+ If clobbering, any existing dirent will be removed before write.
299
+ """
300
+
301
+ path = os.path.abspath(raw_path)
302
+
303
+ if (exists(path)):
304
+ if (no_clobber):
305
+ raise ValueError(f"Destination of write bytes already exists: '{raw_path}'.")
306
+
307
+ remove(path)
308
+
309
+ if (contents is None):
310
+ contents = b''
311
+
312
+ with open(path, 'wb') as file:
313
+ file.write(contents)
314
+
315
+ def contains_path(parent: str, child: str) -> bool:
316
+ """
317
+ Check if the parent path contains the child path.
318
+ This is pure lexical analysis, no dirent stats are checked.
319
+ Will return false if the (absolute) paths are the same
320
+ (this function does not allow a path to contain itself).
321
+ """
322
+
323
+ if ((parent == '') or (child == '')):
324
+ return False
325
+
326
+ parent = os.path.abspath(parent)
327
+ child = os.path.abspath(child)
328
+
329
+ child = os.path.dirname(child)
330
+ for _ in range(DEPTH_LIMIT):
331
+ if (parent == child):
332
+ return True
333
+
334
+ new_child = os.path.dirname(child)
335
+ if (child == new_child):
336
+ return False
337
+
338
+ child = new_child
339
+
340
+ raise ValueError("Depth limit reached.")