esrf-data-compressor 0.2.0__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.
File without changes
@@ -0,0 +1,77 @@
1
+ import os
2
+ from concurrent.futures import ProcessPoolExecutor, as_completed
3
+ from tqdm import tqdm
4
+
5
+ from esrf_data_compressor.checker.ssim import compute_ssim_for_file_pair
6
+ from esrf_data_compressor.utils.paths import resolve_compressed_path
7
+
8
+
9
+ def run_ssim_check(
10
+ raw_files: list[str], method: str, report_path: str, layout: str = "sibling"
11
+ ) -> None:
12
+ """
13
+ Given a list of raw HDF5 file paths, partitions into:
14
+ to_check → those with an expected compressed counterpart according to `layout`
15
+ missing → those without one
16
+
17
+ Writes a report to `report_path`:
18
+ - '=== NOT COMPRESSED FILES ===' listing each missing
19
+ - then for each to_check pair, computes SSIM in parallel and appends
20
+ per‐dataset SSIM lines under '=== <stem> ===' with full paths
21
+ """
22
+ to_check: list[tuple[str, str]] = []
23
+ missing: list[str] = []
24
+
25
+ # partition
26
+ for orig in raw_files:
27
+ comp_path = resolve_compressed_path(orig, method, layout=layout)
28
+ if os.path.exists(comp_path):
29
+ to_check.append((orig, comp_path))
30
+ else:
31
+ missing.append(orig)
32
+ print(
33
+ f"Found {len(to_check)} file pairs to check, {len(missing)} missing compressed files."
34
+ )
35
+
36
+ # write report
37
+ with open(report_path, "w") as rpt:
38
+ if missing:
39
+ rpt.write("=== NOT COMPRESSED FILES ===\n")
40
+ for orig in missing:
41
+ rpt.write(f"{orig} :: NO COMPRESSED DATASET FOUND\n")
42
+ rpt.write("\n")
43
+
44
+ if not to_check:
45
+ rpt.write("No file pairs to check (no compressed siblings found).\n")
46
+ return
47
+
48
+ # run SSIM in parallel
49
+ n_workers = min(len(to_check), os.cpu_count() or 1)
50
+ with ProcessPoolExecutor(max_workers=n_workers) as exe:
51
+ futures = {
52
+ exe.submit(compute_ssim_for_file_pair, orig, comp): (orig, comp)
53
+ for orig, comp in to_check
54
+ }
55
+
56
+ for fut in tqdm(
57
+ as_completed(futures),
58
+ total=len(futures),
59
+ desc="Checking SSIM (files)",
60
+ unit="file",
61
+ ):
62
+ orig, comp = futures[fut]
63
+ fname = os.path.basename(orig)
64
+ comp_name = os.path.basename(comp)
65
+ tqdm.write(f"Checking file: {fname} ↔ {comp_name}")
66
+ try:
67
+ # get results
68
+ basename, lines = fut.result()
69
+ # write section with both file paths
70
+ rpt.write(f"=== {basename} ===\n")
71
+ rpt.write(f"Uncompressed file: {orig}\n")
72
+ rpt.write(f"Compressed file: {comp}\n")
73
+ for line in lines:
74
+ rpt.write(line + "\n")
75
+ rpt.write("\n")
76
+ except Exception as e:
77
+ rpt.write(f"{orig} :: ERROR processing file pair: {e}\n\n")
@@ -0,0 +1,87 @@
1
+ # src/esrf_data_compressor/checker/ssim.py
2
+
3
+ import os
4
+ import numpy as np
5
+ import h5py
6
+ from skimage.metrics import structural_similarity as ssim
7
+
8
+
9
+ def _select_win_size(H: int, W: int) -> int:
10
+ """
11
+ Choose an odd, valid window size for SSIM given slice dimensions H×W.
12
+ win_size = min(H, W, 7), made odd, at least 3.
13
+ """
14
+ win = min(H, W, 7)
15
+ if win % 2 == 0:
16
+ win -= 1
17
+ return max(win, 3)
18
+
19
+
20
+ def compute_ssim_for_dataset_pair(
21
+ orig_path: str, comp_path: str, dataset_relpath: str
22
+ ) -> tuple[float, float]:
23
+ """
24
+ Given two HDF5 files and the relative 3D dataset path (e.g., 'entry_0000/ESRF-ID11/marana/data'),
25
+ compute SSIM on the first (z=0) and last (z=Z-1) slices.
26
+ Returns (ssim_first, ssim_last). If a slice is constant, SSIM = 1.0.
27
+ """
28
+ with h5py.File(orig_path, "r") as fo, h5py.File(comp_path, "r") as fc:
29
+ ds_o = fo[dataset_relpath]
30
+ ds_c = fc[dataset_relpath]
31
+
32
+ # Ensure both datasets are 3D
33
+ if ds_o.ndim != 3 or ds_c.ndim != 3:
34
+ raise IndexError(
35
+ f"Dataset '{dataset_relpath}' is not 3D (orig: {ds_o.ndim}D, comp: {ds_c.ndim}D)"
36
+ )
37
+
38
+ first_o = ds_o[0].astype(np.float64)
39
+ last_o = ds_o[-1].astype(np.float64)
40
+ first_c = ds_c[0].astype(np.float64)
41
+ last_c = ds_c[-1].astype(np.float64)
42
+
43
+ H, W = first_o.shape
44
+ win = _select_win_size(H, W)
45
+
46
+ def _slice_ssim(a: np.ndarray, b: np.ndarray) -> float:
47
+ amin, amax = a.min(), a.max()
48
+ if amax == amin:
49
+ return 1.0
50
+ dr = amax - amin
51
+ return ssim(a, b, data_range=dr, win_size=win)
52
+
53
+ s0 = _slice_ssim(first_o, first_c)
54
+ s1 = _slice_ssim(last_o, last_c)
55
+ return s0, s1
56
+
57
+
58
+ def compute_ssim_for_file_pair(orig_path: str, comp_path: str) -> tuple[str, list[str]]:
59
+ """
60
+ Compute SSIM for every 3D dataset under `orig_path` vs. `comp_path`.
61
+ Returns (basename, [report_lines…]), where each line is either:
62
+ "<dataset_relpath>: SSIM_first=… SSIM_last=…" or an error message.
63
+ """
64
+ basename = os.path.basename(orig_path)
65
+ report_lines: list[str] = []
66
+
67
+ with h5py.File(orig_path, "r") as fo:
68
+ ds_paths: list[str] = []
69
+
70
+ def visitor(name, obj):
71
+ if isinstance(obj, h5py.Dataset) and obj.ndim == 3:
72
+ ds_paths.append(name)
73
+
74
+ fo.visititems(visitor)
75
+
76
+ if not ds_paths:
77
+ report_lines.append(f"No 3D datasets found in {basename}")
78
+ return basename, report_lines
79
+
80
+ for ds in ds_paths:
81
+ try:
82
+ s0, s1 = compute_ssim_for_dataset_pair(orig_path, comp_path, ds)
83
+ report_lines.append(f"{ds}: SSIM_first={s0:.4f} SSIM_last={s1:.4f}")
84
+ except Exception as e:
85
+ report_lines.append(f"{ds}: ERROR computing SSIM: {e}")
86
+
87
+ return basename, report_lines
@@ -0,0 +1,193 @@
1
+ import os
2
+ import argparse
3
+ from esrf_data_compressor.finder.finder import find_vds_files, write_report
4
+ from esrf_data_compressor.compressors.base import CompressorManager
5
+ from esrf_data_compressor.checker.run_check import run_ssim_check
6
+ from esrf_data_compressor.utils.hdf5_helpers import exit_with_error
7
+ from esrf_data_compressor.utils.utils import parse_report
8
+
9
+
10
+ def get_path_components(args):
11
+ comps = [args.experiment]
12
+ if args.beamline:
13
+ comps.append(args.beamline)
14
+ if args.session:
15
+ comps.append(args.session)
16
+ return comps
17
+
18
+
19
+ def do_list(args):
20
+ """
21
+ 1) Discover datasets under RAW_DATA/<components...>
22
+ 2) Apply dataset‑level filters (--filter key:val[,key2:val2...])
23
+ 3) Extract VDS source files from every dataset
24
+ 4) Write two‑section report:
25
+ ## TO COMPRESS ## (sources from matching datasets)
26
+ ## REMAINING ## (sources from non‑matching datasets)
27
+ """
28
+ comps = get_path_components(args)
29
+ try:
30
+ to_c, rem = find_vds_files(comps, base_root=args.root, filter_expr=args.filter)
31
+ except SystemExit as e:
32
+ exit_with_error(str(e))
33
+
34
+ report_path = args.output or "file_list.txt"
35
+ write_report(to_c, rem, report_path)
36
+ print(f"Report written to {report_path}")
37
+
38
+
39
+ def do_compress(args):
40
+ report = args.input
41
+ if not report:
42
+ exit_with_error("The --input report file must be specified for compress")
43
+ try:
44
+ files = parse_report(report)
45
+ except Exception as e:
46
+ exit_with_error(f"Failed to read report '{report}': {e}")
47
+
48
+ if not files:
49
+ print("Nothing to compress (TO COMPRESS list is empty).")
50
+ return
51
+
52
+ print(
53
+ f"Compressing {len(files)} file(s) from '{report}' using '{args.method}' method, ratio {args.cratio}, layout '{args.layout}' …"
54
+ )
55
+ mgr = CompressorManager(cratio=args.cratio, method=args.method, layout=args.layout)
56
+ mgr.compress_files(files)
57
+ print("Compression complete.\n")
58
+
59
+
60
+ def do_check(args):
61
+ report = args.input or "file_list.txt"
62
+ try:
63
+ files = parse_report(report)
64
+ except Exception as e:
65
+ exit_with_error(f"Failed to read report '{report}': {e}")
66
+
67
+ if not files:
68
+ print("Nothing to check (TO COMPRESS list is empty).")
69
+ return
70
+
71
+ report_fname = f"{os.path.splitext(report)[0]}_{args.method}_ssim_report.txt"
72
+ report_path = os.path.abspath(report_fname)
73
+
74
+ try:
75
+ run_ssim_check(files, args.method, report_path, layout=args.layout)
76
+ except SystemExit as e:
77
+ exit_with_error(str(e))
78
+
79
+ print(f"SSIM report written to {report_path}\n")
80
+
81
+
82
+ def do_overwrite(args):
83
+ report = args.input or "file_list.txt"
84
+ try:
85
+ files = parse_report(report)
86
+ except Exception as e:
87
+ exit_with_error(f"Failed to read report '{report}': {e}")
88
+
89
+ if not files:
90
+ print("Nothing to process (TO COMPRESS list is empty).")
91
+ return
92
+
93
+ mgr = CompressorManager()
94
+
95
+ if args.final:
96
+ print(f"Finalizing overwrite for {len(files)} file(s) from '{report}' …")
97
+ mgr.remove_backups(files)
98
+ print("Finalize step complete.\n")
99
+ return
100
+
101
+ if args.undo:
102
+ print(f"Undoing overwrite for {len(files)} file(s) from '{report}' …")
103
+ mgr.restore_backups(files)
104
+ print("Undo step complete.\n")
105
+ return
106
+
107
+ print(f"Overwriting {len(files)} file(s) from '{report}' …")
108
+ mgr.overwrite_files(files)
109
+ print("Overwrite complete (backups kept).\n")
110
+
111
+
112
+ def main():
113
+ parser = argparse.ArgumentParser(
114
+ description="List, compress, check or overwrite ESRF HDF5 VDS sources."
115
+ )
116
+ sub = parser.add_subparsers(dest="command", required=True)
117
+
118
+ p = sub.add_parser("list", help="Report VDS sources → TO COMPRESS vs REMAINING")
119
+ p.add_argument("experiment", help="Experiment ID")
120
+ p.add_argument("beamline", nargs="?", help="Optional beamline")
121
+ p.add_argument("session", nargs="?", help="Optional session")
122
+ p.add_argument("--root", default="/data/visitor", help="Base directory")
123
+ p.add_argument(
124
+ "--filter",
125
+ metavar="KEY:VAL[,KEY2:VAL2...]",
126
+ help="Dataset-level attribute substring filters",
127
+ )
128
+ p.add_argument("--output", help="Report file (default = file_list.txt)")
129
+ p.set_defaults(func=do_list)
130
+
131
+ p = sub.add_parser("compress", help="Compress only the TO COMPRESS files")
132
+ p.add_argument(
133
+ "--input",
134
+ "-i",
135
+ required=True,
136
+ help="Report file to read (must be produced by `list`)",
137
+ )
138
+ p.add_argument("--cratio", type=int, default=10, help="Compression ratio")
139
+ p.add_argument(
140
+ "--method",
141
+ choices=["jp2k"],
142
+ default="jp2k",
143
+ help="Compression method",
144
+ )
145
+ p.add_argument(
146
+ "--layout",
147
+ choices=["sibling", "mirror"],
148
+ default="mirror",
149
+ help="Output layout: sibling (next to each source) or mirror (under RAW_DATA_COMPRESSED, preserving source names).",
150
+ )
151
+ p.set_defaults(func=do_compress)
152
+
153
+ p = sub.add_parser("check", help="Generate SSIM report for TO COMPRESS files")
154
+ p.add_argument(
155
+ "--input", "-i", help="Report file to read (default = file_list.txt)"
156
+ )
157
+ p.add_argument(
158
+ "--method", choices=["jp2k"], default="jp2k", help="Compression method"
159
+ )
160
+ p.add_argument(
161
+ "--layout",
162
+ choices=["sibling", "mirror"],
163
+ default="mirror",
164
+ help="Location of compressed files to check.",
165
+ )
166
+ p.set_defaults(func=do_check)
167
+
168
+ p = sub.add_parser(
169
+ "overwrite",
170
+ help="Swap in compressed files and keep backups; with --final or --undo, perform cleanup/restore only.",
171
+ )
172
+ p.add_argument(
173
+ "--input", "-i", help="Report file to read (default = file_list.txt)"
174
+ )
175
+ group = p.add_mutually_exclusive_group()
176
+ group.add_argument(
177
+ "--final",
178
+ action="store_true",
179
+ help="Cleanup only: delete existing *.h5.bak backups after confirmation (no overwrite).",
180
+ )
181
+ group.add_argument(
182
+ "--undo",
183
+ action="store_true",
184
+ help="Restore only: move <file>.h5.bak back to <file>.h5 and preserve the current file as <file>_<method>.h5 when needed.",
185
+ )
186
+ p.set_defaults(func=do_overwrite)
187
+
188
+ args = parser.parse_args()
189
+ args.func(args)
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
File without changes
@@ -0,0 +1,271 @@
1
+ import os
2
+ import shutil
3
+ from concurrent.futures import ProcessPoolExecutor, as_completed
4
+ from tqdm import tqdm
5
+
6
+ from esrf_data_compressor.compressors.jp2k import JP2KCompressorWrapper
7
+ from esrf_data_compressor.utils.paths import (
8
+ find_dataset_base_h5,
9
+ resolve_compressed_path,
10
+ resolve_mirror_path,
11
+ )
12
+
13
+
14
+ class Compressor:
15
+ """
16
+ Abstract base class. Subclasses must implement compress_file().
17
+ """
18
+
19
+ def compress_file(self, input_path: str, output_path: str, **kwargs):
20
+ raise NotImplementedError
21
+
22
+
23
+ class CompressorManager:
24
+ """
25
+ Manages parallel compression and overwrite.
26
+
27
+ Each worker process is given up to 4 Blosc2 threads (or fewer if the machine
28
+ has fewer than 4 cores). The number of worker processes is then
29
+ total_cores // threads_per_worker (at least 1). If the user explicitly
30
+ passes `workers`, we cap it to `total_cores`, then recompute threads_per_worker
31
+ = min(4, total_cores // workers).
32
+
33
+ Usage:
34
+ mgr = CompressorManager(cratio=10, method='jp2k')
35
+ mgr.compress_files([...])
36
+ mgr.overwrite_files([...])
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ workers: int | None = None,
42
+ cratio: int = 10,
43
+ method: str = "jp2k",
44
+ layout: str = "sibling",
45
+ ):
46
+ total_cores = os.cpu_count() or 1
47
+ default_nthreads = 4 if total_cores >= 4 else 1
48
+ default_workers = max(1, total_cores // default_nthreads)
49
+
50
+ if workers is None:
51
+ w = default_workers
52
+ nthreads = default_nthreads
53
+ else:
54
+ w = min(workers, total_cores)
55
+ possible = total_cores // w
56
+ nthreads = min(possible, 4) if possible >= 1 else 1
57
+
58
+ self.workers = max(1, w)
59
+ self.nthreads = max(1, nthreads)
60
+ self.cratio = cratio
61
+ self.method = method
62
+ self.layout = layout
63
+
64
+ if self.method == "jp2k":
65
+ self.compressor = JP2KCompressorWrapper(
66
+ cratio=cratio, nthreads=self.nthreads
67
+ )
68
+ else:
69
+ raise ValueError(f"Unsupported compression method: {self.method}")
70
+
71
+ print(f"Compression method: {self.method}")
72
+ print(f"Output layout: {self.layout}")
73
+ print(f"Total CPU cores: {total_cores}")
74
+ print(f"Worker processes: {self.workers}")
75
+ print(f"Threads per worker: {self.nthreads}")
76
+ print(f"Total threads: {self.workers * self.nthreads}")
77
+
78
+ def _compress_worker(self, ipath: str) -> tuple[str, str]:
79
+ """
80
+ Worker function for ProcessPoolExecutor: compress a single HDF5:
81
+ - sibling layout: <same_dir>/<basename>_<method>.h5
82
+ - mirror layout: mirror RAW_DATA tree under RAW_DATA_COMPRESSED
83
+ """
84
+ outp = resolve_compressed_path(ipath, self.method, layout=self.layout)
85
+ os.makedirs(os.path.dirname(outp), exist_ok=True)
86
+ self.compressor.compress_file(
87
+ ipath, outp, cratio=self.cratio, nthreads=self.nthreads
88
+ )
89
+ return ipath, "success"
90
+
91
+ def _mirror_non_compressed_dataset_content(self, file_list: list[str]) -> None:
92
+ source_targets = {os.path.realpath(p) for p in file_list}
93
+ mirror_roots: set[str] = set()
94
+ for ipath in file_list:
95
+ base_h5 = find_dataset_base_h5(ipath)
96
+ dataset_dir = (
97
+ os.path.dirname(base_h5) if base_h5 else os.path.dirname(ipath)
98
+ )
99
+ # Mirror the parent sample folder too, so sidecar files next to
100
+ # dataset folders are preserved (e.g. RAW_DATA/<sample>/*.h5).
101
+ mirror_roots.add(os.path.dirname(dataset_dir))
102
+
103
+ for src_dir in sorted(mirror_roots):
104
+ try:
105
+ dst_dir = resolve_mirror_path(src_dir)
106
+ except ValueError:
107
+ print(f"WARNING: Cannot mirror folder outside RAW_DATA: '{src_dir}'")
108
+ continue
109
+
110
+ for cur, dirs, files in os.walk(src_dir):
111
+ rel_cur = os.path.relpath(cur, src_dir)
112
+ target_cur = (
113
+ dst_dir if rel_cur == "." else os.path.join(dst_dir, rel_cur)
114
+ )
115
+ os.makedirs(target_cur, exist_ok=True)
116
+
117
+ for dname in dirs:
118
+ os.makedirs(os.path.join(target_cur, dname), exist_ok=True)
119
+
120
+ for fname in files:
121
+ src_file = os.path.join(cur, fname)
122
+ if os.path.realpath(src_file) in source_targets:
123
+ # Do not copy raw files that will be produced by compression.
124
+ continue
125
+ dst_file = os.path.join(target_cur, fname)
126
+ shutil.copy2(src_file, dst_file)
127
+
128
+ def compress_files(self, file_list: list[str]) -> None:
129
+ """
130
+ Compress each .h5 in file_list in parallel.
131
+ - sibling layout: produce <basename>_<method>.h5 next to each source.
132
+ - mirror layout: write compressed files to RAW_DATA_COMPRESSED with same file names.
133
+ Does not overwrite originals. At the end, prints total elapsed time and data rate in MB/s.
134
+ """
135
+ valid = [p for p in file_list if p.lower().endswith(".h5")]
136
+ if not valid:
137
+ print("No valid .h5 files to compress.")
138
+ return
139
+ if self.layout == "mirror":
140
+ print(
141
+ "Preparing RAW_DATA_COMPRESSED with non-compressed dataset content..."
142
+ )
143
+ self._mirror_non_compressed_dataset_content(valid)
144
+
145
+ total_bytes = 0
146
+ for f in valid:
147
+ try:
148
+ total_bytes += os.path.getsize(f)
149
+ except OSError:
150
+ pass
151
+
152
+ import time
153
+
154
+ t0 = time.time()
155
+
156
+ with ProcessPoolExecutor(max_workers=self.workers) as executor:
157
+ futures = {executor.submit(self._compress_worker, p): p for p in valid}
158
+ for fut in tqdm(
159
+ as_completed(futures),
160
+ total=len(futures),
161
+ desc=f"Compressing HDF5 files ({self.method})",
162
+ unit="file",
163
+ ):
164
+ pth = futures[fut]
165
+ try:
166
+ fut.result()
167
+ except Exception as e:
168
+ print(f"Failed to compress '{pth}': {e}")
169
+
170
+ elapsed = time.time() - t0
171
+ total_mb = total_bytes / (1024 * 1024)
172
+ rate_mb_s = total_mb / elapsed if elapsed > 0 else float("inf")
173
+ print(f"\nTotal elapsed time: {elapsed:.3f}s")
174
+ print(f"Data processed: {total_mb:.2f} MB ({rate_mb_s:.2f} MB/s)\n")
175
+
176
+ def overwrite_files(self, file_list: list[str]) -> None:
177
+ """
178
+ Overwrites files only if they have a compressed sibling:
179
+
180
+ 1) Rename <file>.h5 → <file>.h5.bak
181
+ 2) Rename <file>_<method>.h5 → <file>.h5
182
+
183
+ After processing all files, removes the backup .h5.bak files.
184
+ """
185
+ for ipath in file_list:
186
+ if not ipath.lower().endswith(".h5"):
187
+ continue
188
+
189
+ compressed_path = resolve_compressed_path(
190
+ ipath, self.method, layout=self.layout
191
+ )
192
+
193
+ if os.path.exists(compressed_path):
194
+ backup = ipath + ".bak"
195
+ try:
196
+ os.replace(ipath, backup)
197
+ os.replace(compressed_path, ipath)
198
+ print(f"Overwritten '{ipath}' (backup at '{backup}').")
199
+ except Exception as e:
200
+ print(f"ERROR overwriting '{ipath}': {e}")
201
+ else:
202
+ print(f"SKIP (no compressed file): {ipath}")
203
+
204
+ def remove_backups(self, file_list: list[str]) -> None:
205
+ candidates = {p + ".bak" for p in file_list if p.lower().endswith(".h5")}
206
+ backups = [b for b in candidates if os.path.exists(b)]
207
+ if not backups:
208
+ print("No backup files to remove.")
209
+ return
210
+
211
+ total_bytes = 0
212
+ for b in backups:
213
+ try:
214
+ total_bytes += os.path.getsize(b)
215
+ except OSError:
216
+ pass
217
+ total_mb = total_bytes / (1024 * 1024)
218
+
219
+ print(
220
+ f"About to remove {len(backups)} backup file(s), ~{total_mb:.2f} MB total."
221
+ )
222
+ ans = input("Proceed? [y/N]: ").strip().lower()
223
+ if ans not in ("y", "yes"):
224
+ print("Backups kept.")
225
+ return
226
+
227
+ removed = 0
228
+ for b in backups:
229
+ try:
230
+ os.remove(b)
231
+ removed += 1
232
+ except Exception as e:
233
+ print(f"ERROR deleting backup '{b}': {e}")
234
+
235
+ print(f"Deleted {removed} backup file(s).")
236
+
237
+ def restore_backups(self, file_list: list[str]) -> None:
238
+ restored = 0
239
+ preserved = 0
240
+ for ipath in file_list:
241
+ if not ipath.lower().endswith(".h5"):
242
+ continue
243
+
244
+ backup = ipath + ".bak"
245
+ method_path = resolve_compressed_path(
246
+ ipath, self.method, layout=self.layout
247
+ )
248
+
249
+ if not os.path.exists(backup):
250
+ print(f"SKIP (no backup): {ipath}")
251
+ continue
252
+
253
+ if os.path.exists(ipath) and not os.path.exists(method_path):
254
+ try:
255
+ os.replace(ipath, method_path)
256
+ preserved += 1
257
+ print(f"Preserved current file to '{method_path}'.")
258
+ except Exception as e:
259
+ print(f"ERROR preserving current '{ipath}' to '{method_path}': {e}")
260
+ continue
261
+
262
+ try:
263
+ os.replace(backup, ipath)
264
+ restored += 1
265
+ print(f"Restored '{ipath}' from backup.")
266
+ except Exception as e:
267
+ print(f"ERROR restoring '{ipath}' from '{backup}': {e}")
268
+
269
+ print(
270
+ f"Restore complete. Restored: {restored}, preserved compressed copies: {preserved}."
271
+ )