dotsync-cli 1.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.
dotsync/args.py ADDED
@@ -0,0 +1,149 @@
1
+ import logging
2
+ import argparse
3
+ import os
4
+
5
+ from dotsync.enums import Actions
6
+ import dotsync.info as info
7
+
8
+ HELP = {
9
+ 'verbose': 'increase verbosity level',
10
+ 'dry-run': 'do not actually execute any file operations',
11
+ 'hard-mode': 'copy files instead of symlinking them',
12
+ 'action': 'action to take on active categories',
13
+ 'category': 'categories to activate (default: common + hostname)'
14
+ }
15
+
16
+ EPILOG = 'See full the documentation at https://dotsync.readthedocs.io/'
17
+
18
+
19
+ class CustomHelpFormatter(argparse.RawDescriptionHelpFormatter):
20
+ """Custom formatter that hides default values containing hostname"""
21
+ def _get_help_string(self, action):
22
+ help_text = action.help
23
+ # Replace %(default)s placeholder
24
+ if '%(default)s' in help_text:
25
+ if action.dest == 'category':
26
+ default = action.default
27
+ if isinstance(default, list) and len(default) == 2 and default[1] == info.hostname:
28
+ help_text = help_text.replace('%(default)s', 'common + hostname')
29
+ else:
30
+ help_text = help_text % {'default': default}
31
+ else:
32
+ help_text = help_text % {'default': action.default}
33
+ return help_text
34
+
35
+ def _format_action(self, action):
36
+ """Override to hide default display for category argument"""
37
+ # Temporarily remove default to prevent argparse from showing it
38
+ if action.dest == 'category' and hasattr(action, 'default'):
39
+ original_default = action.default
40
+ # Replace with SUPPRESS to hide it from help output
41
+ action.default = argparse.SUPPRESS
42
+
43
+ # Format the action
44
+ result = super()._format_action(action)
45
+
46
+ # Restore original default
47
+ if action.dest == 'category' and 'original_default' in locals():
48
+ action.default = original_default
49
+
50
+ return result
51
+
52
+
53
+ class Arguments:
54
+ def __init__(self, args=None):
55
+ # construct parser
56
+ parser = argparse.ArgumentParser(prog='dotsync',
57
+ epilog=EPILOG,
58
+ formatter_class=CustomHelpFormatter)
59
+
60
+ # add parser options
61
+ parser.add_argument('--version', action='version',
62
+ version=f'dotsync {info.__version__}')
63
+ parser.add_argument('--verbose', '-v', action='count', default=0,
64
+ help=HELP['verbose'])
65
+ parser.add_argument('--dry-run', action='store_true',
66
+ help=HELP['dry-run'])
67
+ parser.add_argument('--hard', action='store_true',
68
+ help=HELP['hard-mode'])
69
+ parser.add_argument('--encrypt', action='store_true',
70
+ help='encrypt the file (for add command)')
71
+
72
+ parser.add_argument('action', choices=[a.value for a in Actions],
73
+ help=HELP['action'])
74
+ # For 'add' action: category[0] is filepath, category[1] is optional category name
75
+ # For 'encrypt' action: category[0] is filepath
76
+ # For 'unmanage' action: category[0] is filepath
77
+ # For other actions: category is list of category names
78
+ category_help = HELP['category']
79
+ add_help = 'filepath [category] - add new config file to filelist'
80
+ encrypt_help = 'filepath - convert existing config file to encrypted'
81
+ unmanage_help = 'filepath - restore file to home and stop managing it'
82
+
83
+ # For init: category[0] is optional directory path (defaults to ~/.dotfiles)
84
+ init_help = '[directory] - initialize dotsync repository (default: ~/.dotfiles)'
85
+ category_help_extended = f'{category_help} (for "init": {init_help}, for "add": {add_help}, for "encrypt": {encrypt_help}, for "unmanage": {unmanage_help})'
86
+
87
+ parser.add_argument('category', nargs='*',
88
+ default=['common', info.hostname],
89
+ help=category_help_extended)
90
+
91
+ # parse args
92
+ args = parser.parse_args(args)
93
+
94
+ # For init action, category[0] is optional directory path
95
+ if args.action == 'init':
96
+ # Check if user provided a directory (not the default categories)
97
+ # Default is ['common', info.hostname], so if category doesn't match this pattern, it's a directory
98
+ if len(args.category) > 0:
99
+ # If category looks like a path (contains / or starts with ~ or is absolute), it's a directory
100
+ first_arg = args.category[0]
101
+ if ('/' in first_arg or first_arg.startswith('~') or os.path.isabs(first_arg) or
102
+ first_arg not in ['common']):
103
+ # User provided a directory
104
+ self.init_directory = first_arg
105
+ else:
106
+ # Probably default categories, use default directory
107
+ self.init_directory = None
108
+ else:
109
+ # No arguments, use default directory
110
+ self.init_directory = None
111
+ # For add action, category[0] is filepath, category[1] is category name
112
+ elif args.action == 'add':
113
+ if len(args.category) < 1:
114
+ parser.error('add action requires at least one argument: filepath [category]')
115
+ self.add_filepath = args.category[0]
116
+ self.add_category = args.category[1] if len(args.category) > 1 else None
117
+ # For encrypt action, category[0] is filepath
118
+ elif args.action == 'encrypt':
119
+ if len(args.category) < 1:
120
+ parser.error('encrypt action requires filepath argument')
121
+ self.add_filepath = args.category[0]
122
+ self.add_category = None
123
+ # For unmanage action, category[0] is filepath
124
+ elif args.action == 'unmanage':
125
+ if len(args.category) < 1:
126
+ parser.error('unmanage action requires filepath argument')
127
+ self.add_filepath = args.category[0]
128
+ self.add_category = None
129
+ else:
130
+ self.add_filepath = None
131
+ self.add_category = None
132
+ self.init_directory = None
133
+
134
+ # extract settings
135
+ if args.verbose:
136
+ args.verbose = min(args.verbose, 2)
137
+ self.verbose_level = (logging.INFO if args.verbose < 2 else
138
+ logging.DEBUG)
139
+ else:
140
+ self.verbose_level = logging.WARNING
141
+
142
+ self.dry_run = args.dry_run
143
+ self.hard_mode = args.hard
144
+ self.encrypt = getattr(args, 'encrypt', False)
145
+ self.action = Actions(args.action)
146
+ self.categories = args.category
147
+
148
+ def __str__(self):
149
+ return str(vars(self))
dotsync/calc_ops.py ADDED
@@ -0,0 +1,298 @@
1
+ import os
2
+ import logging
3
+
4
+ from dotsync.file_ops import FileOps
5
+
6
+
7
+ class CalcOps:
8
+ def __init__(self, repo, restore_path, plugin):
9
+ self.repo = str(repo)
10
+ self.restore_path = str(restore_path)
11
+ self.plugin = plugin
12
+
13
+ def update(self, files):
14
+ fops = FileOps(self.repo)
15
+
16
+ for path in files:
17
+ categories = files[path]
18
+
19
+ master = min(categories)
20
+ slaves = [c for c in categories if c != master]
21
+
22
+ # checks if a candidate exists and also checks if the candidate is
23
+ # a link so that its resolved path can be used
24
+ original_path = {}
25
+
26
+ def check_cand(cand):
27
+ cand = os.path.join(cand, path)
28
+ if os.path.isfile(cand):
29
+ if os.path.islink(cand):
30
+ old = cand
31
+ cand = os.path.realpath(cand)
32
+ original_path[cand] = old
33
+ return [cand]
34
+ return []
35
+
36
+ candidates = []
37
+ candidates += check_cand(self.restore_path)
38
+
39
+ # candidate not found in restore path, so check elsewhere
40
+ if not candidates:
41
+ for cand in [os.path.join(self.repo, c) for c in categories]:
42
+ candidates += check_cand(cand)
43
+ else:
44
+ logging.debug(f'"{path}" found in restore path, so overriding '
45
+ 'any other candidates')
46
+
47
+ if not candidates:
48
+ logging.warning(f'unable to find any candidates for "{path}"')
49
+ continue
50
+
51
+ candidates = list(set(candidates))
52
+ if len(candidates) > 1:
53
+ print(f'multiple candidates found for {path}:\n')
54
+
55
+ for i, cand in enumerate(candidates):
56
+ print(f'[{i}] {cand}')
57
+ print('[-1] cancel')
58
+
59
+ while True:
60
+ try:
61
+ choice = int(input('please select the version you '
62
+ 'would like to use '
63
+ f'[0-{len(candidates)-1}]: '))
64
+ choice = candidates[choice]
65
+ except (ValueError, EOFError):
66
+ print('invalid choice entered, please try again')
67
+ continue
68
+ break
69
+ source = choice
70
+
71
+ # if one of the candidates is not in the repo and it is not the
72
+ # source it should be deleted manually since it will not be
73
+ # deleted in the slave linking below, as the other candidates
74
+ # would be
75
+ restore_path = os.path.join(self.restore_path, path)
76
+ if restore_path in candidates and source != restore_path:
77
+ fops.remove(restore_path)
78
+
79
+ else:
80
+ source = candidates.pop()
81
+
82
+ master = os.path.join(self.repo, master, path)
83
+ slaves = [os.path.join(self.repo, s, path) for s in slaves]
84
+
85
+ if source != master and not self.plugin.samefile(master, source):
86
+ if os.path.exists(master):
87
+ fops.remove(master)
88
+ # check if source is in repo, if it is not apply the plugin
89
+ if source.startswith(self.repo + os.sep):
90
+ # if the source is one of the slaves, move the source
91
+ # otherwise just copy it because it might have changed into
92
+ # a seperate category - cleanup will remove it if needed
93
+ if source in slaves:
94
+ fops.move(source, master)
95
+ else:
96
+ fops.copy(source, master)
97
+ else:
98
+ fops.plugin(self.plugin.apply, source, master)
99
+ if source in original_path:
100
+ fops.remove(original_path[source])
101
+ else:
102
+ fops.remove(source)
103
+
104
+ for slave in slaves:
105
+ if slave != source:
106
+ if os.path.isfile(slave) or os.path.islink(slave):
107
+ if os.path.realpath(slave) != master:
108
+ fops.remove(slave)
109
+ else:
110
+ # already linked to master so just ignore
111
+ continue
112
+ fops.link(master, slave)
113
+
114
+ return fops
115
+
116
+ def restore(self, files):
117
+ fops = FileOps(self.repo)
118
+
119
+ for path in files:
120
+ categories = files[path]
121
+ master = min(categories)
122
+ source = os.path.join(self.repo, master, path)
123
+
124
+ if not os.path.exists(source):
125
+ logging.debug(f'{source} not found in repo')
126
+ logging.warning(f'unable to find "{path}" in repo, skipping')
127
+ continue
128
+
129
+ dest = os.path.join(self.restore_path, path)
130
+
131
+ # Use shared conflict handling logic
132
+ # Import here to avoid circular imports
133
+ import sys
134
+ import os as os_module
135
+ # Get the repo root by going up from repo path
136
+ repo_root = os_module.path.dirname(os_module.path.dirname(self.repo))
137
+
138
+ # Import handle_dest_file_conflict from __main__ if available
139
+ # For now, use inline simplified version that matches restore behavior
140
+ should_proceed, should_copy = self._handle_restore_conflict(source, dest)
141
+
142
+ if not should_proceed:
143
+ continue
144
+ if not should_copy:
145
+ # Files are same, skip
146
+ continue
147
+
148
+ fops.plugin(self.plugin.remove, source, dest)
149
+
150
+ return fops
151
+
152
+ def _handle_restore_conflict(self, source, dest):
153
+ """Handle conflict for restore operation (simpler than unmanage)"""
154
+ if not os.path.exists(dest):
155
+ # Check for dangling symlink
156
+ if os.path.islink(dest):
157
+ os.remove(dest)
158
+ return (True, True)
159
+
160
+ # Check if files are the same
161
+ try:
162
+ if self.plugin.samefile(source, dest):
163
+ logging.debug(f'{dest} is the same file as in the repo, skipping')
164
+ return (True, False) # Skip, don't copy
165
+ except Exception:
166
+ pass
167
+
168
+ # Check if dest is a symlink to repo
169
+ if os.path.islink(dest):
170
+ link_target = os.readlink(dest)
171
+ repo_abs = os.path.abspath(source)
172
+ if os.path.abspath(link_target) == repo_abs or link_target.startswith(os.path.dirname(os.path.dirname(self.repo)) + os.sep):
173
+ # Symlink to repo, safe to remove
174
+ logging.info(f'{dest} already linked to repo, replacing with new file')
175
+ os.remove(dest)
176
+ return (True, True)
177
+ else:
178
+ # Symlink to somewhere else, ask user (restore behavior)
179
+ a = input(f'{dest} already exists, replace? [Yn] ')
180
+ a = 'y' if not a else a
181
+ if a.lower() == 'y':
182
+ os.remove(dest)
183
+ return (True, True)
184
+ else:
185
+ return (False, False) # Cancelled
186
+
187
+ # Regular file exists - simple prompt for restore
188
+ a = input(f'{dest} already exists, replace? [Yn] ')
189
+ a = 'y' if not a else a
190
+ if a.lower() == 'y':
191
+ os.remove(dest)
192
+ return (True, True)
193
+ else:
194
+ return (False, False) # Cancelled
195
+
196
+ # removes links from restore path that point to the repo
197
+ def clean(self, files):
198
+ fops = FileOps(self.repo)
199
+
200
+ for path in files:
201
+ categories = files[path]
202
+ master = min(categories)
203
+ repo_path = os.path.join(self.repo, master, path)
204
+
205
+ restore_path = os.path.join(self.restore_path, path)
206
+
207
+ if os.path.exists(repo_path) and os.path.exists(restore_path):
208
+ if self.plugin.samefile(repo_path, restore_path):
209
+ fops.remove(restore_path)
210
+
211
+ return fops
212
+
213
+ # will go through the repo and search for files that should no longer be
214
+ # there. accepts a list of filenames that are allowed
215
+ def clean_repo(self, filenames):
216
+ fops = FileOps(self.repo)
217
+
218
+ if not os.path.isdir(self.repo):
219
+ return fops
220
+
221
+ # System files to ignore (e.g., macOS .DS_Store, Windows Thumbs.db)
222
+ SYSTEM_FILES = {'.DS_Store', 'Thumbs.db', '.DS_Store?'}
223
+
224
+ for category in os.listdir(self.repo):
225
+ # Skip system files
226
+ if category in SYSTEM_FILES:
227
+ continue
228
+
229
+ category_path = os.path.join(self.repo, category)
230
+
231
+ # Skip if not a directory (shouldn't happen, but be safe)
232
+ if not os.path.isdir(category_path):
233
+ continue
234
+
235
+ # remove empty category folders
236
+ try:
237
+ if not os.listdir(category_path):
238
+ logging.info(f'{category} is empty, removing')
239
+ fops.remove(category)
240
+ continue
241
+ except (OSError, NotADirectoryError):
242
+ # If listdir fails, skip this entry
243
+ continue
244
+
245
+ for root, dirs, files in os.walk(category_path):
246
+ # Filter out system files from directories list
247
+ dirs[:] = [d for d in dirs if d not in SYSTEM_FILES]
248
+
249
+ # remove empty directories
250
+ for dname in dirs:
251
+ dname = os.path.join(root, dname)
252
+ if os.path.isdir(dname):
253
+ try:
254
+ if not os.listdir(dname):
255
+ dname = os.path.relpath(dname, self.repo)
256
+ logging.info(f'{dname} is empty, removing')
257
+ fops.remove(dname)
258
+ except (OSError, NotADirectoryError):
259
+ continue
260
+
261
+ # remove files that are not in the manifest
262
+ for fname in files:
263
+ # Skip system files
264
+ if fname in SYSTEM_FILES:
265
+ continue
266
+ fname = os.path.relpath(os.path.join(root, fname),
267
+ self.repo)
268
+ if fname not in filenames:
269
+ logging.info(f'{fname} is not in the manifest, '
270
+ 'removing')
271
+ fops.remove(fname)
272
+
273
+ return fops
274
+
275
+ # goes through the filelist and finds files that have modifications that
276
+ # are not yet in the repo e.g. changes to encrypted files. This should not
277
+ # be used for any calculations, only for informational purposes
278
+ def diff(self, categories):
279
+ diffs = []
280
+ for category in categories:
281
+ category_path = os.path.join(self.repo, category)
282
+
283
+ for root, dirs, files in os.walk(category_path):
284
+ for fname in files:
285
+ fname = os.path.join(root, fname)
286
+ fname = os.path.relpath(fname, category_path)
287
+
288
+ restore_file = os.path.join(self.restore_path, fname)
289
+ category_file = os.path.join(category_path, fname)
290
+
291
+ if not os.path.exists(restore_file):
292
+ continue
293
+
294
+ logging.debug(f'checking diff samefile for {restore_file}')
295
+ if not self.plugin.samefile(category_file, restore_file):
296
+ diffs.append(f'modified {restore_file}')
297
+
298
+ return diffs
dotsync/checks.py ADDED
@@ -0,0 +1,41 @@
1
+ import os
2
+ import logging
3
+ import subprocess
4
+
5
+
6
+ def safety_checks(dir_name, home, init):
7
+ # check that we're not in the user's home folder
8
+ if dir_name == home:
9
+ logging.error('dotsync should not be run inside home folder')
10
+ return False
11
+
12
+ try:
13
+ subprocess.run(['git', '--version'], check=True,
14
+ stdout=subprocess.PIPE)
15
+ except FileNotFoundError:
16
+ logging.error('"git" command not found in path, needed for proper '
17
+ 'dotsync operation')
18
+ return False
19
+
20
+ if init:
21
+ return True
22
+
23
+ if os.path.isfile(os.path.join(dir_name, 'cryptlist')):
24
+ logging.error('this appears to be an old dotsync repo, please check '
25
+ 'the documentation for '
26
+ 'instructions on how to migrate your repo to the new '
27
+ 'version of dotsync or use the old version of dotsync by '
28
+ 'rather running "dotsync.sh"')
29
+ return False
30
+
31
+ if not os.path.isdir(os.path.join(dir_name, '.git')):
32
+ logging.error('this does not appear to be a git repo, make sure to '
33
+ 'init the repo before running dotsync in this folder')
34
+ return False
35
+
36
+ for flist in ['filelist']:
37
+ if not os.path.isfile(os.path.join(dir_name, flist)):
38
+ logging.error(f'unable to locate {flist} in repo')
39
+ return False
40
+
41
+ return True
dotsync/enums.py ADDED
@@ -0,0 +1,16 @@
1
+ import enum
2
+
3
+
4
+ class Actions(enum.Enum):
5
+ """Actions ordered by typical usage lifecycle"""
6
+ INIT = 'init' # Initialize dotsync repository (first-time setup)
7
+ ADD = 'add' # Add new config file to filelist
8
+ ENCRYPT = 'encrypt' # Convert existing plain config to encrypted
9
+ UNMANAGE = 'unmanage' # Restore file to home and stop managing it
10
+ LIST = 'list' # List all managed configuration files
11
+ UPDATE = 'update' # Sync config files from home to repository
12
+ RESTORE = 'restore' # Restore config files from repository to home
13
+ DIFF = 'diff' # Show differences between home and repository
14
+ COMMIT = 'commit' # Commit changes to git and optionally push
15
+ CLEAN = 'clean' # Remove files from repository that are no longer managed
16
+ PASSWD = 'passwd' # Change encryption password for encrypted files
dotsync/file_ops.py ADDED
@@ -0,0 +1,122 @@
1
+ import os
2
+ import logging
3
+ import enum
4
+ import shutil
5
+ import inspect
6
+
7
+
8
+ class Op(enum.Enum):
9
+ LINK = enum.auto()
10
+ COPY = enum.auto()
11
+ MOVE = enum.auto()
12
+ REMOVE = enum.auto()
13
+ MKDIR = enum.auto()
14
+
15
+
16
+ class FileOps:
17
+ def __init__(self, wd):
18
+ self.wd = wd
19
+ self.ops = []
20
+
21
+ def clear(self):
22
+ self.ops = []
23
+
24
+ def check_path(self, path):
25
+ return path if os.path.isabs(path) else os.path.join(self.wd, path)
26
+
27
+ def check_dest_dir(self, path):
28
+ dirname = os.path.dirname(path)
29
+ if not os.path.isdir(self.check_path(dirname)):
30
+ self.mkdir(dirname)
31
+
32
+ def mkdir(self, path):
33
+ logging.debug(f'adding mkdir op for {path}')
34
+ self.ops.append((Op.MKDIR, path))
35
+
36
+ def copy(self, source, dest):
37
+ logging.debug(f'adding cp op for {source} -> {dest}')
38
+ self.check_dest_dir(dest)
39
+ self.ops.append((Op.COPY, (source, dest)))
40
+
41
+ def move(self, source, dest):
42
+ logging.debug(f'adding mv op for {source} -> {dest}')
43
+ self.check_dest_dir(dest)
44
+ self.ops.append((Op.MOVE, (source, dest)))
45
+
46
+ def link(self, source, dest):
47
+ logging.debug(f'adding ln op for {source} <- {dest}')
48
+ self.check_dest_dir(dest)
49
+ self.ops.append((Op.LINK, (source, dest)))
50
+
51
+ def remove(self, path):
52
+ logging.debug(f'adding rm op for {path}')
53
+ self.ops.append((Op.REMOVE, path))
54
+
55
+ def plugin(self, plugin, source, dest):
56
+ logging.debug(f'adding plugin op ({plugin.__qualname__}) for {source} '
57
+ f'-> {dest}')
58
+ self.check_dest_dir(dest)
59
+ self.ops.append((plugin, (source, dest)))
60
+
61
+ def apply(self, dry_run=False):
62
+ for op in self.ops:
63
+ op, path = op
64
+
65
+ if type(path) is tuple:
66
+ src, dest = path
67
+ src, dest = self.check_path(src), self.check_path(dest)
68
+ logging.info(self.str_op(op, (src, dest)))
69
+ else:
70
+ path = self.check_path(path)
71
+ logging.info(self.str_op(op, path))
72
+
73
+ if dry_run:
74
+ continue
75
+
76
+ if op == Op.LINK:
77
+ src = os.path.relpath(src, os.path.join(self.wd,
78
+ os.path.dirname(dest)))
79
+ os.symlink(src, dest)
80
+ elif op == Op.COPY:
81
+ shutil.copyfile(src, dest)
82
+ elif op == Op.MOVE:
83
+ os.rename(src, dest)
84
+ elif op == Op.REMOVE:
85
+ if os.path.isdir(path):
86
+ shutil.rmtree(path)
87
+ else:
88
+ os.remove(path)
89
+ elif op == Op.MKDIR:
90
+ if not os.path.isdir(path):
91
+ os.makedirs(path)
92
+ elif callable(op):
93
+ op(src, dest)
94
+
95
+ self.clear()
96
+
97
+ def append(self, other):
98
+ self.ops += other.ops
99
+ return self
100
+
101
+ def str_op(self, op, path):
102
+ def strip_wd(p):
103
+ p = str(p)
104
+ wd = str(self.wd)
105
+ return p[len(wd) + 1:] if p.startswith(wd) else p
106
+
107
+ if type(op) is Op:
108
+ op = op.name
109
+ else:
110
+ op = dict(inspect.getmembers(op))['__self__'].strify(op)
111
+
112
+ if type(path) is tuple:
113
+ path = [strip_wd(p) for p in path]
114
+ return f'{op} "{path[0]}" -> "{path[1]}"'
115
+ else:
116
+ return f'{op} "{strip_wd(path)}"'
117
+
118
+ def __str__(self):
119
+ return '\n'.join(self.str_op(*op) for op in self.ops)
120
+
121
+ def __repr__(self):
122
+ return str(self.ops)