distbackup 1.0.19__tar.gz → 1.0.21__tar.gz

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.
Files changed (31) hide show
  1. {distbackup-1.0.19 → distbackup-1.0.21}/PKG-INFO +8 -3
  2. distbackup-1.0.21/distbackup/__init__.py +1 -0
  3. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/cli.py +0 -1
  4. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/database.py +9 -74
  5. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/disk.py +39 -28
  6. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/restore.py +16 -2
  7. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/update.py +11 -16
  8. distbackup-1.0.21/distbackup/utils.py +395 -0
  9. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/PKG-INFO +8 -3
  10. {distbackup-1.0.19 → distbackup-1.0.21}/setup.cfg +1 -1
  11. distbackup-1.0.19/distbackup/__init__.py +0 -1
  12. distbackup-1.0.19/distbackup/utils.py +0 -272
  13. {distbackup-1.0.19 → distbackup-1.0.21}/LICENSE +0 -0
  14. {distbackup-1.0.19 → distbackup-1.0.21}/README.md +0 -0
  15. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/__main__.py +0 -0
  16. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/backup.py +0 -0
  17. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/constants.py +0 -0
  18. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/errors.py +0 -0
  19. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/logger.py +0 -0
  20. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/nexus.py +0 -0
  21. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/objects.py +0 -0
  22. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/report.py +0 -0
  23. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/schema.py +0 -0
  24. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/sourcefile.py +0 -0
  25. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/verify.py +0 -0
  26. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/SOURCES.txt +0 -0
  27. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/dependency_links.txt +0 -0
  28. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/entry_points.txt +0 -0
  29. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/requires.txt +0 -0
  30. {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/top_level.txt +0 -0
  31. {distbackup-1.0.19 → distbackup-1.0.21}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: distbackup
3
- Version: 1.0.19
3
+ Version: 1.0.21
4
4
  Summary: A backup manager which can back up a large set of files to a number of offline disks
5
5
  Home-page: https://ktpanda.org/software/distbackup
6
6
  Author: Katie Rust
@@ -9,9 +9,14 @@ License: MIT License
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.8
12
+ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
+ Requires-Dist: argcomplete>=2.0.0
16
+ Requires-Dist: psutil>=5.9.0
17
+ Requires-Dist: ktpanda_modules>=1.0.28
18
+ Requires-Dist: hashcopy>=1.0.5; platform_system == "Linux"
19
+ Dynamic: license-file
15
20
 
16
21
  Distbackup
17
22
  ==========
@@ -0,0 +1 @@
1
+ VERSION = "1.0.21"
@@ -432,7 +432,6 @@ def cmd_disk_list(db:BackupDB, parser, args):
432
432
  @arg('name', metavar='NAME', help='Name of new disk')
433
433
  @arg('path', metavar='PATH', type=Path, help='Path to data files')
434
434
  @arg('-s', '--size', help='Set size of selected disk')
435
- @arg('-p', '--path', metavar='PATH', help='Path to data files')
436
435
  def cmd_disk_add(db:BackupDB, parser, args):
437
436
  args.path.mkdir(parents=True, exist_ok=True)
438
437
  path = args.path.resolve()
@@ -54,75 +54,11 @@ class BackupDB(SQLiteDB):
54
54
  # Upgrades
55
55
  ##########################################################################################
56
56
 
57
- def _upgrade_to_13(self, oldvers):
58
- self.backend.execute('ALTER TABLE disks ADD COLUMN relative_path TEXT')
59
-
60
- def _upgrade_to_16(self, oldvers):
61
- self.backend.execute("ALTER TABLE file_names ADD COLUMN metadata TEXT NOT NULL DEFAULT ''")
62
-
63
- def _upgrade_to_18(self, oldvers):
64
- for column in ('file_names.path', 'path_map.virtual_path', 'path_config.virtual_path', 'links.path', 'files.last_path'):
65
- table, column = column.split('.')
66
- sql = f"UPDATE {table} SET {column} = '/' || {column} WHERE {column} NOT LIKE '/%'"
67
- self.backend.execute(sql)
68
-
69
- def _upgrade_to_20(self, oldvers):
70
- if self.backend.execute("SELECT name FROM sqlite_master WHERE name = 'source_path'"):
71
- self.backend.execute('ALTER TABLE source_path RENAME TO path_map')
72
-
73
- if self.backend.execute("SELECT name FROM sqlite_master WHERE name = 'source_config'"):
74
- self.backend.execute('ALTER TABLE source_config RENAME TO path_config')
75
-
76
57
  def _drop_triggers(self):
77
58
  triggers = [row[0] for row in self.backend.execute("SELECT name FROM sqlite_master WHERE type = 'trigger'")]
78
59
  for trigger in triggers:
79
60
  self.backend.execute(f'DROP TRIGGER {trigger}')
80
61
 
81
- def _upgrade_to_21(self, oldvers):
82
- '''*BIG UPDATE* - drop all triggers, create the new tables, copy
83
- the data over, then let it create the new triggers'''
84
-
85
- self._drop_triggers()
86
-
87
- self.exec_schema(TABLES_INDICES)
88
-
89
- self.backend.create_function('newid', 0, new_uuid)
90
- self.backend.execute('INSERT INTO object(hash, size, nexus, refs, last_path, last_modtime) SELECT hash, size, nexus, refs, last_path, last_lastmod FROM files')
91
- self.backend.execute('INSERT INTO file_tree(virtual_path, last_modified, hash, ino, size, metadata) SELECT path, lastmod, hash, ino, size, metadata FROM file_names')
92
- self.backend.execute('INSERT INTO symbolic_link(virtual_path, target) SELECT path, dest FROM links')
93
- self.backend.execute('INSERT INTO disk(uuid, name, nexus_index, relative_path, size) SELECT IFNULL(uuid, newid()), name, id - 1, relative_path, size FROM disks')
94
- self.backend.execute('INSERT INTO nexus(nexus, refs, totalsize) SELECT nexus, refs, totalsize FROM dist_nexus')
95
- self.backend.execute('DROP TABLE files')
96
- self.backend.execute('DROP TABLE file_names')
97
- self.backend.execute('DROP TABLE links')
98
- self.backend.execute('DROP TABLE disks')
99
- self.backend.execute('DROP TABLE dist_nexus')
100
- self.backend.execute('DROP TABLE IF EXISTS vars')
101
- self.backend.execute('DROP TABLE IF EXISTS source_lock')
102
-
103
- def _upgrade_to_22(self, oldvers):
104
- self._drop_triggers()
105
- self.backend.execute("ALTER TABLE file_tree ADD COLUMN maxcopies INTEGER")
106
- self.backend.execute("ALTER TABLE object ADD COLUMN maxcopies INTEGER")
107
-
108
- def _upgrade_to_23(self, oldvers):
109
- self._drop_triggers()
110
- self.backend.execute("ALTER TABLE nexus RENAME COLUMN count TO level")
111
-
112
- def _upgrade_to_24(self, oldvers):
113
- self.backend.execute("ALTER TABLE nexus ADD COLUMN disks TEXT NOT NULL DEFAULT ''")
114
- self.update_nexus_disk_text()
115
-
116
- def _upgrade_to_25(self, oldvers):
117
- self._drop_triggers()
118
- self.backend.execute('ALTER TABLE object RENAME TO old_object')
119
- self.backend.execute('ALTER TABLE file_tree RENAME TO old_file_tree')
120
- self.exec_schema(TABLES_INDICES)
121
- self.backend.execute('INSERT INTO object(hash, size, nexus, refs, priority, last_path, last_modtime) SELECT hash, size, nexus, refs, priority, last_path, last_modtime FROM old_object')
122
- self.backend.execute('INSERT INTO file_tree(virtual_path, last_modified, hash, ino, size, priority, metadata) SELECT virtual_path, last_modified, hash, ino, size, priority, metadata FROM old_file_tree')
123
- self.backend.execute('DROP TABLE old_object')
124
- self.backend.execute('DROP TABLE old_file_tree')
125
-
126
62
  def _upgrade_to_26(self, oldvers):
127
63
  self._drop_triggers()
128
64
 
@@ -158,8 +94,7 @@ class BackupDB(SQLiteDB):
158
94
  ("type = 'table' AND name = 'object'", (), lambda text: (
159
95
  re.sub(r'(last_path TEXT)(\s*,)', r"\1 NOT NULL DEFAULT ''\2",
160
96
  re.sub(r'(last_modtime INTEGER)(\s*,)', r"\1 NOT NULL DEFAULT 0\2", text))
161
- )),
162
- debug=True
97
+ ))
163
98
  )
164
99
 
165
100
  ##########################################################################################
@@ -193,15 +128,15 @@ class BackupDB(SQLiteDB):
193
128
  @in_transaction
194
129
  def copy_database(self, dest_dir=None):
195
130
  if dest_dir is None:
131
+ if not self.current_disk or not self.current_disk.data_path:
132
+ return
133
+
196
134
  dest_dir = self.current_disk.data_path
197
135
 
198
136
  self.execute_nonquery('PRAGMA wal_checkpoint(FULL)')
199
137
  if self.nocopydb:
200
138
  return
201
139
 
202
- if not self.current_disk or not self.current_disk.data_path:
203
- return
204
-
205
140
  progress = Progress.get_global()
206
141
  progress.set_status('copying database')
207
142
  tmppath = dest_dir / (DBNAME + '.new')
@@ -327,7 +262,7 @@ class BackupDB(SQLiteDB):
327
262
  @in_transaction('IMMEDIATE')
328
263
  def add_disk(self, name, path, size=None):
329
264
  uuid = new_uuid()
330
- new_disk = BackupDisk(uuid, name, None, size)
265
+ new_disk = BackupDisk(uuid=uuid, name=name, size=size)
331
266
  new_disk.set_from_path(path)
332
267
 
333
268
  try:
@@ -402,7 +337,7 @@ class BackupDB(SQLiteDB):
402
337
  def drop_disk_objects(self):
403
338
  index = self.current_disk.nexus_index
404
339
  with self.execute('SELECT nexus, level, totalsize FROM nexus') as curs:
405
- for nexus, copies, totalsize in curs:
340
+ for nexus, _, _ in curs:
406
341
  if disk_in_nexus(nexus, index):
407
342
  new_nexus = nexus_without_disk(nexus, index)
408
343
  self.execute_nonquery('UPDATE object SET nexus = ? WHERE nexus = ?', (new_nexus, nexus))
@@ -561,7 +496,7 @@ class BackupDB(SQLiteDB):
561
496
  for uuid, config in data['disks'].items():
562
497
  realuuid = self.query_scalar('SELECT uuid FROM disk WHERE uuid = ?', (canonical_uuid(uuid),))
563
498
  if realuuid is None:
564
- raise ValueError(f'Disk with UUID {realuuid} not in database (disks cannot be added with import)')
499
+ raise ValueError(f'Disk with UUID {uuid} not in database (disks cannot be added with import)')
565
500
 
566
501
  if 'size_bytes' in config:
567
502
  size = int(str(config['size_bytes']).replace(',', ''))
@@ -627,10 +562,10 @@ class BackupDB(SQLiteDB):
627
562
  with self.execute(sql, args) as curs:
628
563
  for row in curs:
629
564
  path = row[0]
565
+ relpath = path.lstrip('/')
630
566
  if rx and not rx.match(path):
631
567
  continue
632
-
633
- if any((base / path).exists() for base in existing):
568
+ if any((base / relpath).exists() for base in existing):
634
569
  continue
635
570
 
636
571
  yield row
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
19
19
 
20
20
  RX_HEX = re.compile(r'^[0-9a-fA-F]+$')
21
21
 
22
- def find_mountpoint(path, partition_by_mountpoint):
22
+ def find_mountpoint(path:Path, partition_by_mountpoint:dict[str, 'Partition']) -> tuple[Path, str, int, Optional['Partition']]:
23
23
  path = path.resolve()
24
24
  cpath = path
25
25
  cstat = path.stat()
@@ -46,33 +46,37 @@ class Partition:
46
46
  fsuuid: Optional[str] = None
47
47
 
48
48
  @classmethod
49
- def list(cls):
50
- rtn = []
51
- if sys.platform == 'linux':
52
- try:
53
- p = subprocess.run(
54
- 'lsblk --json -p -b -o name,size,label,uuid,mountpoint,fstype,fsavail,fssize --list'.split(),
55
- encoding='ascii', stdout=subprocess.PIPE, check=True
49
+ def _list_linux(cls):
50
+ try:
51
+ p = subprocess.run(
52
+ 'lsblk --json -p -b -o name,size,label,uuid,mountpoint,fstype,fsavail,fssize --list'.split(),
53
+ encoding='ascii', stdout=subprocess.PIPE, check=True
54
+ )
55
+ jsdata = json.loads(p.stdout)
56
+ rtn = []
57
+ for dev in jsdata['blockdevices']:
58
+ mountpoint = dev.get('mountpoint')
59
+ part = cls(
60
+ device=dev.get('name'),
61
+ mountpoint=Path(mountpoint) if mountpoint else None,
62
+ size=dev.get('fssize') or dev.get('size'),
63
+ label=dev.get('label'),
64
+ fstype=dev.get('fstype'),
65
+ fsuuid=dev.get('uuid'),
56
66
  )
57
- jsdata = json.loads(p.stdout)
58
- for dev in jsdata['blockdevices']:
59
- mountpoint = dev.get('mountpoint')
60
- part = cls(
61
- device=dev.get('name'),
62
- mountpoint=Path(mountpoint) if mountpoint else None,
63
- size=dev.get('fssize') or dev.get('size'),
64
- label=dev.get('label'),
65
- fstype=dev.get('fstype'),
66
- fsuuid=dev.get('uuid'),
67
- )
68
- if part.mountpoint or part.fsuuid:
69
- rtn.append(part)
70
-
71
- return rtn
72
-
73
- except (OSError, ValueError, KeyError) as e:
74
- log.warning(f'Unable to use `lsblk`: {e}', file=sys.stderr)
67
+ if part.mountpoint or part.fsuuid:
68
+ rtn.append(part)
75
69
 
70
+ return rtn
71
+
72
+ except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as e:
73
+ log.warning(f'Unable to use `lsblk`: {e}')
74
+ # Fallback to using psutil if lsblk fails
75
+ return cls._list_psutil()
76
+
77
+ @classmethod
78
+ def _list_psutil(cls):
79
+ rtn = []
76
80
  for disk in psutil.disk_partitions():
77
81
  if not disk.mountpoint:
78
82
  continue
@@ -80,6 +84,13 @@ class Partition:
80
84
  rtn.append(cls(device=disk.device, mountpoint=Path(disk.mountpoint), fstype=disk.fstype))
81
85
  return rtn
82
86
 
87
+ @classmethod
88
+ def list(cls):
89
+ if sys.platform == 'linux':
90
+ return cls._list_linux()
91
+
92
+ return cls._list_psutil()
93
+
83
94
  @dataclass
84
95
  class BackupDisk:
85
96
  uuid: str
@@ -126,7 +137,7 @@ class BackupDisk:
126
137
 
127
138
  def set_from_path(self, path: Path):
128
139
  partitions = Partition.list()
129
- partition_by_mountpoint = {part.mountpoint: part for part in partitions if part.mountpoint}
140
+ partition_by_mountpoint = {str(part.mountpoint): part for part in partitions if part.mountpoint}
130
141
  mountpt, relpath, totalsize, partition = find_mountpoint(path, partition_by_mountpoint)
131
142
 
132
143
  if not self.size:
@@ -199,7 +210,7 @@ class BackupDisk:
199
210
  rdisk = BackupDisk.read_config(part.mountpoint / disk.relative_path)
200
211
  if rdisk:
201
212
  if rdisk.uuid != disk.uuid:
202
- log.warning(f'Disk on partition {part.fsuuid} ({rdisk.data_path}) does not match expected UUID!', file=sys.stderr)
213
+ log.warning(f'Disk on partition {part.fsuuid} ({rdisk.data_path}) does not match expected UUID!')
203
214
  log.warning(f' Expected: {disk.name} {disk.uuid}')
204
215
  log.warning(f' Got : {rdisk.name} {rdisk.uuid}')
205
216
  continue
@@ -116,24 +116,38 @@ class Restore:
116
116
  for z in sorted(all_file_nexus):
117
117
  log.info(' '.join((disk_names.get(j, '?') if (z&(1<<j)) else '--').ljust(10) for j in disk_seq))
118
118
 
119
+ # Start with no disks chosen with all groups remaining
119
120
  found_list = None
120
121
  search_queue = deque()
121
122
  search_queue.append((0, all_file_nexus))
123
+
122
124
  while search_queue:
123
125
  chosen_disks, sets = search_queue.popleft()
126
+
127
+ # All disk IDs that haven't been chosen at this step - candidates for choosing next
124
128
  disk_set = [(disk_id, set()) for disk_id in disk_seq if not chosen_disks & (1<<disk_id)]
125
129
  for disk_id, next_set in disk_set:
126
130
  mask = 1 << disk_id
127
- if mask & chosen_disks:
128
- continue
131
+ # needed? filtered out in list comp above
132
+ #if mask & chosen_disks:
133
+ # continue
134
+ assert (mask & chosen_disks) == 0
135
+
136
+ # Remove any nexus groups that reference disk_id, giving us the remaining disks
129
137
  for nexus_set in sets:
130
138
  if not nexus_set & mask:
131
139
  next_set.add(nexus_set & ~mask)
132
140
 
141
+ # Sort the list of candidates by the size of the remaining set, smallest first
133
142
  disk_set.sort(key=lambda v: len(v[1]))
143
+
144
+ # Keep up to 4 candidates to reduce the search space
134
145
  del disk_set[4:]
146
+
147
+ # Queue up the next level of candidates
135
148
  for disk_id, next_set in disk_set:
136
149
  new_chosen = chosen_disks | (1 << disk_id)
150
+ # If the next set is empty, then new_chosen covers all files.
137
151
  if not next_set:
138
152
  found_list = new_chosen
139
153
  break
@@ -169,13 +169,18 @@ class Updater:
169
169
  def lock_path(self, vpath):
170
170
  log.info(f'{vpath} is locked')
171
171
 
172
+ vpath_without_slash = vpath.rstrip('/')
173
+ vpath_with_slash = vpath_without_slash + '/'
174
+
172
175
  links = self.symlink_unseen
173
- locked_links = [k for k in links if k.startswith(vpath)]
176
+ links.pop(vpath_without_slash, None)
177
+ locked_links = [k for k in links if k.startswith(vpath_with_slash)]
174
178
  for k in locked_links:
175
179
  del links[k]
176
180
 
177
181
  bypath = self.row_by_path
178
- locked_files = [k for k in bypath if k.startswith(vpath)]
182
+ bypath.pop(vpath_without_slash, None)
183
+ locked_files = [k for k in bypath if k.startswith(vpath_with_slash)]
179
184
  for k in locked_files:
180
185
  del bypath[k]
181
186
 
@@ -246,23 +251,13 @@ class Updater:
246
251
  continue
247
252
 
248
253
  try:
249
- fstat = lstat = f.lstat()
254
+ lstat = f.stat() if follow_links else f.lstat()
250
255
  except OSError as e:
251
- log.info(f'WARNING: cannot stat {f} ({e})')
256
+ log.info(f'WARNING: cannot stat {f} ({e}), locking')
257
+ self.lock_path(virtual_path)
252
258
  continue
253
259
 
254
- if follow_links and S_ISLNK(lstat.st_mode):
255
- try:
256
- fstat = f.stat()
257
- except OSError as e:
258
- # If we encounter a broken symbolic link when 'follow_symlinks' is
259
- # on, assume the destination is not mounted and lock it to prevent
260
- # its contents from being removed from the tree.
261
- log.info(f'WARNING: cannot follow link {f} ({e}), locking')
262
- self.lock_path(virtual_path)
263
- continue
264
-
265
- srcf = SourceFile(f, fstat)
260
+ srcf = SourceFile(f, lstat)
266
261
 
267
262
  srcf.read_meta()
268
263
  if srcf.priority is None:
@@ -0,0 +1,395 @@
1
+ import sys
2
+ import os
3
+ import json
4
+ import re
5
+ import uuid
6
+ import fnmatch
7
+ import traceback
8
+ from pathlib import Path
9
+
10
+ # Third-party imports
11
+ from ktpanda import fileutils
12
+ from ktpanda.textcolor import Colorizer
13
+
14
+ class CacheDict(dict):
15
+ '''Works like `defaultdict`, except it passes the key to the factory function'''
16
+ def __init__(self, factory):
17
+ super().__init__()
18
+ self.factory = factory
19
+
20
+ def __missing__(self, key):
21
+ r = self[key] = self.factory(key)
22
+ return r
23
+
24
+ class Progress:
25
+ '''
26
+ Class to track and display progress of an operation.
27
+ Provides a global instance for convenience.
28
+ '''
29
+ _global_instance = None
30
+
31
+ def __init__(self):
32
+ self.current_percent:float = 0.0
33
+ self.converted_progress:int = 0
34
+ self.status:str = ''
35
+
36
+ @classmethod
37
+ def set_global(cls, progress):
38
+ cls._global_instance = progress
39
+
40
+ @classmethod
41
+ def get_global(cls):
42
+ return cls._global_instance or NullProgress()
43
+
44
+ def set_status(self, status:str):
45
+ self.status = status
46
+ self.display()
47
+
48
+ def _convert_percent(self, percent:float):
49
+ return int(percent * 10)
50
+
51
+ def progress(self, percent:float):
52
+ if percent is None:
53
+ self.reset()
54
+ return
55
+
56
+ new_progress = self._convert_percent(percent)
57
+ if new_progress != self.converted_progress:
58
+ self.current_percent = percent
59
+ self.converted_progress = new_progress
60
+ self.display()
61
+
62
+ def reset(self):
63
+ self.status = ''
64
+ self.current_percent = 0.0
65
+ self.converted_progress = self._convert_percent(0.0)
66
+ self.clear()
67
+
68
+ def display(self):
69
+ raise NotImplementedError
70
+
71
+ def clear(self):
72
+ raise NotImplementedError
73
+
74
+ class NullProgress(Progress):
75
+ def display(self):
76
+ pass
77
+
78
+ def clear(self):
79
+ pass
80
+
81
+ class ProgressBar(Progress):
82
+ '''
83
+ Progress bar implementation of the `Progress` class.
84
+ Displays a textual progress bar in the terminal.
85
+ '''
86
+ def display(self):
87
+ barsz = 90 - len(self.status) - 12
88
+ barfill = int(self.current_percent * barsz / 100)
89
+ text = f'\r\033[?7l {self.status} {self.current_percent:5.1f}% |{"=" * barfill}{" " * (barsz - barfill)}|\r\033[?7h'
90
+ sys.stdout.write(text)
91
+ sys.stdout.flush()
92
+
93
+ def clear(self):
94
+ sys.stdout.write('\r\033[K')
95
+ sys.stdout.flush()
96
+
97
+ def progress(pct=None):
98
+ '''Convenience function to update the global progress instance.'''
99
+ if Progress._global_instance:
100
+ Progress._global_instance.progress(pct)
101
+
102
+ _unit = list("BKMGTPE")
103
+ _unit_color = [
104
+ 'fff',
105
+ '05f',
106
+ '3f3',
107
+ 'fc0',
108
+ 'c0f',
109
+ '0f9',
110
+ '0f0',
111
+ ]
112
+
113
+ def _prettysize(n):
114
+ '''
115
+ Helper function for `prettysize` and `prettysize_clr`.
116
+ Returns a tuple `(prefix, number, magnitude)` where `prefix` is '-' for negative numbers or '' for non-negative,
117
+ `number` is the formatted number as a string, and `magnitude` is the index of the unit in `_unit`.
118
+ '''
119
+ neg = n < 0
120
+ if neg:
121
+ n = -n
122
+ un = 0
123
+ while n >= 1000.0:
124
+ n /= 1000.0
125
+ un += 1
126
+ pfx = ('-' if neg else '')
127
+ if un:
128
+ return pfx, format(n, '.2f'), un
129
+ return pfx, format(n, 'd'), 0
130
+
131
+ def prettysize(n):
132
+ '''
133
+ Convert a size in bytes to a human-readable string with appropriate unit suffix, in SI units (powers of 1000, not 1024)
134
+ '''
135
+ pfx, num, magnitude = _prettysize(n)
136
+ return f'{pfx}{num}{_unit[magnitude]}'
137
+
138
+ def size_color(n):
139
+ '''
140
+ Return the color code associated with the magnitude of the given size `n`.
141
+ '''
142
+ _, _, magnitude = _prettysize(n)
143
+ return _unit_color[magnitude]
144
+
145
+ def prettysize_clr(n, padding=0):
146
+ '''
147
+ Convert a size in bytes to a human-readable string with appropriate unit suffix and color.
148
+ The result is right-justified to the specified `padding` width.
149
+ Returns a `Colorizer` instance with the text.
150
+ '''
151
+ pfx, num, magnitude = _prettysize(n)
152
+ return Colorizer().t(f'{pfx}{num}{_unit[magnitude]}'.rjust(padding), fg=_unit_color[magnitude])
153
+
154
+ RX_COMMA = re.compile(r'(\d\d\d)(?=\d)')
155
+ def addcomma(n:int|float|str) -> str:
156
+ '''
157
+ Format a number with commas as thousands separators. Leaves the decimal part unchanged.
158
+ Can be called with an integer, float, or string representing a number (without any separators).
159
+ '''
160
+ # Admittedly, this is very US-centric. I verified that str(<float>) always uses '.' as the decimal separator,
161
+ # even in locales like de-DE where ',' is used as the decimal separator. Other locales may use commas, but
162
+ # place them differently. Standard convention would be to use '_' as a separator, but I personally prefer ','
163
+ # since I made this tool for my own use.
164
+
165
+ intpart, sep, fracpart = str(n).partition('.')
166
+ # Reverse the integer part, insert commas every three digits, then reverse it back.
167
+ return RX_COMMA.sub(r'\1,', intpart[::-1])[::-1] + sep + fracpart
168
+
169
+ mul = dict(k=1000, m=1000000, g=1000000000, t=1000000000000)
170
+ def parsesize(s):
171
+ '''
172
+ Parse a human-readable size string (e.g., '10k', '5M', '2G') and return the size in bytes as an integer.
173
+ Supports 'k', 'm', 'g', 't' suffixes for kilo, mega, giga, and tera respectively.
174
+ '''
175
+ if s is None:
176
+ return None
177
+
178
+ s = s.lower().replace(',', '')
179
+ m = mul.get(s[-1:])
180
+ if m:
181
+ return int(float(s[:-1]) * m)
182
+ return int(s)
183
+
184
+ def read_file(p:Path, default=None):
185
+ '''
186
+ Read the contents of a file at path `p` and return it as a string. Any whitespace at the beginning or end is stripped.
187
+ If the file does not exist, return the `default` value.
188
+ '''
189
+ try:
190
+ r = p.read_text(encoding='utf-8').strip()
191
+ return r or default
192
+ except FileNotFoundError:
193
+ return default
194
+
195
+ def write_file(p:Path, data: str):
196
+ '''
197
+ Write the string `data` to a file at path `p`, appending a newline at the end.
198
+ The file is written with UTF-8 encoding.
199
+ Uses a temporary file and atomic replace to ensure safe writes.
200
+ '''
201
+ with fileutils.save_file(p, mode='wb') as fp:
202
+ fp.write(data.encode('utf-8') + b'\n')
203
+ fp.flush()
204
+ os.fsync(fp.fileno())
205
+
206
+ def try_unlink(p:str|Path):
207
+ '''
208
+ Try to unlink (delete) the file at path `p`.
209
+ Returns True if the file was successfully deleted, False if an error occurred.
210
+ '''
211
+ try:
212
+ os.unlink(p)
213
+ return True
214
+ except OSError:
215
+ return False
216
+
217
+ def check_dir(f:Path):
218
+ '''
219
+ Ensure that the parent directory of the file `f` exists.
220
+ Creates the directory and any necessary parent directories if they do not exist.
221
+ '''
222
+ f.parent.mkdir(parents=True, exist_ok=True)
223
+
224
+ def compact_json(data):
225
+ '''
226
+ Return a compact JSON representation of the given `data` object.
227
+ '''
228
+ return json.dumps(data, separators=(',',':'))
229
+
230
+ def canonical_path(path:str, tslash:bool|None=False) -> str:
231
+ '''
232
+ Canonicalize the given file path `path`.
233
+ Collapses multiple slashes, and removes extra slashes from the beginning.
234
+ If `tslash` is False (default), the resulting path will not have a trailing slash.
235
+ If `tslash` is True, the resulting path will always have a trailing slash.
236
+ If `tslash` is None, the resulting path will have a trailing slash only if `path` has one.
237
+ '''
238
+ if path is None:
239
+ return None
240
+
241
+ # Collapse all multiple slashes into one
242
+ path = re.sub(r'[\\/]+', '/', path)
243
+
244
+ # Remove extra slashes from the beginning, and if `tslash` is not None, also remove trailing slashes.
245
+ path = '/' + (path.lstrip('/') if tslash is None else path.strip('/'))
246
+
247
+ # Add the trailing slash back if `tslash` is True, but not if the path is just '/'.
248
+ if tslash and path != '/':
249
+ path += '/'
250
+ return path
251
+
252
+ ##########################################################################################
253
+ # Functions exported to SQLite
254
+ ##########################################################################################
255
+
256
+ SQL_FUNCS = {}
257
+ def sqlf(f):
258
+ '''
259
+ Decorator to register a function as an SQLite-exported function.
260
+ The decorated function will be added to the `SQL_FUNCS` dictionary.
261
+ '''
262
+ def ret(*args):
263
+ try:
264
+ return f(*args)
265
+ except Exception:
266
+ traceback.print_exc()
267
+ raise
268
+ SQL_FUNCS[f.__name__] = ret
269
+ return f
270
+
271
+ @sqlf
272
+ def disk_in_nexus(nexus, nexus_index):
273
+ '''
274
+ Returns True if the disk at the specified `nexus_index` is present in the `nexus` string, False otherwise.
275
+ '''
276
+ if nexus_index < 0 or nexus_index >= len(nexus):
277
+ return False
278
+ return nexus[nexus_index] == '1'
279
+
280
+ @sqlf
281
+ def nexus_with_disk(nexus, nexus_index):
282
+ '''
283
+ Returns a new `nexus` string with the disk at the specified `nexus_index` marked as present ('1').
284
+ If the `nexus_index` is beyond the current length of the `nexus` string, the string is extended with '0's as needed.
285
+ '''
286
+ if nexus_index >= len(nexus):
287
+ return nexus + '0' * (nexus_index - len(nexus)) + '1'
288
+ return nexus[:nexus_index] + '1' + nexus[nexus_index + 1:]
289
+
290
+ @sqlf
291
+ def nexus_without_disk(nexus, nexus_index):
292
+ '''
293
+ Returns a new `nexus` string with the disk at the specified `nexus_index` marked as absent ('0').
294
+ Trailing '0's are removed from the resulting string.
295
+ '''
296
+ if nexus_index >= len(nexus):
297
+ return nexus
298
+ return (nexus[:nexus_index] + '0' + nexus[nexus_index + 1:]).rstrip('0')
299
+
300
+ @sqlf
301
+ def nexus_level(nexus):
302
+ '''
303
+ Returns the number of disks present in the `nexus` string, i.e., the count of '1' characters.
304
+ '''
305
+ return nexus.count('1')
306
+
307
+ @sqlf
308
+ def new_uuid():
309
+ '''
310
+ Generate a new UUID and return it as a string.
311
+ '''
312
+ return str(uuid.uuid4())
313
+
314
+ @sqlf
315
+ def canonical_uuid(val):
316
+ '''
317
+ Convert the given UUID string `val` to its canonical form.
318
+ '''
319
+ return str(uuid.UUID(val))
320
+
321
+ def prefix_upper(prefix):
322
+ '''
323
+ Return the next string after the given `prefix` in lexicographical order.
324
+ This is used to construct SQL range queries for prefix matching.
325
+ '''
326
+ return prefix[:-1] + chr(1 + ord(prefix[-1:]))
327
+
328
+ def prefix_sql(column, prefix):
329
+ '''
330
+ Construct an SQL range query for the given `column` to match the specified `prefix`.
331
+ Returns a tuple `(query, params)` where `query` is the SQL condition string and `params` is a tuple of parameters.
332
+ '''
333
+ return f'({column} >= ? AND {column} < ?)', (prefix, prefix_upper(prefix))
334
+
335
+ def escape_like(text):
336
+ '''
337
+ Escape special characters in the given `text` for use in an SQL LIKE query.
338
+ '''
339
+ return text.replace('\\', r'\\').replace('%', r'\%').replace('_', r'\_')
340
+
341
+ def hash_relpath(hash):
342
+ '''
343
+ Returns the on-disk path for the given object `hash`.
344
+ '''
345
+ return f'distbackup-objects/{hash[:3]}/{hash}'
346
+
347
+ def pattern_to_regex(pat):
348
+ '''
349
+ Convert a shell-style pattern `pat` such as '*.txt' to a compiled regular expression and a prefix string.
350
+ The returned regular expression will also match any files in subdirectories of the specified prefix.
351
+ Returns a tuple `(regex, prefix)`, where `regex` is a compiled regular expression matching the pattern,
352
+ and `prefix` is the longest literal prefix of the pattern.
353
+ If the pattern is empty, returns `(None, None)`.
354
+ '''
355
+ if not pat:
356
+ return None, None
357
+
358
+ pat = canonical_path(pat, None)
359
+
360
+ prefix = None
361
+ m = re.match(r'^([^\*\[\?]+)', pat)
362
+ if m:
363
+ prefix = m.group(1)
364
+
365
+ rxpattern = fnmatch.translate(pat)
366
+
367
+ # fnmatch.translate tacks on \Z or \z (depending on Python version) to match the end of the string
368
+ if rxpattern.endswith(r'\Z') or rxpattern.endswith(r'\z'):
369
+ rxpattern = rxpattern[:-2]
370
+
371
+ # Match either an exact file, or any file in a subdirectory
372
+ if pat.endswith('/'):
373
+ rxpattern += r'(.*)?\Z'
374
+ else:
375
+ rxpattern += r'(/.*)?\Z'
376
+
377
+ return re.compile(rxpattern, re.S), prefix
378
+
379
+ def round_blocks(s):
380
+ '''
381
+ Round the given size `s` up to the nearest multiple of 4096, then adds one extra block of 4096 to account for metadata.
382
+ '''
383
+ return 4096 + ((s + 4095) & ~4095)
384
+
385
+ def int_mtime(m):
386
+ '''
387
+ Convert the given modification time `m` to an integer representing milliseconds since the epoch.
388
+ '''
389
+ return int(m * 1000)
390
+
391
+ def restore_mtime(m):
392
+ '''
393
+ Convert the given integer modification time `m` representing milliseconds since the epoch back to a floating-point number of seconds.
394
+ '''
395
+ return m / 1000.0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: distbackup
3
- Version: 1.0.19
3
+ Version: 1.0.21
4
4
  Summary: A backup manager which can back up a large set of files to a number of offline disks
5
5
  Home-page: https://ktpanda.org/software/distbackup
6
6
  Author: Katie Rust
@@ -9,9 +9,14 @@ License: MIT License
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.8
12
+ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
+ Requires-Dist: argcomplete>=2.0.0
16
+ Requires-Dist: psutil>=5.9.0
17
+ Requires-Dist: ktpanda_modules>=1.0.28
18
+ Requires-Dist: hashcopy>=1.0.5; platform_system == "Linux"
19
+ Dynamic: license-file
15
20
 
16
21
  Distbackup
17
22
  ==========
@@ -14,7 +14,7 @@ classifiers =
14
14
  Operating System :: OS Independent
15
15
 
16
16
  [options]
17
- python_requires = >=3.8
17
+ python_requires = >=3.10
18
18
  install_requires =
19
19
  argcomplete>=2.0.0
20
20
  psutil>=5.9.0
@@ -1 +0,0 @@
1
- VERSION = "1.0.19"
@@ -1,272 +0,0 @@
1
- import sys
2
- import os
3
- import json
4
- import re
5
- import uuid
6
- import argparse
7
- import fnmatch
8
- import traceback
9
- from ktpanda.textcolor import Colorizer
10
-
11
- class CacheDict(dict):
12
- '''Works like `defaultdict`, except it passes the key to the factory function'''
13
- def __init__(self, factory):
14
- super().__init__()
15
- self.factory = factory
16
-
17
- def __missing__(self, key):
18
- r = self[key] = self.factory(key)
19
- return r
20
-
21
- class Progress:
22
- _global_instance = None
23
-
24
- def __init__(self):
25
- self.current_percent:float = 0.0
26
- self.converted_progress:int = 0
27
- self.status:str = ''
28
-
29
- @classmethod
30
- def set_global(cls, progress):
31
- cls._global_instance = progress
32
-
33
- @classmethod
34
- def get_global(cls):
35
- return cls._global_instance or NullProgress()
36
-
37
- def set_status(self, status:str):
38
- self.status = status
39
- self.display()
40
-
41
- def _convert_percent(self, percent:float):
42
- return int(percent * 10)
43
-
44
- def progress(self, percent:float):
45
- if percent is None:
46
- self.reset()
47
- return
48
-
49
- new_progress = self._convert_percent(percent)
50
- if new_progress != self.converted_progress:
51
- self.current_percent = percent
52
- self.converted_progress = new_progress
53
- self.display()
54
-
55
- def reset(self):
56
- self.status = ''
57
- self.current_percent = 0.0
58
- self.converted_progress = self._convert_percent(0.0)
59
- self.clear()
60
-
61
- def display(self):
62
- raise NotImplementedError
63
-
64
- def clear(self):
65
- raise NotImplementedError
66
-
67
- class NullProgress(Progress):
68
- def display(self):
69
- pass
70
-
71
- def clear(self):
72
- pass
73
-
74
- class ProgressBar(Progress):
75
- def display(self):
76
- barsz = 90 - len(self.status) - 12
77
- barfill = int(self.current_percent * barsz / 100)
78
- text = f'\r\033[?7l {self.status} {self.current_percent:5.1f}% |{"=" * barfill}{" " * (barsz - barfill)}|\r\033[?7h'
79
- sys.stdout.write(text)
80
- sys.stdout.flush()
81
-
82
- def clear(self):
83
- sys.stdout.write('\r\033[K')
84
- sys.stdout.flush()
85
-
86
- def progress(pct=None):
87
- if Progress._global_instance:
88
- Progress._global_instance.progress(pct)
89
-
90
- _unit = list("BKMGTPE")
91
- _unit_color = [
92
- 'fff',
93
- '05f',
94
- '3f3',
95
- 'fc0',
96
- 'c0f',
97
- '0f9',
98
- '0f0',
99
- ]
100
-
101
- def _prettysize(n):
102
- neg = n < 0
103
- if neg:
104
- n = -n
105
- un = 0
106
- while n >= 1000.0:
107
- n /= 1000.0
108
- un += 1
109
- pfx = ('-' if neg else '')
110
- if un:
111
- return pfx, format(n, '.2f'), un
112
- return pfx, format(n, 'd'), 0
113
-
114
- def prettysize(n):
115
- pfx, num, magnitude = _prettysize(n)
116
- return f'{pfx}{num}{_unit[magnitude]}'
117
-
118
- def size_color(n):
119
- _, _, magnitude = _prettysize(n)
120
- return _unit_color[magnitude]
121
-
122
- def prettysize_clr(n, padding=0):
123
- pfx, num, magnitude = _prettysize(n)
124
- return Colorizer().t(f'{pfx}{num}{_unit[magnitude]}'.rjust(padding), fg=_unit_color[magnitude])
125
-
126
- RX_COMMA = re.compile(r'(\d\d\d)(?=\d)')
127
- def addcomma(n):
128
- n, sep, d = str(n).partition('.')
129
- return RX_COMMA.sub(r'\1,', n[::-1])[::-1] + sep + d
130
-
131
- mul = dict(k=1000, m=1000000, g=1000000000, t=1000000000000)
132
- def parsesize(s):
133
- if s is None:
134
- return None
135
-
136
- s = s.lower().replace(',', '')
137
- m = mul.get(s[-1:])
138
- if m:
139
- return int(float(s[:-1]) * m)
140
- return int(s)
141
-
142
- def read_file(p, default=None):
143
- try:
144
- r = p.read_text().strip()
145
- return r or default
146
- except FileNotFoundError:
147
- return default
148
-
149
- def write_file(p, data=None):
150
- tmpf = p.with_name(p.name + '~')
151
- with tmpf.open('w') as fp:
152
- fp.write(data + '\n')
153
- fp.flush()
154
- os.fsync(fp.fileno())
155
- tmpf.replace(p)
156
-
157
- def try_unlink(p):
158
- try:
159
- os.unlink(p)
160
- return True
161
- except OSError:
162
- return False
163
-
164
- def check_dir(f):
165
- f.parent.mkdir(parents=True, exist_ok=True)
166
-
167
- def compact_json(data):
168
- return json.dumps(data, separators=(',',':'))
169
-
170
- def canonical_path(path, tslash=False):
171
- if path is None:
172
- return None
173
-
174
- # Collapse all multiple slashes into one
175
- path = re.sub(r'[\\/]+', '/', path)
176
-
177
- # Remove extra slashes from the beginning
178
- path = '/' + (path.lstrip('/') if tslash is None else path.strip('/'))
179
- if tslash and path != '/':
180
- path += '/'
181
- return path
182
-
183
- ##########################################################################################
184
- # Functions exported to SQLite
185
- ##########################################################################################
186
-
187
- SQL_FUNCS = {}
188
- def sqlf(f):
189
- def ret(*args):
190
- try:
191
- return f(*args)
192
- except Exception:
193
- traceback.print_exc()
194
- raise
195
- SQL_FUNCS[f.__name__] = ret
196
- return f
197
-
198
- @sqlf
199
- def disk_in_nexus(nexus, nexus_index):
200
- if nexus_index < 0 or nexus_index >= len(nexus):
201
- return False
202
- return nexus[nexus_index] == '1'
203
-
204
- @sqlf
205
- def nexus_with_disk(nexus, nexus_index):
206
- if nexus_index >= len(nexus):
207
- return nexus + '0' * (nexus_index - len(nexus)) + '1'
208
- return nexus[:nexus_index] + '1' + nexus[nexus_index + 1:]
209
-
210
- @sqlf
211
- def nexus_without_disk(nexus, nexus_index):
212
- if nexus_index >= len(nexus):
213
- return nexus
214
- return (nexus[:nexus_index] + '0' + nexus[nexus_index + 1:]).rstrip('0')
215
-
216
- @sqlf
217
- def nexus_level(nexus):
218
- return nexus.count('1')
219
-
220
- @sqlf
221
- def new_uuid():
222
- return str(uuid.uuid4())
223
-
224
- @sqlf
225
- def canonical_uuid(val):
226
- return str(uuid.UUID(val))
227
-
228
- def prefix_upper(prefix):
229
- return prefix[:-1] + chr(1 + ord(prefix[-1:]))
230
-
231
- def prefix_sql(column, prefix):
232
- return f'({column} >= ? AND {column} < ?)', (prefix, prefix_upper(prefix))
233
-
234
- def escape_like(text):
235
- return text.replace('\\', r'\\').replace('%', r'\%').replace('_', r'\_')
236
-
237
- def hash_relpath(hash):
238
- return f'distbackup-objects/{hash[:3]}/{hash}'
239
-
240
- def pattern_to_regex(pat):
241
- if not pat:
242
- return None, None
243
-
244
- pat = canonical_path(pat, None)
245
-
246
- prefix = None
247
- m = re.match(r'^([^\*\.\[\?]+)', pat)
248
- if m:
249
- prefix = m.group(1)
250
-
251
- rxpattern = fnmatch.translate(pat)
252
-
253
- # fnmatch.translate tacks on \Z to match the end of the string
254
- if rxpattern.endswith(r'\Z'):
255
- rxpattern = rxpattern[:-2]
256
-
257
- # Match either an exact file, or any file in a subdirectory
258
- if pat.endswith('/'):
259
- rxpattern += r'(.*)?\Z'
260
- else:
261
- rxpattern += r'(/.*)?\Z'
262
-
263
- return re.compile(rxpattern, re.S), prefix
264
-
265
- def round_blocks(s):
266
- return 4096 + (s + 4095) & ~4095
267
-
268
- def int_mtime(m):
269
- return int(m * 1000)
270
-
271
- def restore_mtime(m):
272
- return m / 1000.0
File without changes
File without changes
File without changes