edq-utils 0.0.1__py3-none-any.whl → 0.0.3__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 CHANGED
@@ -1 +1,5 @@
1
- __version__ = '0.0.1'
1
+ """
2
+ General Python tools used by several EduLinq projects.
3
+ """
4
+
5
+ __version__ = '0.0.3'
edq/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """
2
+ Testing infrastructure.
3
+ """
edq/testing/run.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Discover and run unit tests (via Python's unittest package)
3
+ that live in this project's base package
4
+ (the parent of this package).
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ import re
10
+ import sys
11
+ import typing
12
+ import unittest
13
+
14
+ DEFAULT_TEST_FILENAME_PATTERN: str = '*_test.py'
15
+
16
+ def _collect_tests(suite: typing.Union[unittest.TestCase, unittest.suite.TestSuite]) -> typing.List[unittest.TestCase]:
17
+ """
18
+ Collect and return tests (unittest.TestCase) from the target directory.
19
+ """
20
+
21
+ if (isinstance(suite, unittest.TestCase)):
22
+ return [suite]
23
+
24
+ if (not isinstance(suite, unittest.suite.TestSuite)):
25
+ raise ValueError(f"Unknown test type: '{str(type(suite))}'.")
26
+
27
+ test_cases = []
28
+ for test_object in suite:
29
+ test_cases += _collect_tests(test_object)
30
+
31
+ return test_cases
32
+
33
+ def run(args: argparse.Namespace) -> int:
34
+ """
35
+ Discover and run unit tests.
36
+ This function may change your working directory.
37
+ Will raise if tests fail to load (e.g. syntax errors) and a suggested exit code otherwise.
38
+ """
39
+
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 = ['.']
49
+
50
+ runner = unittest.TextTestRunner(verbosity = 3)
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)
56
+
57
+ tests = unittest.suite.TestSuite()
58
+
59
+ for test_case in test_cases:
60
+ if (isinstance(test_case, unittest.loader._FailedTest)): # type: ignore[attr-defined]
61
+ raise ValueError(f"Failed to load test: '{test_case.id()}'.") from test_case._exception
62
+
63
+ if (args.pattern is None or re.search(args.pattern, test_case.id())):
64
+ tests.addTest(test_case)
65
+ else:
66
+ print(f"Skipping {test_case.id()} because of match pattern.")
67
+
68
+ result = runner.run(tests)
69
+ faults = len(result.errors) + len(result.failures)
70
+
71
+ if (not result.wasSuccessful()):
72
+ # This value will be used as an exit status, so it should not be larger than a byte.
73
+ # (Some higher values are used specially, so just keep it at a round number.)
74
+ return max(1, min(faults, 100))
75
+
76
+ return 0
77
+
78
+ def main() -> int:
79
+ """ Parse the CLI arguments and run tests. """
80
+
81
+ args = _get_parser().parse_args()
82
+ return run(args)
83
+
84
+ def _get_parser() -> argparse.ArgumentParser:
85
+ """ Build a parser for CLI arguments. """
86
+
87
+ parser = argparse.ArgumentParser(description = 'Run unit tests discovered in this project.')
88
+
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.')
100
+
101
+ parser.add_argument('--filename-pattern', dest = 'filename_pattern',
102
+ action = 'store', type = str, default = DEFAULT_TEST_FILENAME_PATTERN,
103
+ help = 'The pattern to use to find test files (default: %(default)s).')
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
+
109
+ return parser
110
+
111
+ if __name__ == '__main__':
112
+ sys.exit(main())
@@ -0,0 +1,54 @@
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 assertJSONDictEqual(self, a: dict, b: dict) -> None: # pylint: disable=invalid-name
18
+ """
19
+ Call assertDictEqual(), but supply a message containing the full JSON representation of the arguments.
20
+ """
21
+
22
+ a_json = edq.util.json.dumps(a, indent = 4)
23
+ b_json = edq.util.json.dumps(b, indent = 4)
24
+
25
+ super().assertDictEqual(a, b, FORMAT_STR % (a_json, b_json))
26
+
27
+ def assertJSONListEqual(self, a: list, b: list) -> None: # pylint: disable=invalid-name
28
+ """
29
+ Call assertListEqual(), but supply a message containing the full JSON representation of the arguments.
30
+ """
31
+
32
+ a_json = edq.util.json.dumps(a, indent = 4)
33
+ b_json = edq.util.json.dumps(b, indent = 4)
34
+
35
+ super().assertListEqual(a, b, FORMAT_STR % (a_json, b_json))
36
+
37
+ def format_error_string(self, ex: typing.Union[BaseException, None]) -> str:
38
+ """
39
+ Format an error string from an exception so it can be checked for testing.
40
+ The type of the error will be included,
41
+ and any nested errors will be joined together.
42
+ """
43
+
44
+ parts = []
45
+
46
+ while (ex is not None):
47
+ type_name = edq.util.reflection.get_qualified_name(ex)
48
+ message = str(ex)
49
+
50
+ parts.append(f"{type_name}: {message}")
51
+
52
+ ex = ex.__cause__
53
+
54
+ return "; ".join(parts)
edq/util/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ """
2
+ Low-level utilities.
3
+ """
edq/util/dirent.py CHANGED
@@ -1,12 +1,35 @@
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
+
1
12
  import atexit
2
13
  import os
3
14
  import shutil
4
15
  import tempfile
16
+ import typing
5
17
  import uuid
6
18
 
7
- DEAULT_ENCODING: str = 'utf-8'
19
+ DEFAULT_ENCODING: str = 'utf-8'
8
20
  """ The default encoding that will be used when reading and writing. """
9
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
+
10
33
  def get_temp_path(prefix: str = '', suffix: str = '', rm: bool = True) -> str:
11
34
  """
12
35
  Get a path to a valid (but not currently existing) temp dirent.
@@ -15,7 +38,7 @@ def get_temp_path(prefix: str = '', suffix: str = '', rm: bool = True) -> str:
15
38
  """
16
39
 
17
40
  path = None
18
- while ((path is None) or os.path.exists(path)):
41
+ while ((path is None) or exists(path)):
19
42
  path = os.path.join(tempfile.gettempdir(), prefix + str(uuid.uuid4()) + suffix)
20
43
 
21
44
  if (rm):
@@ -33,14 +56,48 @@ def get_temp_dir(prefix: str = '', suffix: str = '', rm: bool = True) -> str:
33
56
  mkdir(path)
34
57
  return path
35
58
 
36
- def mkdir(path: str) -> None:
59
+ def mkdir(raw_path: str) -> None:
37
60
  """
38
61
  Make a directory (including any required parent directories).
39
- Does not complain if the directory (or parents) already exist.
62
+ Does not complain if the directory (or parents) already exist
63
+ (this includes if the directory or parents are links to directories).
40
64
  """
41
65
 
66
+ path = os.path.abspath(raw_path)
67
+
68
+ if (exists(path)):
69
+ if (os.path.isdir(path)):
70
+ return
71
+
72
+ raise ValueError(f"Target of mkdir already exists, and is not a dir: '{raw_path}'.")
73
+
74
+ _check_parent_dirs(raw_path)
75
+
42
76
  os.makedirs(path, exist_ok = True)
43
77
 
78
+ def _check_parent_dirs(raw_path: str) -> None:
79
+ """
80
+ Check all parents to ensure that they are all dirs (or don't exist).
81
+ This is naturally handled by os.makedirs(),
82
+ but the error messages are not consistent between POSIX and Windows.
83
+ """
84
+
85
+ path = os.path.abspath(raw_path)
86
+
87
+ parent_path = path
88
+ for _ in range(DEPTH_LIMIT):
89
+ new_parent_path = os.path.dirname(parent_path)
90
+ if (parent_path == new_parent_path):
91
+ # We have reached root (are our own parent).
92
+ return
93
+
94
+ parent_path = new_parent_path
95
+
96
+ if (os.path.exists(parent_path) and (not os.path.isdir(parent_path))):
97
+ raise ValueError(f"Target of mkdir contains parent ('{os.path.basename(parent_path)}') that exists and is not a dir: '{raw_path}'.")
98
+
99
+ raise ValueError("Depth limit reached.")
100
+
44
101
  def remove(path: str) -> None:
45
102
  """
46
103
  Remove the given path.
@@ -48,7 +105,7 @@ def remove(path: str) -> None:
48
105
  and does not need to exist.
49
106
  """
50
107
 
51
- if (not os.path.exists(path)):
108
+ if (not exists(path)):
52
109
  return
53
110
 
54
111
  if (os.path.isfile(path) or os.path.islink(path)):
@@ -58,75 +115,130 @@ def remove(path: str) -> None:
58
115
  else:
59
116
  raise ValueError(f"Unknown type of dirent: '{path}'.")
60
117
 
61
- def move(source: str, dest: str) -> None:
118
+ def same(a: str, b: str):
119
+ """
120
+ Check if two paths represent the same dirent.
121
+ If either (or both) paths do not exist, false will be returned.
122
+ If either paths are links, they are resolved before checking
123
+ (so a link and the target file are considered the "same").
124
+ """
125
+
126
+ return (exists(a) and exists(b) and os.path.samefile(a, b))
127
+
128
+ def move(raw_source: str, raw_dest: str, no_clobber: bool = False) -> None:
62
129
  """
63
130
  Move the source dirent to the given destination.
64
131
  Any existing destination will be removed before moving.
65
132
  """
66
133
 
134
+ source = os.path.abspath(raw_source)
135
+ dest = os.path.abspath(raw_dest)
136
+
137
+ if (not exists(source)):
138
+ raise ValueError(f"Source of move does not exist: '{raw_source}'.")
139
+
67
140
  # If dest is a dir, then resolve the path.
68
141
  if (os.path.isdir(dest)):
69
142
  dest = os.path.abspath(os.path.join(dest, os.path.basename(source)))
70
143
 
71
144
  # Skip if this is self.
72
- if (os.path.exists(dest) and os.path.samefile(source, dest)):
145
+ if (same(source, dest)):
73
146
  return
74
147
 
75
- # Create any required parents.
76
- os.makedirs(os.path.dirname(dest), exist_ok = True)
148
+ # Check for clobber.
149
+ if (exists(dest)):
150
+ if (no_clobber):
151
+ raise ValueError(f"Destination of move already exists: '{raw_dest}'.")
77
152
 
78
- # Remove any existing dest.
79
- if (os.path.exists(dest)):
80
153
  remove(dest)
81
154
 
155
+ # Create any required parents.
156
+ os.makedirs(os.path.dirname(dest), exist_ok = True)
157
+
82
158
  shutil.move(source, dest)
83
159
 
84
- def copy(source: str, dest: str, dirs_exist_ok: bool = False) -> None:
160
+ def copy(raw_source: str, raw_dest: str, no_clobber: bool = False) -> None:
85
161
  """
86
- Copy a file or directory into dest.
87
- If source is a file, then dest can be a file or dir.
88
- If source is a dir, it is copied as a subdirectory of dest.
89
- If dirs_exist_ok is true, an existing destination directory is allowed.
162
+ Copy a dirent or directory to a destination.
163
+
164
+ The destination will be overwritten if it exists (and no_clobber is false).
165
+ For copying the contents of a directory INTO another directory, use copy_contents().
166
+
167
+ No copy is made if the source and dest refer to the same dirent.
90
168
  """
91
169
 
92
- if (os.path.isfile(source) or os.path.islink(source)):
93
- os.makedirs(os.path.dirname(dest), exist_ok = True)
170
+ source = os.path.abspath(raw_source)
171
+ dest = os.path.abspath(raw_dest)
94
172
 
95
- try:
96
- shutil.copy2(source, dest, follow_symlinks = False)
97
- except shutil.SameFileError:
98
- return
99
- else:
100
- if (os.path.isdir(dest)):
101
- dest = os.path.join(dest, os.path.basename(source))
173
+ if (same(source, dest)):
174
+ return
175
+
176
+ if (not exists(source)):
177
+ raise ValueError(f"Source of copy does not exist: '{raw_source}'.")
102
178
 
103
- shutil.copytree(source, dest, dirs_exist_ok = dirs_exist_ok, symlinks = True)
179
+ if (exists(dest)):
180
+ if (no_clobber):
181
+ raise ValueError(f"Destination of copy already exists: '{raw_dest}'.")
104
182
 
105
- def copy_contents(source: str, dest: str) -> None:
183
+ if (contains_path(dest, source)):
184
+ raise ValueError(f"Destination of copy cannot contain the source. Destination: '{raw_dest}', Source: '{raw_source}'.")
185
+
186
+ remove(dest)
187
+
188
+ mkdir(os.path.dirname(dest))
189
+
190
+ if (os.path.islink(source)):
191
+ # shutil.copy2() can generally handle (broken) links, but Windows is inconsistent (between 3.11 and 3.12) on link handling.
192
+ link_target = os.readlink(source)
193
+ os.symlink(link_target, dest)
194
+ elif (os.path.isfile(source)):
195
+ shutil.copy2(source, dest, follow_symlinks = False)
196
+ elif (os.path.isdir(source)):
197
+ mkdir(dest)
198
+
199
+ for child in sorted(os.listdir(source)):
200
+ copy(os.path.join(raw_source, child), os.path.join(raw_dest, child))
201
+ else:
202
+ raise ValueError(f"Source of copy is not a dir, fie, or link: '{raw_source}'.")
203
+
204
+ def copy_contents(raw_source: str, raw_dest: str, no_clobber: bool = False) -> None:
106
205
  """
107
- Copy a file or the contents of a directory (excluding the top-level directory) into dest.
108
- For a file: `cp source dest/`
109
- For a dir: `cp -r source/* dest/`
206
+ Copy a file or the contents of a directory (excluding the top-level directory itself) into a destination.
207
+ If the destination exists, it must be a directory.
208
+
209
+ The source and destination should not be the same file.
210
+
211
+ For a file, this is equivalent to `mkdir -p dest && cp source dest`
212
+ For a dir, this is equivalent to `mkdir -p dest && cp -r source/* dest`
110
213
  """
111
214
 
112
- source = os.path.abspath(source)
215
+ source = os.path.abspath(raw_source)
216
+ dest = os.path.abspath(raw_dest)
113
217
 
114
- if (os.path.isfile(source)):
115
- copy(source, dest)
116
- return
218
+ if (same(source, dest)):
219
+ raise ValueError(f"Source and destination of contents copy cannot be the same: '{raw_source}'.")
117
220
 
118
- for dirent in os.listdir(source):
119
- source_path = os.path.join(source, dirent)
120
- dest_path = os.path.join(dest, dirent)
221
+ if (exists(dest) and (not os.path.isdir(dest))):
222
+ raise ValueError(f"Destination of contents copy exists and is not a dir: '{raw_dest}'.")
121
223
 
122
- if (os.path.isfile(source_path) or os.path.islink(source_path)):
123
- copy(source_path, dest_path)
124
- else:
125
- shutil.copytree(source_path, dest_path, symlinks = True)
224
+ mkdir(dest)
126
225
 
127
- def read_file(path: str, strip: bool = True, encoding: str = DEAULT_ENCODING) -> str:
226
+ if (os.path.isfile(source) or os.path.islink(source)):
227
+ copy(source, os.path.join(dest, os.path.basename(source)), no_clobber = no_clobber)
228
+ elif (os.path.isdir(source)):
229
+ for child in sorted(os.listdir(source)):
230
+ copy(os.path.join(raw_source, child), os.path.join(raw_dest, child), no_clobber = no_clobber)
231
+ else:
232
+ raise ValueError(f"Source of contents copy is not a dir, fie, or link: '{raw_source}'.")
233
+
234
+ def read_file(raw_path: str, strip: bool = True, encoding: str = DEFAULT_ENCODING) -> str:
128
235
  """ Read the contents of a file. """
129
236
 
237
+ path = os.path.abspath(raw_path)
238
+
239
+ if (not exists(path)):
240
+ raise ValueError(f"Source of read does not exist: '{raw_path}'.")
241
+
130
242
  with open(path, 'r', encoding = encoding) as file:
131
243
  contents = file.read()
132
244
 
@@ -135,12 +247,24 @@ def read_file(path: str, strip: bool = True, encoding: str = DEAULT_ENCODING) ->
135
247
 
136
248
  return contents
137
249
 
138
- def write_file(path: str, contents: str, strip: bool = True, newline: bool = True, encoding: str = DEAULT_ENCODING) -> None:
250
+ def write_file(
251
+ raw_path: str, contents: typing.Union[str, None],
252
+ strip: bool = True, newline: bool = True,
253
+ encoding: str = DEFAULT_ENCODING,
254
+ no_clobber = False) -> None:
139
255
  """
140
256
  Write the contents of a file.
141
- Any existing file will be truncated.
257
+ If clobbering, any existing dirent will be removed before write.
142
258
  """
143
259
 
260
+ path = os.path.abspath(raw_path)
261
+
262
+ if (exists(path)):
263
+ if (no_clobber):
264
+ raise ValueError(f"Destination of write already exists: '{raw_path}'.")
265
+
266
+ remove(path)
267
+
144
268
  if (contents is None):
145
269
  contents = ''
146
270
 
@@ -152,3 +276,63 @@ def write_file(path: str, contents: str, strip: bool = True, newline: bool = Tru
152
276
 
153
277
  with open(path, 'w', encoding = encoding) as file:
154
278
  file.write(contents)
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 = 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
+
313
+ def contains_path(parent: str, child: str) -> bool:
314
+ """
315
+ Check if the parent path contains the child path.
316
+ This is pure lexical analysis, no dirent stats are checked.
317
+ Will return false if the (absolute) paths are the same
318
+ (this function does not allow a path to contain itself).
319
+ """
320
+
321
+ if ((parent == '') or (child == '')):
322
+ return False
323
+
324
+ parent = os.path.abspath(parent)
325
+ child = os.path.abspath(child)
326
+
327
+ child = os.path.dirname(child)
328
+ for _ in range(DEPTH_LIMIT):
329
+ if (parent == child):
330
+ return True
331
+
332
+ new_child = os.path.dirname(child)
333
+ if (child == new_child):
334
+ return False
335
+
336
+ child = new_child
337
+
338
+ raise ValueError("Depth limit reached.")