From 1236ba09ac5d830f00c33c4d115df2831405b5e4 Mon Sep 17 00:00:00 2001 From: benya Date: Tue, 24 Feb 2026 22:19:35 +0300 Subject: [PATCH] Fix numeric parsing, active file status, and checksum flow --- main.py | 215 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 179 insertions(+), 36 deletions(-) diff --git a/main.py b/main.py index 5dcc69a..2c3cb4f 100644 --- a/main.py +++ b/main.py @@ -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("", lambda _e: self.request_autosave()) + self.keep_files_spin.bind("", 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,20 +1356,25 @@ 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 - self.save_active_profile() + if save_current: + self.save_active_profile() profile = self.profiles.get(name) or self.default_profile() self.active_profile.set(name) self.include_masks_var.set(profile.get("include_masks", DEFAULT_INCLUDE_MASKS)) @@ -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): - copied += 1 - self.last_hashes[str(dst_file)] = compute_file_checksum(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)] = 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: + 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") - else: + elif src_hash == dst_hash: 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") - self.record_error_detail(msg) + 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))