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/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ dploy script is an attempt at creating a clone of GNU stow that will work on
3
+ Windows as well as *nix
4
+ """
5
+
6
+ from dploy import linkcmd
7
+ from dploy import stowcmd
8
+
9
+
10
+ def stow(sources, dest, is_silent=True, is_dry_run=False, ignore_patterns=None):
11
+ """
12
+ sub command stow
13
+ """
14
+ stowcmd.Stow(sources, dest, is_silent, is_dry_run, ignore_patterns)
15
+
16
+
17
+ def unstow(sources, dest, is_silent=True, is_dry_run=False, ignore_patterns=None):
18
+ """
19
+ sub command unstow
20
+ """
21
+ stowcmd.UnStow(sources, dest, is_silent, is_dry_run, ignore_patterns)
22
+
23
+
24
+ def clean(sources, dest, is_silent=True, is_dry_run=False, ignore_patterns=None):
25
+ """
26
+ sub command clean
27
+ """
28
+ stowcmd.Clean(sources, dest, is_silent, is_dry_run, ignore_patterns)
29
+
30
+
31
+ def link(source, dest, is_silent=True, is_dry_run=False, ignore_patterns=None):
32
+ """
33
+ sub command link
34
+ """
35
+ linkcmd.Link(source, dest, is_silent, is_dry_run, ignore_patterns)
dploy/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ The entry point when dploy is called as a module
4
+ """
5
+
6
+ from dploy import cli
7
+
8
+
9
+ def main():
10
+ """
11
+ main entry point when using dploy from the command line
12
+ """
13
+ cli.run()
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
dploy/actions.py ADDED
@@ -0,0 +1,217 @@
1
+ """
2
+ This module contains the actions that are combined to perform dploy's sub
3
+ commands
4
+ """
5
+
6
+ from collections import defaultdict
7
+ from dploy import utils
8
+
9
+
10
+ class Actions:
11
+ """
12
+ A class that collects and executes action objects
13
+ """
14
+
15
+ def __init__(self, is_silent, is_dry_run):
16
+ self.actions = []
17
+ self.is_silent = is_silent
18
+ self.is_dry_run = is_dry_run
19
+
20
+ def add(self, action):
21
+ """
22
+ Adds an action
23
+ """
24
+ self.actions.append(action)
25
+
26
+ def execute(self):
27
+ """
28
+ Prints and executes actions
29
+ """
30
+ for action in self.actions:
31
+ if not self.is_silent:
32
+ print(action)
33
+ if not self.is_dry_run:
34
+ action.execute()
35
+
36
+ def get_unlink_actions(self):
37
+ """
38
+ get the current Unlink() actions from the self.actions
39
+ """
40
+ return [a for a in self.actions if isinstance(a, UnLink)]
41
+
42
+ def get_unlink_target_parents(self):
43
+ """
44
+ Get list of the parents for the current Unlink() actions from
45
+ self.actions
46
+ """
47
+ unlink_actions = self.get_unlink_actions()
48
+ # sort for deterministic output
49
+ return sorted(set([a.target.parent for a in unlink_actions]))
50
+
51
+ def get_unlink_targets(self):
52
+ """
53
+ Get list of the targets for the current Unlink() actions from
54
+ self.actions
55
+ """
56
+ unlink_actions = self.get_unlink_actions()
57
+ return [a.target for a in unlink_actions]
58
+
59
+ def get_duplicates(self):
60
+ """
61
+ return a tuple containing tuples with the following structure
62
+ (link destination, [indices of duplicates])
63
+ """
64
+ tally = defaultdict(list)
65
+ for index, action in enumerate(self.actions):
66
+ if isinstance(action, SymbolicLink):
67
+ tally[action.dest].append(index)
68
+ # sort for deterministic output
69
+ return sorted([indices for _, indices in tally.items() if len(indices) > 1])
70
+
71
+
72
+ class AbstractBaseAction:
73
+ # pylint: disable=too-few-public-methods
74
+ """
75
+ An abstract base class that define the interface for actions
76
+ """
77
+
78
+ def __init__(self):
79
+ pass
80
+
81
+ def execute(self):
82
+ """
83
+ function that executes the logic of each concrete action
84
+ """
85
+ pass
86
+
87
+
88
+ class SymbolicLink(AbstractBaseAction):
89
+ # pylint: disable=too-few-public-methods
90
+ """
91
+ Action to create a symbolic link relative to the source of the link
92
+ """
93
+
94
+ def __init__(self, subcmd, source, dest):
95
+ super().__init__()
96
+ self.source = source
97
+ self.source_relative = utils.get_relative_path(source, dest.parent)
98
+ self.subcmd = subcmd
99
+ self.dest = dest
100
+
101
+ def execute(self):
102
+ self.dest.symlink_to(self.source_relative)
103
+
104
+ def __repr__(self):
105
+ return "dploy {subcmd}: link {dest} => {source}".format(
106
+ subcmd=self.subcmd, dest=self.dest, source=self.source_relative
107
+ )
108
+
109
+
110
+ class AlreadyLinked(AbstractBaseAction):
111
+ # pylint: disable=too-few-public-methods
112
+ """
113
+ Action to used to print an already linked message
114
+ """
115
+
116
+ def __init__(self, subcmd, source, dest):
117
+ super().__init__()
118
+ self.source = source
119
+ self.source_relative = utils.get_relative_path(source, dest.parent)
120
+ self.dest = dest
121
+ self.subcmd = subcmd
122
+
123
+ def execute(self):
124
+ pass
125
+
126
+ def __repr__(self):
127
+ return "dploy {subcmd}: already linked {dest} => {source}".format(
128
+ subcmd=self.subcmd, source=self.source_relative, dest=self.dest
129
+ )
130
+
131
+
132
+ class AlreadyUnlinked(AbstractBaseAction):
133
+ # pylint: disable=too-few-public-methods
134
+ """
135
+ Action to used to print an already unlinked message
136
+ """
137
+
138
+ def __init__(self, subcmd, source, dest):
139
+ super().__init__()
140
+ self.source = source
141
+ self.source_relative = utils.get_relative_path(source, dest.parent)
142
+ self.dest = dest
143
+ self.subcmd = subcmd
144
+
145
+ def execute(self):
146
+ pass
147
+
148
+ def __repr__(self):
149
+ return "dploy {subcmd}: already unlinked {dest} => {source}".format(
150
+ subcmd=self.subcmd, source=self.source_relative, dest=self.dest
151
+ )
152
+
153
+
154
+ class UnLink(AbstractBaseAction):
155
+ # pylint: disable=too-few-public-methods
156
+ """
157
+ Action to unlink a symbolic link
158
+ """
159
+
160
+ def __init__(self, subcmd, target):
161
+ super().__init__()
162
+ self.target = target
163
+ self.subcmd = subcmd
164
+
165
+ def execute(self):
166
+ if not self.target.is_symlink():
167
+ # pylint: disable=line-too-long
168
+ raise RuntimeError(
169
+ "dploy detected and aborted an attempt to unlink a non-symlink {target} this is a bug and should be reported".format(
170
+ target=self.target
171
+ )
172
+ )
173
+ self.target.unlink()
174
+
175
+ def __repr__(self):
176
+ return "dploy {subcmd}: unlink {target} => {source}".format(
177
+ subcmd=self.subcmd, target=self.target, source=utils.readlink(self.target)
178
+ )
179
+
180
+
181
+ class MakeDirectory(AbstractBaseAction):
182
+ # pylint: disable=too-few-public-methods
183
+ """
184
+ Action to create a directory
185
+ """
186
+
187
+ def __init__(self, subcmd, target):
188
+ super().__init__()
189
+ self.target = target
190
+ self.subcmd = subcmd
191
+
192
+ def execute(self):
193
+ self.target.mkdir()
194
+
195
+ def __repr__(self):
196
+ return "dploy {subcmd}: make directory {target}".format(
197
+ target=self.target, subcmd=self.subcmd
198
+ )
199
+
200
+
201
+ class RemoveDirectory(AbstractBaseAction):
202
+ # pylint: disable=too-few-public-methods
203
+ """
204
+ Action to remove a directory
205
+ """
206
+
207
+ def __init__(self, subcmd, target):
208
+ super().__init__()
209
+ self.target = target
210
+ self.subcmd = subcmd
211
+
212
+ def execute(self):
213
+ self.target.rmdir()
214
+
215
+ def __repr__(self):
216
+ msg = "dploy {subcmd}: remove directory {target}"
217
+ return msg.format(target=self.target, subcmd=self.subcmd)
dploy/cli.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ The command line interface
3
+ """
4
+
5
+ import sys
6
+ import argparse
7
+ from dploy import linkcmd
8
+ from dploy import stowcmd
9
+ from dploy import version
10
+ from dploy.error import DployError
11
+
12
+
13
+ def add_ignore_argument(parser):
14
+ """
15
+ adds the ignore argument to a subcmd parser
16
+ """
17
+ parser.add_argument(
18
+ "--ignore",
19
+ dest="ignore_patterns",
20
+ action="append",
21
+ default=None,
22
+ help="glob pattern used to ignore directories",
23
+ )
24
+
25
+
26
+ def create_parser():
27
+ """
28
+ create the CLI argument parser
29
+ """
30
+ parser = argparse.ArgumentParser(prog="dploy")
31
+
32
+ parser.add_argument(
33
+ "--version",
34
+ action="version",
35
+ version="%(prog)s {version}".format(version=version.__version__),
36
+ )
37
+ parser.add_argument(
38
+ "--silent", dest="is_silent", action="store_true", help="suppress all output"
39
+ )
40
+ parser.add_argument(
41
+ "--dry-run",
42
+ dest="is_dry_run",
43
+ action="store_true",
44
+ help="show what would be done without doing it",
45
+ )
46
+
47
+ sub_parsers = parser.add_subparsers(dest="subcmd")
48
+
49
+ stow_parser = sub_parsers.add_parser("stow")
50
+ stow_parser.add_argument("source", nargs="+", help="source directory to stow")
51
+ stow_parser.add_argument("dest", help="destination path to stow into")
52
+ add_ignore_argument(stow_parser)
53
+
54
+ unstow_parser = sub_parsers.add_parser("unstow")
55
+ unstow_parser.add_argument(
56
+ "source", nargs="+", help="source directory to unstow from"
57
+ )
58
+ unstow_parser.add_argument("dest", help="destination path to unstow")
59
+ add_ignore_argument(unstow_parser)
60
+
61
+ clean_parser = sub_parsers.add_parser("clean")
62
+ clean_parser.add_argument(
63
+ "source", nargs="+", help="source directory to clean from"
64
+ )
65
+ clean_parser.add_argument("dest", help="destination path to clean")
66
+ add_ignore_argument(clean_parser)
67
+
68
+ link_parser = sub_parsers.add_parser("link")
69
+ link_parser.add_argument("source", help="source file or directory to link")
70
+ link_parser.add_argument("dest", help="destination path to link")
71
+ add_ignore_argument(link_parser)
72
+ return parser
73
+
74
+
75
+ def run(arguments=None):
76
+ """
77
+ interpret the parser arguments and execute the corresponding commands
78
+ """
79
+
80
+ subcmd_map = {
81
+ "stow": stowcmd.Stow,
82
+ "unstow": stowcmd.UnStow,
83
+ "clean": stowcmd.Clean,
84
+ "link": linkcmd.Link,
85
+ }
86
+
87
+ try:
88
+ parser = create_parser()
89
+
90
+ if arguments is None:
91
+ args = parser.parse_args()
92
+ else:
93
+ args = parser.parse_args(arguments)
94
+
95
+ if args.subcmd in subcmd_map:
96
+ subcmd = subcmd_map[args.subcmd]
97
+ else:
98
+ parser.print_help()
99
+ sys.exit(0)
100
+
101
+ try:
102
+ subcmd(
103
+ args.source,
104
+ args.dest,
105
+ is_silent=args.is_silent,
106
+ is_dry_run=args.is_dry_run,
107
+ ignore_patterns=args.ignore_patterns,
108
+ )
109
+ except DployError:
110
+ sys.exit(1)
111
+
112
+ except (KeyboardInterrupt) as error:
113
+ print(error, file=sys.stderr)
114
+ sys.exit(130)
dploy/error.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ All the exceptions and their messages used by the program
3
+ """
4
+ import sys
5
+
6
+ ERROR_HEAD = "dploy {subcmd}: can not {subcmd} "
7
+
8
+
9
+ class Errors:
10
+ """
11
+ A class that collects and executes action objects
12
+ """
13
+
14
+ def __init__(self, is_silent):
15
+ self.exceptions = []
16
+ self.is_silent = is_silent
17
+
18
+ def add(self, error):
19
+ """
20
+ Adds an error
21
+ """
22
+ self.exceptions.append(error)
23
+
24
+ def handle(self):
25
+ """
26
+ Prints and handles errors
27
+ """
28
+ if len(self.exceptions) > 0:
29
+ if not self.is_silent:
30
+ for exception in self.exceptions:
31
+ print(exception, file=sys.stderr)
32
+ raise self.exceptions[0]
33
+
34
+
35
+ # pylint: disable=missing-docstring
36
+ # pylint: disable=too-few-public-methods
37
+
38
+
39
+ class DployError(Exception):
40
+ pass
41
+
42
+
43
+ class SourceIsSameAsDest(DployError):
44
+ def __init__(self, subcmd, file):
45
+ self.msg = (
46
+ ERROR_HEAD + "'{file}': A source argument is the same as the dest argument"
47
+ )
48
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
49
+ # self.exception = ValueError(self.msg)
50
+
51
+ def __str__(self):
52
+ return self.msg
53
+
54
+
55
+ class ConflictsWithAnotherSource(DployError):
56
+ def __init__(self, subcmd, files):
57
+ self.msg = ERROR_HEAD + "the following: Conflicts with other source {files}"
58
+ files_list = "\n " + "\n ".join(files)
59
+ self.msg = self.msg.format(subcmd=subcmd, files=files_list)
60
+ # self.exception = ValueError(self.msg)
61
+
62
+ def __str__(self):
63
+ return self.msg
64
+
65
+
66
+ class ConflictsWithExistingFile(DployError):
67
+ def __init__(self, subcmd, source, dest):
68
+ self.msg = ERROR_HEAD + "'{source}': Conflicts with existing file '{dest}'"
69
+ self.msg = self.msg.format(subcmd=subcmd, source=source, dest=dest)
70
+ # self.exception = ValueError(self.msg)
71
+
72
+ def __str__(self):
73
+ return self.msg
74
+
75
+
76
+ class ConflictsWithExistingLink(DployError):
77
+ def __init__(self, subcmd, source, dest):
78
+ self.msg = ERROR_HEAD + "'{source}': Conflicts with existing symlink '{dest}'"
79
+ self.msg = self.msg.format(subcmd=subcmd, source=source, dest=dest)
80
+ # self.exception = ValueError(self.msg)
81
+
82
+ def __str__(self):
83
+ return self.msg
84
+
85
+
86
+ class InsufficientPermissions(DployError):
87
+ def __init__(self, subcmd, file):
88
+ self.msg = ERROR_HEAD + "'{file}': Insufficient permissions"
89
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
90
+ # self.exception = PermissionError(self.msg)
91
+
92
+ def __str__(self):
93
+ return self.msg
94
+
95
+
96
+ class NoSuchDirectory(DployError):
97
+ def __init__(self, subcmd, file):
98
+ self.msg = ERROR_HEAD + "'{file}': No such directory"
99
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
100
+ # self.exception = NotADirectoryError(self.msg)
101
+
102
+ def __str__(self):
103
+ return self.msg
104
+
105
+
106
+ class PermissionDenied(DployError):
107
+ def __init__(self, subcmd, file):
108
+ self.msg = ERROR_HEAD + "'{file}': Permission denied"
109
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
110
+ # self.exception = PermissionError(self.msg)
111
+
112
+ def __str__(self):
113
+ return self.msg
114
+
115
+
116
+ class InsufficientPermissionsToSubcmdFrom(DployError):
117
+ def __init__(self, subcmd, file):
118
+ self.msg = ERROR_HEAD + "from '{file}': Insufficient permissions"
119
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
120
+ # self.exception = PermissionError(self.msg)
121
+
122
+ def __str__(self):
123
+ return self.msg
124
+
125
+
126
+ class NoSuchDirectoryToSubcmdInto(DployError):
127
+ def __init__(self, subcmd, file):
128
+ self.msg = ERROR_HEAD + "into '{file}': No such directory"
129
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
130
+ # self.exception = NotADirectoryError(self.msg)
131
+
132
+ def __str__(self):
133
+ return self.msg
134
+
135
+
136
+ class InsufficientPermissionsToSubcmdTo(DployError):
137
+ def __init__(self, subcmd, file):
138
+ self.msg = ERROR_HEAD + "to '{file}': Insufficient permissions"
139
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
140
+ # self.exception = PermissionError(self.msg)
141
+
142
+ def __str__(self):
143
+ return self.msg
144
+
145
+
146
+ class NoSuchFileOrDirectory(DployError):
147
+ def __init__(self, subcmd, file):
148
+ self.msg = ERROR_HEAD + "'{file}': No such file or directory"
149
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
150
+ # self.exception = FileNotFoundError(self.msg)
151
+
152
+ def __str__(self):
153
+ return self.msg
154
+
155
+
156
+ class DuplicateSource(DployError):
157
+ def __init__(self, subcmd, file):
158
+ self.msg = ERROR_HEAD + "'{file}': Duplicate source argument"
159
+ self.msg = self.msg.format(subcmd=subcmd, file=file)
160
+ # self.exception = ValueError(self.msg)
161
+
162
+ def __str__(self):
163
+ return self.msg
dploy/ignore.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Module for the --ignore IGNORE_PATTERN flag and .dploystowignore file
3
+ """
4
+ import pathlib
5
+ from dploy import utils
6
+
7
+
8
+ class Ignore:
9
+ """
10
+ Handles ignoring of files via glob patterns either passed in directly in or
11
+ in a specified ignore file.
12
+ """
13
+
14
+ def __init__(self, patterns, source):
15
+ if patterns is None:
16
+ input_patterns = []
17
+ else:
18
+ input_patterns = patterns
19
+ self.ignored_files = []
20
+
21
+ file = source.parent / pathlib.Path(".dploystowignore")
22
+
23
+ self.patterns = [str(file.name)] # ignore the ignore file
24
+ self.patterns.extend(input_patterns)
25
+ self._read_ignore_file_patterns(file)
26
+
27
+ def _read_ignore_file_patterns(self, file):
28
+ """
29
+ read ignore patterns from a specified file
30
+ """
31
+ try:
32
+ with open(str(file)) as afile:
33
+ file_patterns = afile.read().splitlines()
34
+ self.patterns.extend(file_patterns)
35
+ except FileNotFoundError:
36
+ pass
37
+
38
+ def should_ignore(self, source):
39
+ """
40
+ check if a source should be ignored, based on the ignore patterns in
41
+ self.patterns
42
+
43
+ This checks if the ignore patterns match either the file exactly or
44
+ its parents
45
+ """
46
+ for pattern in self.patterns:
47
+ try:
48
+ files = sorted(source.parent.glob(pattern))
49
+ except IndexError: # the glob result was empty
50
+ continue
51
+
52
+ for file in files:
53
+ if utils.is_same_file(file, source) or source in file.parents:
54
+ return True
55
+ return False
56
+
57
+ def ignore(self, file):
58
+ """
59
+ add a file to be ignored
60
+ """
61
+ self.ignored_files.append(file)
62
+
63
+ def get_ignored_files(self):
64
+ """
65
+ get a list of the files that have been ignored
66
+ """
67
+ return self.ignored_files
dploy/linkcmd.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ The logic and workings behind the link sub-commands
3
+ """
4
+
5
+ from dploy import actions
6
+ from dploy import utils
7
+ from dploy import error
8
+ from dploy import main
9
+
10
+
11
+ # pylint: disable=too-few-public-methods
12
+ class Link(main.AbstractBaseSubCommand):
13
+ """
14
+ Concrete class implementation of the link sub-command
15
+ """
16
+
17
+ # pylint: disable=too-many-arguments
18
+ def __init__(
19
+ self, source, dest, is_silent=True, is_dry_run=False, ignore_patterns=None
20
+ ):
21
+ super().__init__("link", [source], dest, is_silent, is_dry_run, ignore_patterns)
22
+
23
+ def _is_valid_input(self, sources, dest):
24
+ """
25
+ Check to see if the input is valid
26
+ """
27
+ return LinkInput(self.errors, self.subcmd).is_valid(sources, dest)
28
+
29
+ def _collect_actions(self, source, dest):
30
+ """
31
+ Concrete method to collect required actions to perform a link
32
+ sub-command
33
+ """
34
+
35
+ if dest.exists():
36
+ if utils.is_same_file(dest, source):
37
+ self.actions.add(actions.AlreadyLinked(self.subcmd, source, dest))
38
+ else:
39
+ self.errors.add(
40
+ error.ConflictsWithExistingFile(self.subcmd, source, dest)
41
+ )
42
+ elif dest.is_symlink():
43
+ self.errors.add(error.ConflictsWithExistingLink(self.subcmd, source, dest))
44
+
45
+ elif not dest.parent.exists():
46
+ self.errors.add(error.NoSuchDirectoryToSubcmdInto(self.subcmd, dest.parent))
47
+
48
+ else:
49
+ self.actions.add(actions.SymbolicLink(self.subcmd, source, dest))
50
+
51
+
52
+ class LinkInput(main.Input):
53
+ """
54
+ Input validator for the link command
55
+ """
56
+
57
+ def _is_valid_dest(self, dest):
58
+ if not dest.parent.exists():
59
+ self.errors.add(error.NoSuchFileOrDirectory(self.subcmd, dest.parent))
60
+ return False
61
+
62
+ elif not utils.is_file_writable(dest.parent) or not utils.is_directory_writable(
63
+ dest.parent
64
+ ):
65
+ self.errors.add(error.InsufficientPermissionsToSubcmdTo(self.subcmd, dest))
66
+ return False
67
+
68
+ return True
69
+
70
+ def _is_valid_source(self, source):
71
+ if not source.exists():
72
+ self.errors.add(error.NoSuchFileOrDirectory(self.subcmd, source))
73
+ return False
74
+
75
+ elif not utils.is_file_readable(source) or not utils.is_directory_readable(
76
+ source
77
+ ):
78
+ self.errors.add(error.InsufficientPermissions(self.subcmd, source))
79
+ return False
80
+
81
+ return True