dploy 0.1.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.
dploy/main.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ The logic and workings behind the stow and unstow sub-commands
3
+ """
4
+
5
+ from collections import defaultdict
6
+ import pathlib
7
+ from dploy import actions
8
+ from dploy import error
9
+ from dploy import ignore
10
+
11
+
12
+ # pylint: disable=too-few-public-methods
13
+ class Input:
14
+ """
15
+ Input validator abstract base class
16
+ """
17
+
18
+ def __init__(self, errors, subcmd):
19
+ self.errors = errors
20
+ self.subcmd = subcmd
21
+
22
+ def is_valid(self, sources, dest):
23
+ """
24
+ Checks if the passes in source and dest are valid
25
+ """
26
+ is_input_valid = True
27
+ if not self._is_there_duplicate_sources(sources) and self._is_valid_dest(dest):
28
+ for source in sources:
29
+ if not self._is_valid_source(source):
30
+ is_input_valid = False
31
+ else:
32
+ is_input_valid = False
33
+
34
+ return is_input_valid
35
+
36
+ def _is_there_duplicate_sources(self, sources):
37
+ """
38
+ Checks sources to see if there are any duplicates
39
+ """
40
+
41
+ is_there_duplicates = False
42
+
43
+ tally = defaultdict(int)
44
+ for source in sources:
45
+ tally[source] += 1
46
+
47
+ for source, count in tally.items():
48
+ if count > 1:
49
+ is_there_duplicates = True
50
+ self.errors.add(error.DuplicateSource(self.subcmd, source))
51
+
52
+ return is_there_duplicates
53
+
54
+ def _is_valid_dest(self, dest):
55
+ """
56
+ Abstract method to check if the dest input to a sub-command is valid
57
+ """
58
+ pass
59
+
60
+ def _is_valid_source(self, source):
61
+ """
62
+ Abstract method to check if the source input to a sub-command is valid
63
+ """
64
+ pass
65
+
66
+
67
+ # pylint: disable=too-few-public-methods
68
+ class AbstractBaseSubCommand:
69
+ """
70
+ An abstract class to unify shared functionality in stow commands
71
+ """
72
+
73
+ # pylint: disable=too-many-arguments
74
+ def __init__(self, subcmd, sources, dest, is_silent, is_dry_run, ignore_patterns):
75
+ self.subcmd = subcmd
76
+
77
+ self.actions = actions.Actions(is_silent, is_dry_run)
78
+ self.errors = error.Errors(is_silent)
79
+
80
+ self.is_silent = is_silent
81
+ self.is_dry_run = is_dry_run
82
+
83
+ self.dest_input = pathlib.Path(dest)
84
+ source_inputs = [pathlib.Path(source) for source in sources]
85
+
86
+ if self._is_valid_input(source_inputs, self.dest_input):
87
+ for source in source_inputs:
88
+ self.ignore = ignore.Ignore(ignore_patterns, source)
89
+
90
+ if self.ignore.should_ignore(source):
91
+ self.ignore.ignore(source)
92
+ continue
93
+
94
+ self._collect_actions(source, self.dest_input)
95
+
96
+ self._check_for_other_actions()
97
+ self._execute_actions()
98
+
99
+ def _check_for_other_actions(self):
100
+ """
101
+ Abstract method for examine the existing action to see if more actions
102
+ need to be added or if some actions need to be removed.
103
+ """
104
+ pass
105
+
106
+ def _is_valid_input(self, sources, dest):
107
+ """
108
+ Abstract method to check if the input to a sub-command is valid
109
+ """
110
+ pass
111
+
112
+ def _collect_actions(self, source, dest):
113
+ """
114
+ Abstract method that collects the actions required to complete a
115
+ sub-command.
116
+ """
117
+ pass
118
+
119
+ def _execute_actions(self):
120
+ """
121
+ Either executes collected actions by a sub command or raises collected
122
+ exceptions.
123
+ """
124
+ self.errors.handle()
125
+ self.actions.execute()
dploy/stowcmd.py ADDED
@@ -0,0 +1,442 @@
1
+ """
2
+ The logic and workings behind the stow and unstow sub-commands
3
+ """
4
+
5
+ from collections import Counter
6
+ import pathlib
7
+ from dploy import actions
8
+ from dploy import utils
9
+ from dploy import error
10
+ from dploy import main
11
+ from dploy import ignore
12
+
13
+
14
+ # pylint: disable=too-few-public-methods
15
+ class AbstractBaseStow(main.AbstractBaseSubCommand):
16
+ """
17
+ Abstract Base class that contains the shared logic for all of the stow
18
+ commands
19
+ """
20
+
21
+ # pylint: disable=too-many-arguments
22
+ def __init__(self, subcmd, source, dest, is_silent, is_dry_run, ignore_patterns):
23
+ self.is_unfolding = False
24
+ super().__init__(subcmd, source, dest, is_silent, is_dry_run, ignore_patterns)
25
+
26
+ def _is_valid_input(self, sources, dest):
27
+ """
28
+ Check to see if the input is valid
29
+ """
30
+ return StowInput(self.errors, self.subcmd).is_valid(sources, dest)
31
+
32
+ def get_directory_contents(self, directory):
33
+ """
34
+ Get the contents of a directory while handling errors that may occur
35
+ """
36
+ contents = []
37
+
38
+ try:
39
+ contents = utils.get_directory_contents(directory)
40
+ except PermissionError:
41
+ self.errors.add(error.PermissionDenied(self.subcmd, directory))
42
+ except FileNotFoundError:
43
+ self.errors.add(error.NoSuchFileOrDirectory(self.subcmd, directory))
44
+ except NotADirectoryError:
45
+ self.errors.add(error.NoSuchDirectory(self.subcmd, directory))
46
+
47
+ return contents
48
+
49
+ def _are_same_file(self, source, dest):
50
+ """
51
+ Abstract method that handles the case when the source and dest are the
52
+ same file when collecting actions
53
+ """
54
+ pass
55
+
56
+ def _are_directories(self, source, dest):
57
+ """
58
+ Abstract method that handles the case when the source and dest are directories
59
+ same file when collecting actions
60
+ """
61
+ pass
62
+
63
+ def _are_other(self, source, dest):
64
+ """
65
+ Abstract method that handles all other cases what to do if no particular
66
+ condition is true cases are found
67
+ """
68
+ pass
69
+
70
+ def _collect_actions_existing_dest(self, source, dest):
71
+ """
72
+ _collect_actions() helper to collect required actions to perform a stow
73
+ command when the destination already exists
74
+ """
75
+ if utils.is_same_file(dest, source):
76
+ if dest.is_symlink() or self.is_unfolding:
77
+ self._are_same_file(source, dest)
78
+ else:
79
+ self.errors.add(error.SourceIsSameAsDest(self.subcmd, dest.parent))
80
+
81
+ elif dest.is_dir() and source.is_dir:
82
+ self._are_directories(source, dest)
83
+ else:
84
+ self.errors.add(error.ConflictsWithExistingFile(self.subcmd, source, dest))
85
+
86
+ def _collect_actions(self, source, dest):
87
+ """
88
+ Concrete method to collect required actions to perform a stow
89
+ sub-command
90
+ """
91
+
92
+ if self.ignore.should_ignore(source):
93
+ self.ignore.ignore(source)
94
+ return
95
+
96
+ if not StowInput(self.errors, self.subcmd).is_valid_collection_input(
97
+ source, dest
98
+ ):
99
+ return
100
+
101
+ sources = self.get_directory_contents(source)
102
+
103
+ for subsources in sources:
104
+ if self.ignore.should_ignore(subsources):
105
+ self.ignore.ignore(subsources)
106
+ continue
107
+
108
+ dest_path = dest / pathlib.Path(subsources.name)
109
+
110
+ does_dest_path_exist = False
111
+ try:
112
+ does_dest_path_exist = dest_path.exists()
113
+ except PermissionError:
114
+ self.errors.add(error.PermissionDenied(self.subcmd, dest_path))
115
+ return
116
+
117
+ if does_dest_path_exist:
118
+ self._collect_actions_existing_dest(subsources, dest_path)
119
+ elif dest_path.is_symlink():
120
+ self.errors.add(
121
+ error.ConflictsWithExistingLink(self.subcmd, subsources, dest_path)
122
+ )
123
+ elif not dest_path.parent.exists() and not self.is_unfolding:
124
+ self.errors.add(error.NoSuchDirectory(self.subcmd, dest_path.parent))
125
+ else:
126
+ self._are_other(subsources, dest_path)
127
+
128
+
129
+ # pylint: disable=too-few-public-methods
130
+ class Stow(AbstractBaseStow):
131
+ """
132
+ Concrete class implementation of the stow sub-command
133
+ """
134
+
135
+ # pylint: disable=too-many-arguments
136
+ def __init__(
137
+ self, source, dest, is_silent=True, is_dry_run=False, ignore_patterns=None
138
+ ):
139
+ super().__init__("stow", source, dest, is_silent, is_dry_run, ignore_patterns)
140
+
141
+ def _unfold(self, source, dest):
142
+ """
143
+ Method unfold a destination directory
144
+ """
145
+ self.is_unfolding = True
146
+ self.actions.add(actions.UnLink(self.subcmd, dest))
147
+ self.actions.add(actions.MakeDirectory(self.subcmd, dest))
148
+ self._collect_actions(source, dest)
149
+ self.is_unfolding = False
150
+
151
+ def _handle_duplicate_actions(self):
152
+ """
153
+ check for symbolic link actions that would cause conflicting symbolic
154
+ links to the same destination. Also check for actions that conflict but
155
+ are candidates for unfolding instead.
156
+ """
157
+ has_conflicts = False
158
+ dupes = self.actions.get_duplicates()
159
+
160
+ if len(dupes) == 0:
161
+ return
162
+
163
+ for indices in dupes:
164
+ first_action = self.actions.actions[indices[0]]
165
+ remaining_actions = [self.actions.actions[i] for i in indices[1:]]
166
+
167
+ if first_action.source.is_dir():
168
+ self._unfold(first_action.source, first_action.dest)
169
+
170
+ for action in remaining_actions:
171
+ self.is_unfolding = True
172
+ self._collect_actions(action.source, action.dest)
173
+ self.is_unfolding = False
174
+ else:
175
+ duplicate_action_sources = [
176
+ str(self.actions.actions[i].source) for i in indices
177
+ ]
178
+ self.errors.add(
179
+ error.ConflictsWithAnotherSource(
180
+ self.subcmd, duplicate_action_sources
181
+ )
182
+ )
183
+ has_conflicts = True
184
+
185
+ if has_conflicts:
186
+ return
187
+
188
+ # remove duplicates
189
+ for indices in dupes:
190
+ for index in reversed(indices[1:]):
191
+ del self.actions.actions[index]
192
+
193
+ self._handle_duplicate_actions()
194
+
195
+ def _check_for_other_actions(self):
196
+ self._handle_duplicate_actions()
197
+
198
+ def _are_same_file(self, source, dest):
199
+ """
200
+ what to do if source and dest are the same files
201
+ """
202
+ if self.is_unfolding:
203
+ self.actions.add(actions.SymbolicLink(self.subcmd, source, dest))
204
+ else:
205
+ self.actions.add(actions.AlreadyLinked(self.subcmd, source, dest))
206
+
207
+ def _are_directories(self, source, dest):
208
+ if dest.is_symlink():
209
+ self._unfold(dest.resolve(), dest)
210
+ self._collect_actions(source, dest)
211
+
212
+ def _are_other(self, source, dest):
213
+ self.actions.add(actions.SymbolicLink(self.subcmd, source, dest))
214
+
215
+
216
+ # pylint: disable=too-few-public-methods
217
+ class UnStow(AbstractBaseStow):
218
+ """
219
+ Concrete class implementation of the unstow sub-command
220
+ """
221
+
222
+ # pylint: disable=too-many-arguments
223
+ def __init__(
224
+ self, source, dest, is_silent=True, is_dry_run=False, ignore_patterns=None
225
+ ):
226
+ super().__init__("unstow", source, dest, is_silent, is_dry_run, ignore_patterns)
227
+
228
+ def _are_same_file(self, source, dest):
229
+ """
230
+ what to do if source and dest are the same files
231
+ """
232
+ self.actions.add(actions.UnLink(self.subcmd, dest))
233
+
234
+ def _are_directories(self, source, dest):
235
+ self._collect_actions(source, dest)
236
+
237
+ def _are_other(self, source, dest):
238
+ self.actions.add(actions.AlreadyUnlinked(self.subcmd, source, dest))
239
+
240
+ def _check_for_other_actions(self):
241
+ self._collect_folding_actions()
242
+
243
+ def _collect_folding_actions(self):
244
+ """
245
+ find candidates for folding i.e. when a directory contains symlinks to
246
+ files that all share the same parent directory
247
+ """
248
+ for parent in self.actions.get_unlink_target_parents():
249
+ items = utils.get_directory_contents(parent)
250
+ other_links_parents = []
251
+ other_links = []
252
+ source_parent = None
253
+ is_normal_files_detected = False
254
+
255
+ for item in items:
256
+ if item not in self.actions.get_unlink_targets():
257
+ does_item_exist = False
258
+ try:
259
+ does_item_exist = item.exists()
260
+ except PermissionError:
261
+ self.errors.add(error.PermissionDenied(self.subcmd, item))
262
+ return
263
+
264
+ if does_item_exist and item.is_symlink():
265
+ source_parent = item.resolve().parent
266
+ other_links_parents.append(item.resolve().parent)
267
+ other_links.append(item)
268
+ else:
269
+ is_normal_files_detected = True
270
+ break
271
+
272
+ if not is_normal_files_detected:
273
+ other_links_parent_count = len(Counter(other_links_parents))
274
+
275
+ if other_links_parent_count == 1:
276
+ assert source_parent is not None
277
+ if utils.is_same_files(
278
+ utils.get_directory_contents(source_parent), other_links
279
+ ):
280
+ self._fold(source_parent, parent)
281
+
282
+ elif other_links_parent_count == 0 and not utils.is_same_file(
283
+ parent, self.dest_input
284
+ ):
285
+ self.actions.add(actions.RemoveDirectory(self.subcmd, parent))
286
+
287
+ def _fold(self, source, dest):
288
+ """
289
+ add the required actions for folding
290
+ """
291
+ self._collect_actions(source, dest)
292
+ self.actions.add(actions.RemoveDirectory(self.subcmd, dest))
293
+ self.actions.add(actions.SymbolicLink(self.subcmd, source, dest))
294
+
295
+
296
+ class StowInput(main.Input):
297
+ """
298
+ Input validator for the link command
299
+ """
300
+
301
+ def _is_valid_dest(self, dest):
302
+ """
303
+ Check if the test argument is valid
304
+ """
305
+ result = True
306
+
307
+ if not dest.is_dir():
308
+ self.errors.add(error.NoSuchDirectoryToSubcmdInto(self.subcmd, dest))
309
+ result = False
310
+ else:
311
+ if not utils.is_directory_writable(dest):
312
+ self.errors.add(
313
+ error.InsufficientPermissionsToSubcmdTo(self.subcmd, dest)
314
+ )
315
+ result = False
316
+
317
+ if not utils.is_directory_readable(dest):
318
+ self.errors.add(
319
+ error.InsufficientPermissionsToSubcmdTo(self.subcmd, dest)
320
+ )
321
+ result = False
322
+
323
+ if not utils.is_directory_executable(dest):
324
+ self.errors.add(
325
+ error.InsufficientPermissionsToSubcmdTo(self.subcmd, dest)
326
+ )
327
+ result = False
328
+
329
+ return result
330
+
331
+ def _is_valid_source(self, source):
332
+ """
333
+ Check if the source argument is valid
334
+ """
335
+ result = True
336
+
337
+ if not source.is_dir():
338
+ self.errors.add(error.NoSuchDirectory(self.subcmd, source))
339
+ result = False
340
+ else:
341
+ if not utils.is_directory_readable(source):
342
+ self.errors.add(
343
+ error.InsufficientPermissionsToSubcmdFrom(self.subcmd, source)
344
+ )
345
+ result = False
346
+
347
+ if not utils.is_directory_executable(source):
348
+ self.errors.add(
349
+ error.InsufficientPermissionsToSubcmdFrom(self.subcmd, source)
350
+ )
351
+ result = False
352
+
353
+ return result
354
+
355
+ def is_valid_collection_input(self, source, dest):
356
+ """
357
+ Helper to validate the source and dest parameters passed to
358
+ _collect_actions()
359
+ """
360
+ result = True
361
+ if not self._is_valid_source(source):
362
+ result = False
363
+
364
+ if dest.exists():
365
+ if not self._is_valid_dest(dest):
366
+ result = False
367
+ return result
368
+
369
+
370
+ # pylint: disable=too-few-public-methods
371
+ class Clean(main.AbstractBaseSubCommand):
372
+ """
373
+ Abstract Base class that contains the shared logic for all of the stow
374
+ commands
375
+ """
376
+
377
+ # pylint: disable=too-many-arguments
378
+ def __init__(self, source, dest, is_silent, is_dry_run, ignore_patterns):
379
+ self.source = [pathlib.Path(s) for s in source]
380
+ self.dest = pathlib.Path(dest)
381
+ self.ignore_patterns = ignore_patterns
382
+ super().__init__("clean", source, dest, is_silent, is_dry_run, ignore_patterns)
383
+
384
+ def _is_valid_input(self, sources, dest):
385
+ """
386
+ Check to see if the input is valid
387
+ """
388
+ return StowInput(self.errors, self.subcmd).is_valid(sources, dest)
389
+
390
+ def get_directory_contents(self, directory):
391
+ """
392
+ Get the contents of a directory while handling errors that may occur
393
+ """
394
+ contents = []
395
+
396
+ try:
397
+ contents = utils.get_directory_contents(directory)
398
+ except PermissionError:
399
+ self.errors.add(error.PermissionDenied(self.subcmd, directory))
400
+ except FileNotFoundError:
401
+ self.errors.add(error.NoSuchFileOrDirectory(self.subcmd, directory))
402
+ except NotADirectoryError:
403
+ self.errors.add(error.NoSuchDirectory(self.subcmd, directory))
404
+
405
+ return contents
406
+
407
+ def _collect_clean_actions(self, source, source_names, dest):
408
+ subdests = utils.get_directory_contents(dest)
409
+ for subdest in subdests:
410
+ if subdest.is_symlink():
411
+ link_target = utils.readlink(subdest, absolute_target=True)
412
+ if not link_target.exists() and not source_names.isdisjoint(
413
+ set(link_target.parents)
414
+ ):
415
+ self.actions.add(actions.UnLink(self.subcmd, subdest))
416
+ elif subdest.is_dir():
417
+ self._collect_clean_actions(source, source_names, subdest)
418
+
419
+ def _check_for_other_actions(self):
420
+ """
421
+ Concrete method to collect required actions to perform a stow
422
+ sub-command
423
+ """
424
+ valid_files = []
425
+ for a_file in self.source:
426
+ self.ignore = ignore.Ignore(self.ignore_patterns, a_file)
427
+ if self.ignore.should_ignore(a_file):
428
+ self.ignore.ignore(a_file)
429
+ continue
430
+ else:
431
+ valid_files.append(a_file)
432
+
433
+ if not StowInput(self.errors, self.subcmd).is_valid_collection_input(
434
+ a_file, self.dest
435
+ ):
436
+ return
437
+
438
+ # NOTE: an option to make clean more aggressive is to change f.name to
439
+ # f.parent this could a be a good --option
440
+ files_names = [utils.get_absolute_path(f.name) for f in valid_files]
441
+ files_names_set = set(files_names)
442
+ self._collect_clean_actions(valid_files, files_names_set, self.dest)
dploy/utils.py ADDED
@@ -0,0 +1,123 @@
1
+ """
2
+ A module that contains utility function mainly used for operations in the
3
+ missing from the pathlib module.
4
+ """
5
+
6
+ import os
7
+ import pathlib
8
+ import shutil
9
+
10
+
11
+ def get_directory_contents(directory):
12
+ """
13
+ return a sorted list of the contents of a directory
14
+ """
15
+ contents = []
16
+
17
+ for child in directory.iterdir():
18
+ contents.append(child)
19
+
20
+ return sorted(contents)
21
+
22
+
23
+ def rmtree(tree):
24
+ """
25
+ a wrapper around shutil.rmtree to recursively delete a directory specified
26
+ by a pathlib.Path object
27
+ """
28
+ shutil.rmtree(str(tree))
29
+
30
+
31
+ def is_same_file(file1, file2):
32
+ """
33
+ test if two pathlib.Path() objects are the same file
34
+
35
+ TODO: consider using pathlib.Path.samefile() instead
36
+ NOTE: this can raise exception FileNotFoundError
37
+ """
38
+ return file1.resolve() == file2.resolve()
39
+
40
+
41
+ def is_same_files(files1, files2):
42
+ """
43
+ test if two collection of files are equivalent
44
+ """
45
+ files1_resolved = [f.resolve() for f in files1]
46
+ files2_resolved = [f.resolve() for f in files2]
47
+ return files1_resolved == files2_resolved
48
+
49
+
50
+ def get_absolute_path(file):
51
+ """
52
+ get the absolute path of a pathlib.Path() object
53
+ """
54
+ absolute_path = os.path.abspath(os.path.expanduser(str(file)))
55
+ return pathlib.Path(absolute_path)
56
+
57
+
58
+ def get_relative_path(path, start_at):
59
+ """
60
+ get the relative path of a pathlib.Path() object
61
+
62
+ TODO: consider using Path.relative_to() with walk_up=True (3.12+)
63
+ """
64
+ try:
65
+ relative_path = os.path.relpath(str(path), str(start_at))
66
+ except ValueError: # when a relative path does not exist
67
+ return get_absolute_path(path)
68
+
69
+ return pathlib.Path(relative_path)
70
+
71
+
72
+ def is_file_readable(a_file):
73
+ """
74
+ check if a pathlib.Path() file is readable
75
+ """
76
+ return os.access(str(a_file), os.R_OK)
77
+
78
+
79
+ def is_file_writable(a_file):
80
+ """
81
+ check if a pathlib.Path() file is writable
82
+ """
83
+ return os.access(str(a_file), os.W_OK)
84
+
85
+
86
+ def is_directory_readable(directory):
87
+ """
88
+ check if a pathlib.Path() directory is readable
89
+ """
90
+ return os.access(str(directory), os.R_OK)
91
+
92
+
93
+ def is_directory_writable(directory):
94
+ """
95
+ check if a pathlib.Path() directory is writable
96
+ """
97
+ return os.access(str(directory), os.W_OK)
98
+
99
+
100
+ def is_directory_executable(directory):
101
+ """
102
+ check if a pathlib.Path() directory is executable
103
+ """
104
+ return os.access(str(directory), os.X_OK)
105
+
106
+
107
+ def readlink(path, absolute_target=False):
108
+ """
109
+ get the target of a symbolic link passed as a pathlib.Path object and
110
+ provide the option to return an absolute path even if the link target is
111
+ relative.
112
+
113
+ Note: we can't use pathlib.Path.resolve because it doesn't work for broken
114
+ links (it resolves the target, which may not exist)
115
+
116
+ """
117
+ link_target = os.readlink(str(path))
118
+ path_dir = os.path.dirname(str(path))
119
+ if absolute_target:
120
+ if not os.path.isabs(link_target):
121
+ link_target = os.path.join(path_dir, link_target)
122
+ return pathlib.Path(link_target)
123
+ return pathlib.Path(link_target)
dploy/version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Version string sourced from pyproject.toml via importlib.metadata."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("dploy")