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.
- {distbackup-1.0.19 → distbackup-1.0.21}/PKG-INFO +8 -3
- distbackup-1.0.21/distbackup/__init__.py +1 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/cli.py +0 -1
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/database.py +9 -74
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/disk.py +39 -28
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/restore.py +16 -2
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/update.py +11 -16
- distbackup-1.0.21/distbackup/utils.py +395 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/PKG-INFO +8 -3
- {distbackup-1.0.19 → distbackup-1.0.21}/setup.cfg +1 -1
- distbackup-1.0.19/distbackup/__init__.py +0 -1
- distbackup-1.0.19/distbackup/utils.py +0 -272
- {distbackup-1.0.19 → distbackup-1.0.21}/LICENSE +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/README.md +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/__main__.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/backup.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/constants.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/errors.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/logger.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/nexus.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/objects.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/report.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/schema.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/sourcefile.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup/verify.py +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/SOURCES.txt +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/dependency_links.txt +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/entry_points.txt +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/requires.txt +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/distbackup.egg-info/top_level.txt +0 -0
- {distbackup-1.0.19 → distbackup-1.0.21}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: distbackup
|
|
3
|
-
Version: 1.0.
|
|
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.
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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!'
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: distbackup
|
|
3
|
-
Version: 1.0.
|
|
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.
|
|
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
|
==========
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|