Fix numeric parsing, active file status, and checksum flow

This commit is contained in:
2026-02-24 22:19:35 +03:00
parent 9a557e6754
commit 1236ba09ac

213
main.py
View File

@@ -40,6 +40,7 @@ COPY_RETRIES = 2
COPY_RETRY_DELAY = 2
DEFAULT_MIN_FREE_GB = 1
DEFAULT_MAX_WORKERS = 3
DEFAULT_KEEP_FILES = 1
DEFAULT_INCLUDE_MASKS = "*.bak;*.sql;*.backup"
DEFAULT_EXCLUDE_MASKS = "*.tmp;*.temp"
MUTEX_NAME = "Global\\BackupCopierMutex"
@@ -106,6 +107,12 @@ def verify_copy(source: Path, target: Path) -> bool:
return compute_file_checksum(source) == compute_file_checksum(target)
def compare_file_checksums(source: Path, target: Path) -> tuple[bool, str, str]:
src_hash = compute_file_checksum(source)
dst_hash = compute_file_checksum(target)
return src_hash == dst_hash, src_hash, dst_hash
def get_resource_path(relative_path: str) -> str:
"""Возвращает абсолютный путь к ресурсу (поддерживает PyInstaller)."""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
@@ -274,6 +281,8 @@ class BackgroundFileCopyApp:
# Флаг для отслеживания состояния
self.is_copying = False
self.copy_lock = threading.Lock()
self.active_files_lock = threading.Lock()
self.active_files: Dict[str, int] = {}
# Автозапуск
self.autostart_enabled = tk.BooleanVar(value=False)
@@ -302,6 +311,7 @@ class BackgroundFileCopyApp:
self.min_free_gb_var = tk.DoubleVar(value=DEFAULT_MIN_FREE_GB)
self.max_workers_var = tk.IntVar(value=DEFAULT_MAX_WORKERS)
self.cleanup_old_var = tk.BooleanVar(value=False)
self.keep_files_var = tk.IntVar(value=DEFAULT_KEEP_FILES)
self.schedules: List[dict] = []
self.schedule_days_vars = {
"Mon": tk.BooleanVar(value=False),
@@ -479,6 +489,12 @@ class BackgroundFileCopyApp:
command=lambda: self.show_hint("Очистка",
"В режиме 'последний файл' удаляет старые файлы в папке назначения.")).pack(side=tk.LEFT, padx=4)
ttk.Label(options_row2, text="Хранить последних:").pack(side=tk.LEFT, padx=(10, 4))
self.keep_files_spin = ttk.Spinbox(options_row2, from_=1, to=999, width=4, textvariable=self.keep_files_var)
self.keep_files_spin.pack(side=tk.LEFT)
self.keep_files_spin.bind("<KeyRelease>", lambda _e: self.request_autosave())
self.keep_files_spin.bind("<FocusOut>", lambda _e: self.request_autosave())
perf_frame = ttk.Frame(schedule_frame)
perf_frame.pack(fill=tk.X, pady=4)
ttk.Label(perf_frame, text="Мин. свободно, ГБ:").pack(side=tk.LEFT)
@@ -728,6 +744,7 @@ class BackgroundFileCopyApp:
profiles["Default"]["min_free_gb"] = settings.get('min_free_gb', DEFAULT_MIN_FREE_GB)
profiles["Default"]["max_workers"] = settings.get('max_workers', DEFAULT_MAX_WORKERS)
profiles["Default"]["verify_only"] = settings.get('verify_only', False)
profiles["Default"]["keep_files"] = settings.get('keep_files', DEFAULT_KEEP_FILES)
profiles["Default"]["last_success_time"] = settings.get('last_success_time')
profiles["Default"]["last_result_summary"] = settings.get('last_result_summary')
profiles["Default"]["last_hashes"] = settings.get('last_hashes', {})
@@ -736,7 +753,7 @@ class BackgroundFileCopyApp:
self.profiles = profiles
self.refresh_profile_combo()
active = settings.get("active_profile") or self.active_profile.get()
self.load_profile(active)
self.load_profile(active, save_current=False)
# Настройки автозапуска
actual_autostart = self.is_autostart_enabled()
@@ -754,7 +771,7 @@ class BackgroundFileCopyApp:
self.log_message("🆕 Файл настроек не найден, создаем новую конфигурацию", "info")
self.profiles = {"Default": self.default_profile()}
self.refresh_profile_combo()
self.load_profile("Default")
self.load_profile("Default", save_current=False)
self.autostart_enabled.set(self.is_autostart_enabled())
except Exception as e:
@@ -763,7 +780,7 @@ class BackgroundFileCopyApp:
traceback.print_exc()
self.profiles = {"Default": self.default_profile()}
self.refresh_profile_combo()
self.load_profile("Default")
self.load_profile("Default", save_current=False)
self.autostart_enabled.set(self.is_autostart_enabled())
finally:
self.autosave_enabled = True
@@ -877,7 +894,8 @@ class BackgroundFileCopyApp:
return None
def update_pair_row(self, pair):
self.render_pairs()
# Treeview is not thread-safe; schedule redraw in UI thread.
self.run_on_ui(self.render_pairs)
def shorten_path(self, path: str, max_len: int = 40) -> str:
if len(path) <= max_len:
@@ -1316,6 +1334,7 @@ class BackgroundFileCopyApp:
"max_workers": DEFAULT_MAX_WORKERS,
"verify_only": False,
"cleanup_old": False,
"keep_files": DEFAULT_KEEP_FILES,
"last_success_time": None,
"last_result_summary": None,
"last_hashes": {},
@@ -1337,19 +1356,24 @@ class BackgroundFileCopyApp:
profile["schedules"] = list(self.schedules)
profile["include_masks"] = self.include_masks_var.get()
profile["exclude_masks"] = self.exclude_masks_var.get()
profile["min_free_gb"] = float(self.min_free_gb_var.get() or DEFAULT_MIN_FREE_GB)
profile["max_workers"] = int(self.max_workers_var.get() or DEFAULT_MAX_WORKERS)
profile["min_free_gb"] = self.get_float_setting(self.min_free_gb_var, DEFAULT_MIN_FREE_GB)
profile["max_workers"] = self.get_int_setting(self.max_workers_var, DEFAULT_MAX_WORKERS, minimum=1)
profile["verify_only"] = bool(self.verify_only.get())
profile["cleanup_old"] = bool(self.cleanup_old_var.get())
try:
profile["keep_files"] = max(1, int(self.keep_files_var.get() or DEFAULT_KEEP_FILES))
except Exception:
profile["keep_files"] = DEFAULT_KEEP_FILES
profile["skip_cleanup_confirm"] = bool(self.skip_cleanup_confirm)
profile["last_success_time"] = self.last_success_time
profile["last_result_summary"] = self.last_result_summary
profile["last_hashes"] = self.last_hashes
self.profiles[name] = profile
def load_profile(self, name: str):
def load_profile(self, name: str, save_current: bool = True):
self.loading_settings = True
self.autosave_enabled = False
if save_current:
self.save_active_profile()
profile = self.profiles.get(name) or self.default_profile()
self.active_profile.set(name)
@@ -1359,6 +1383,10 @@ class BackgroundFileCopyApp:
self.max_workers_var.set(profile.get("max_workers", DEFAULT_MAX_WORKERS))
self.verify_only.set(profile.get("verify_only", False))
self.cleanup_old_var.set(profile.get("cleanup_old", False))
try:
self.keep_files_var.set(max(1, int(profile.get("keep_files", DEFAULT_KEEP_FILES) or DEFAULT_KEEP_FILES)))
except Exception:
self.keep_files_var.set(DEFAULT_KEEP_FILES)
self.skip_cleanup_confirm = bool(profile.get("skip_cleanup_confirm", False))
self.last_success_time = profile.get("last_success_time")
self.last_result_summary = profile.get("last_result_summary")
@@ -1539,6 +1567,8 @@ class BackgroundFileCopyApp:
def stop_copying(self):
if self.is_copying:
if not messagebox.askyesno("Подтверждение", "Остановить текущее копирование?"):
return
self.cancel_event.set()
self.log_message("⏹ Остановка копирования по запросу пользователя", "warning")
@@ -1716,9 +1746,72 @@ class BackgroundFileCopyApp:
"""Планирует выполнение функции в UI-потоке."""
self.queue.put({'type': 'ui', 'func': func})
def get_float_setting(self, var, default: float) -> float:
try:
raw = var.get()
except Exception:
return float(default)
if raw in ("", None):
return float(default)
try:
return float(raw)
except (TypeError, ValueError):
return float(default)
def get_int_setting(self, var, default: int, minimum: int = 1) -> int:
try:
raw = var.get()
except Exception:
return max(minimum, int(default))
if raw in ("", None):
return max(minimum, int(default))
try:
return max(minimum, int(raw))
except (TypeError, ValueError):
return max(minimum, int(default))
def _format_active_file_label(self, file_path: str) -> str:
p = Path(file_path)
parts = p.parts
if len(parts) >= 2:
return str(Path(parts[-2]) / parts[-1])
return p.name or file_path
def set_status_text(self, text: str):
with self.active_files_lock:
has_active = bool(self.active_files)
if has_active:
self.update_status_with_active_files()
return
self.root.after(0, lambda: self.status_bar.config(text=text))
def update_status_with_active_files(self):
with self.active_files_lock:
file_paths = sorted(self.active_files.keys())
if not file_paths:
text = "Идет копирование..."
else:
labels = [self._format_active_file_label(p) for p in file_paths]
if len(labels) <= 3:
text = "Идет копирование: " + ", ".join(labels)
else:
text = f"Идет копирование: {', '.join(labels[:3])} ... (+{len(labels) - 3})"
self.root.after(0, lambda t=text: self.status_bar.config(text=t))
def begin_active_file(self, file_path: str):
with self.active_files_lock:
self.active_files[file_path] = self.active_files.get(file_path, 0) + 1
self.update_status_with_active_files()
def end_active_file(self, file_path: str):
with self.active_files_lock:
count = self.active_files.get(file_path, 0)
if count <= 1:
self.active_files.pop(file_path, None)
else:
self.active_files[file_path] = count - 1
self.update_status_with_active_files()
def set_progress_total(self, total: int):
def _set():
self.progress.config(maximum=max(1, total))
@@ -1757,16 +1850,22 @@ class BackgroundFileCopyApp:
self.log_message(f"❌ Нет целевого файла для проверки: {target.name}", "error")
return False
if not should_copy_file(source, target):
ok, src_hash, dst_hash = compare_file_checksums(source, target)
self.log_message(f"🔐 SHA256 src={src_hash} dest={dst_hash} file={target.name}", "info")
if not ok:
self.log_message(f"⚠️ SHA256 отличается у пропущенного файла: {target.name}", "warning")
self.log_message(f"⏭️ Проверка пропущена (не изменен): {target.name}", "warning")
return True
if verify_copy(source, target):
ok, src_hash, dst_hash = compare_file_checksums(source, target)
self.log_message(f"🔐 SHA256 src={src_hash} dest={dst_hash} file={target.name}", "info")
if ok:
self.log_message(f"✅ Проверка OK: {target.name}", "success")
return True
self.log_message(f"❌ Проверка не пройдена: {target.name}", "error")
return False
def has_enough_space(self, dest_path: Path, size_bytes: int) -> bool:
min_gb = float(self.min_free_gb_var.get() or DEFAULT_MIN_FREE_GB)
min_gb = self.get_float_setting(self.min_free_gb_var, DEFAULT_MIN_FREE_GB)
free_gb = get_free_space_gb(dest_path)
if free_gb < min_gb:
self.log_message(f"❌ Недостаточно места на диске: свободно {free_gb:.2f} ГБ, минимум {min_gb} ГБ", "error")
@@ -1776,6 +1875,34 @@ class BackgroundFileCopyApp:
return False
return True
def cleanup_destination_files(self, dest_path: Path, include_masks: List[str], exclude_masks: List[str]) -> None:
try:
keep_count = max(1, int(self.keep_files_var.get() or DEFAULT_KEEP_FILES))
except Exception:
keep_count = DEFAULT_KEEP_FILES
candidates: List[Path] = []
for item in dest_path.iterdir():
if not item.is_file():
continue
if include_masks or exclude_masks:
if not matches_masks(item.name, include_masks, exclude_masks):
continue
candidates.append(item)
if len(candidates) <= keep_count:
return
candidates.sort(key=lambda p: (p.stat().st_mtime, p.name), reverse=True)
for item in candidates[keep_count:]:
try:
item.unlink()
self.log_message(f"🧹 Удален старый файл: {item.name}", "info")
except Exception as e:
msg = f"Не удалось удалить файл {item.name}: {e}"
self.log_message(msg, "warning")
self.record_error_detail(msg)
def copy_full_folder(self, source: Path, dest: Path) -> tuple:
copied = 0
skipped = 0
@@ -1791,6 +1918,7 @@ class BackgroundFileCopyApp:
break
src_file = Path(root) / fname
dst_file = dest_root / fname
self.begin_active_file(str(src_file))
try:
self.set_status_text(f"Копируется: {src_file.name}")
self.check_previous_hash(dst_file) if dst_file.exists() else None
@@ -1806,14 +1934,24 @@ class BackgroundFileCopyApp:
errors += 1
self.step_progress()
continue
if self.copy_file_with_retries(src_file, dst_file) and verify_copy(src_file, dst_file):
if self.copy_file_with_retries(src_file, dst_file):
ok, src_hash, dst_hash = compare_file_checksums(src_file, dst_file)
self.log_message(f"🔐 SHA256 src={src_hash} dest={dst_hash} file={dst_file.name}", "info")
if ok:
copied += 1
self.last_hashes[str(dst_file)] = compute_file_checksum(dst_file)
self.last_hashes[str(dst_file)] = dst_hash
else:
errors += 1
else:
errors += 1
self.step_progress()
else:
skipped += 1
if dst_file.exists():
ok, src_hash, dst_hash = compare_file_checksums(src_file, dst_file)
self.log_message(f"🔐 SHA256 src={src_hash} dest={dst_hash} file={dst_file.name}", "info")
if not ok:
self.log_message(f"⚠️ SHA256 отличается у пропущенного файла: {dst_file.name}", "warning")
self.step_progress()
except Exception as e:
errors += 1
@@ -1821,6 +1959,8 @@ class BackgroundFileCopyApp:
msg = f"❌ Ошибка при копировании {src_file.name}: {e}"
self.log_message(msg, "error")
self.record_error_detail(msg)
finally:
self.end_active_file(str(src_file))
return copied, skipped, errors
def process_pair(self, source: str, dest: str, full_copy: bool, background: bool) -> tuple:
@@ -1884,6 +2024,7 @@ class BackgroundFileCopyApp:
return copied_files, skipped_files, error_files + 1
target_file = dest_path / latest_file.name
self.begin_active_file(str(latest_file))
try:
self.set_status_text(f"Копируется: {latest_file.name}")
if target_file.exists():
@@ -1901,31 +2042,24 @@ class BackgroundFileCopyApp:
if not self.has_enough_space(dest_path, latest_file.stat().st_size):
self.step_progress()
return copied_files, skipped_files, error_files + 1
if self.copy_file_with_retries(latest_file, target_file) and verify_copy(latest_file, target_file):
if self.copy_file_with_retries(latest_file, target_file):
copied_files += 1
self.last_hashes[str(target_file)] = compute_file_checksum(target_file)
if target_existed:
self.log_message(f"✅ Обновлен: {latest_file.name} (новее)", "success")
else:
self.log_message(f"✅ Скопирован: {latest_file.name}", "success")
if self.cleanup_old_var.get():
include_masks = parse_masks(self.include_masks_var.get())
exclude_masks = parse_masks(self.exclude_masks_var.get())
for item in dest_path.iterdir():
if not item.is_file():
continue
if item.name == latest_file.name:
continue
if include_masks or exclude_masks:
if not matches_masks(item.name, include_masks, exclude_masks):
continue
try:
item.unlink()
self.log_message(f"🧹 Удален старый файл: {item.name}", "info")
except Exception as e:
msg = f"Не удалось удалить файл {item.name}: {e}"
self.log_message(msg, "warning")
src_hash = compute_file_checksum(latest_file)
dst_hash = compute_file_checksum(target_file)
self.log_message(f"🔐 SHA256 src={src_hash} dest={dst_hash} file={target_file.name}", "info")
self.last_hashes[str(target_file)] = dst_hash
if src_hash != dst_hash:
error_files += 1
copied_files -= 1
msg = f"❌ Контрольная сумма не совпала: {latest_file.name}"
self.log_message(msg, "error")
self.record_error_detail(msg)
if src_hash == dst_hash and target_existed:
self.log_message(f"✅ Обновлен: {latest_file.name} (новее)", "success")
elif src_hash == dst_hash:
self.log_message(f"✅ Скопирован: {latest_file.name}", "success")
if src_hash == dst_hash and self.cleanup_old_var.get():
self.cleanup_destination_files(dest_path, include_masks, exclude_masks)
else:
error_files += 1
msg = f"❌ Контрольная сумма не совпала: {latest_file.name}"
@@ -1934,6 +2068,11 @@ class BackgroundFileCopyApp:
self.step_progress()
else:
skipped_files += 1
if target_file.exists():
ok, src_hash, dst_hash = compare_file_checksums(latest_file, target_file)
self.log_message(f"🔐 SHA256 src={src_hash} dest={dst_hash} file={target_file.name}", "info")
if not ok:
self.log_message(f"⚠️ SHA256 отличается у пропущенного файла: {target_file.name}", "warning")
self.log_message(f"⏭️ Пропущен: {latest_file.name} (уже актуален)", "warning")
self.step_progress()
except Exception as e:
@@ -1942,6 +2081,8 @@ class BackgroundFileCopyApp:
msg = f"❌ Ошибка при копировании {latest_file.name}: {e}"
self.log_message(msg, "error")
self.record_error_detail(msg)
finally:
self.end_active_file(str(latest_file))
else:
error_files += 1
self.step_progress()
@@ -1984,7 +2125,7 @@ class BackgroundFileCopyApp:
self.log_message("\n" + "=" * 50, "info")
self.log_message("🚀 Начало копирования последних файлов", "info")
max_workers = max(1, int(self.max_workers_var.get() or DEFAULT_MAX_WORKERS))
max_workers = self.get_int_setting(self.max_workers_var, DEFAULT_MAX_WORKERS, minimum=1)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(self.process_pair, source, dest, full_copy, background) for source, dest, full_copy in pairs]
for future in as_completed(futures):
@@ -2032,6 +2173,8 @@ class BackgroundFileCopyApp:
self.run_on_ui(lambda: messagebox.showerror("Ошибка", f"Произошла ошибка:\n{e}"))
finally:
self.is_copying = False
with self.active_files_lock:
self.active_files.clear()
self.copy_lock.release()
self.root.after(0, lambda: self.status_bar.config(text="Готов к работе"))
self.root.after(0, lambda: self.progress_var.set(0))