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