From 996c462d43390783d392ff58ab438bb42dfad9b2 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:43:27 +1100 Subject: [PATCH 01/14] Adds basic validation, undo, redo, debouncing, error display and prvents training if there are validation errors --- modules/ui/AdditionalEmbeddingsTab.py | 4 +- modules/ui/CloudTab.py | 2 +- modules/ui/ConceptTab.py | 2 +- modules/ui/ConceptWindow.py | 4 +- modules/ui/ConvertModelUI.py | 6 +- modules/ui/LoraTab.py | 4 +- modules/ui/ModelTab.py | 30 +-- modules/ui/SampleFrame.py | 8 +- modules/ui/TrainUI.py | 29 ++- modules/util/ui/UIState.py | 17 +- modules/util/ui/components.py | 244 +++++++---------------- modules/util/ui/ui_utils.py | 20 -- modules/util/ui/validation.py | 269 ++++++++++++++++++++++++++ 13 files changed, 401 insertions(+), 238 deletions(-) create mode 100644 modules/util/ui/validation.py diff --git a/modules/ui/AdditionalEmbeddingsTab.py b/modules/ui/AdditionalEmbeddingsTab.py index d6cef6ca8..bf929ce8b 100644 --- a/modules/ui/AdditionalEmbeddingsTab.py +++ b/modules/ui/AdditionalEmbeddingsTab.py @@ -89,9 +89,9 @@ def __init__(self, master, element, i, open_command, remove_command, clone_comma # embedding model names components.label(top_frame, 0, 2, "base embedding:", tooltip="The base embedding to train on. Leave empty to create a new embedding") - components.file_entry( + components.path_entry( top_frame, 0, 3, self.ui_state, "model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # placeholder diff --git a/modules/ui/CloudTab.py b/modules/ui/CloudTab.py index 33ea64f99..99057e428 100644 --- a/modules/ui/CloudTab.py +++ b/modules/ui/CloudTab.py @@ -66,7 +66,7 @@ def __init__(self, master, train_config: TrainConfig, ui_state: UIState, parent) components.label(self.frame, 7, 0, "SSH keyfile path", tooltip="Absolute path to the private key file used for SSH connections. Leave empty to rely on your system SSH configuration.") - components.file_entry(self.frame, 7, 1, self.ui_state, "secrets.cloud.key_file") #TODO Replace with path_entry in a future PR + components.path_entry(self.frame, 7, 1, self.ui_state, "secrets.cloud.key_file", mode="file") components.label(self.frame, 8, 0, "SSH password", tooltip="SSH password for password-based authentication. If you try to use native SCP requires sshpass to be installed. Leave empty to use key-based authentication.") diff --git a/modules/ui/ConceptTab.py b/modules/ui/ConceptTab.py index a3806c15d..c610a6850 100644 --- a/modules/ui/ConceptTab.py +++ b/modules/ui/ConceptTab.py @@ -10,7 +10,7 @@ from modules.util.enum.ConceptType import ConceptType from modules.util.image_util import load_image from modules.util.ui import components -from modules.util.ui.ui_utils import DebounceTimer +from modules.util.ui.validation import DebounceTimer from modules.util.ui.UIState import UIState import customtkinter as ctk diff --git a/modules/ui/ConceptWindow.py b/modules/ui/ConceptWindow.py index 60b195c75..fa70bf372 100644 --- a/modules/ui/ConceptWindow.py +++ b/modules/ui/ConceptWindow.py @@ -147,14 +147,14 @@ def __general_tab(self, master, concept: ConceptConfig): # path components.label(frame, 3, 0, "Path", tooltip="Path where the training data is located") - components.dir_entry(frame, 3, 1, self.ui_state, "path") + components.path_entry(frame, 3, 1, self.ui_state, "path", mode="dir") components.button(frame, 3, 2, text="download now", command=self.__download_dataset_threaded, tooltip="Download dataset from Huggingface now, for the purpose of previewing and statistics. Otherwise, it will be downloaded when you start training. Path must be a Huggingface repository.") # prompt source components.label(frame, 4, 0, "Prompt Source", tooltip="The source for prompts used during training. When selecting \"From single text file\", select a text file that contains a list of prompts") - prompt_path_entry = components.file_entry(frame, 4, 2, self.text_ui_state, "prompt_path") + prompt_path_entry = components.path_entry(frame, 4, 2, self.text_ui_state, "prompt_path", mode="file") def set_prompt_path_entry_enabled(option: str): if option == 'concept': diff --git a/modules/ui/ConvertModelUI.py b/modules/ui/ConvertModelUI.py index 837b27614..d601a4158 100644 --- a/modules/ui/ConvertModelUI.py +++ b/modules/ui/ConvertModelUI.py @@ -82,9 +82,9 @@ def main_frame(self, master): # input name components.label(master, 2, 0, "Input name", tooltip="Filename, directory or hugging face repository of the base model") - components.file_entry( + components.path_entry( master, 2, 1, self.ui_state, "input_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # output data type @@ -107,7 +107,7 @@ def main_frame(self, master): # output model destination components.label(master, 5, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - components.file_entry(master, 5, 1, self.ui_state, "output_model_destination", is_output=True) + components.path_entry(master, 5, 1, self.ui_state, "output_model_destination", mode="file", is_output=True) self.button = components.button(master, 6, 1, "Convert", self.convert_model) diff --git a/modules/ui/LoraTab.py b/modules/ui/LoraTab.py index 902f2c8c2..78b815d81 100644 --- a/modules/ui/LoraTab.py +++ b/modules/ui/LoraTab.py @@ -64,9 +64,9 @@ def setup_lora(self, peft_type: PeftType): # lora model name components.label(master, 0, 0, f"{name} base model", tooltip=f"The base {name} to train on. Leave empty to create a new {name}") - entry = components.file_entry( + entry = components.path_entry( master, 0, 1, self.ui_state, "lora_model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) entry.grid(row=0, column=1, columnspan=4) diff --git a/modules/ui/ModelTab.py b/modules/ui/ModelTab.py index b8ad3d26d..182ee7ff2 100644 --- a/modules/ui/ModelTab.py +++ b/modules/ui/ModelTab.py @@ -365,9 +365,9 @@ def __create_base_dtype_components(self, frame, row: int) -> int: # base model components.label(frame, row, 0, "Base Model", tooltip="Filename, directory or Hugging Face repository of the base model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "base_model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # compile @@ -410,9 +410,9 @@ def __create_base_components( # prior model components.label(frame, row, 0, "Prior Model", tooltip="Filename, directory or Hugging Face repository of the prior model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "prior.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # prior weight dtype @@ -428,9 +428,9 @@ def __create_base_components( # transformer model components.label(frame, row, 0, "Override Transformer / GGUF", tooltip="Can be used to override the transformer in the base model. Safetensors and GGUF files are supported, local and on Huggingface. If a GGUF file is used, the DataType must also be set to GGUF") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "transformer.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # transformer weight dtype @@ -512,9 +512,9 @@ def __create_base_components( # text encoder 4 weight dtype components.label(frame, row, 0, "Text Encoder 4 Override", tooltip="Filename, directory or Hugging Face repository of the text encoder 4 model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "text_encoder_4.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # text encoder 4 weight dtype @@ -529,9 +529,9 @@ def __create_base_components( # base model components.label(frame, row, 0, "VAE Override", tooltip="Directory or Hugging Face repository of a VAE model in diffusers format. Can be used to override the VAE included in the base model. Using a safetensor VAE file will cause an error that the model cannot be loaded.") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "vae.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # vae weight dtype @@ -548,9 +548,9 @@ def __create_effnet_encoder_components(self, frame, row: int): # effnet encoder model components.label(frame, row, 0, "Effnet Encoder Model", tooltip="Filename, directory or Hugging Face repository of the effnet encoder model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "effnet_encoder.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # effnet encoder weight dtype @@ -572,9 +572,9 @@ def __create_decoder_components( # decoder model components.label(frame, row, 0, "Decoder Model", tooltip="Filename, directory or Hugging Face repository of the decoder model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "decoder.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # decoder weight dtype @@ -616,7 +616,7 @@ def __create_output_components( # output model destination components.label(frame, row, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - components.file_entry(frame, row, 1, self.ui_state, "output_model_destination", is_output=True) + components.path_entry(frame, row, 1, self.ui_state, "output_model_destination", mode="file", is_output=True) # output data type components.label(frame, row, 3, "Output Data Type", diff --git a/modules/ui/SampleFrame.py b/modules/ui/SampleFrame.py index 2f35a23a2..1b46aaa18 100644 --- a/modules/ui/SampleFrame.py +++ b/modules/ui/SampleFrame.py @@ -108,15 +108,15 @@ def __init__( # base image path components.label(bottom_frame, 6, 0, "base image path:", tooltip="The base image used when inpainting.") - components.file_entry(bottom_frame, 6, 1, self.ui_state, "base_image_path", - allow_model_files=False, + components.path_entry(bottom_frame, 6, 1, self.ui_state, "base_image_path", + mode="file", allow_model_files=False, allow_image_files=True, ) # mask image path components.label(bottom_frame, 6, 2, "mask image path:", tooltip="The mask used when inpainting.") - components.file_entry(bottom_frame, 6, 3, self.ui_state, "mask_image_path", - allow_model_files=False, + components.path_entry(bottom_frame, 6, 3, self.ui_state, "mask_image_path", + mode="file", allow_model_files=False, allow_image_files=True, ) diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 584b68f81..7d0fe1939 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -12,7 +12,7 @@ from collections.abc import Callable from contextlib import suppress from pathlib import Path -from tkinter import filedialog +from tkinter import filedialog, messagebox import scripts.generate_debug_report from modules.ui.AdditionalEmbeddingsTab import AdditionalEmbeddingsTab @@ -42,6 +42,7 @@ from modules.util.ui import components from modules.util.ui.ui_utils import set_window_icon from modules.util.ui.UIState import UIState +from modules.util.ui.validation import flush_and_validate_all import torch @@ -239,12 +240,12 @@ def create_general_tab(self, master): # workspace dir components.label(frame, 0, 0, "Workspace Directory", tooltip="The directory where all files of this training run are saved") - components.dir_entry(frame, 0, 1, self.ui_state, "workspace_dir", command=self._on_workspace_dir_change) + components.path_entry(frame, 0, 1, self.ui_state, "workspace_dir", mode="dir", command=self._on_workspace_dir_change) # cache dir components.label(frame, 0, 2, "Cache Directory", tooltip="The directory where cached data is saved") - components.dir_entry(frame, 0, 3, self.ui_state, "cache_dir") + components.path_entry(frame, 0, 3, self.ui_state, "cache_dir", mode="dir") # continue from previous backup components.label(frame, 2, 0, "Continue from last backup", @@ -263,7 +264,7 @@ def create_general_tab(self, master): components.label(frame, 4, 2, "Debug Directory", tooltip="The directory where debug data is saved") - components.dir_entry(frame, 4, 3, self.ui_state, "debug_dir") + components.path_entry(frame, 4, 3, self.ui_state, "debug_dir", mode="dir") # tensorboard components.label(frame, 6, 0, "Tensorboard", @@ -479,9 +480,9 @@ def lora_tab(self, master): # lora model name components.label(frame, 0, 0, "LoRA base model", tooltip="The base LoRA to train on. Leave empty to create a new LoRA") - components.file_entry( + components.path_entry( frame, 0, 1, self.ui_state, "lora_model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # lora rank @@ -526,9 +527,9 @@ def embedding_tab(self, master): # embedding model name components.label(frame, 0, 0, "Base embedding", tooltip="The base embedding to train on. Leave empty to create a new embedding") - components.file_entry( + components.path_entry( frame, 0, 1, self.ui_state, "embedding.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x ) # token count @@ -784,6 +785,18 @@ def __training_thread_function(self): def start_training(self): if self.training_thread is None: self.save_default() + + # --- pre-training validation gate --- + errors = flush_and_validate_all() + + if errors: + messagebox.showerror( + "Cannot Start Training", + "Please fix the following errors before training:\n\n" + + "\n".join(f"• {e}" for e in errors), + ) + return + self._set_training_button_running() if self.train_config.tensorboard and not self.train_config.tensorboard_always_on and self.always_on_tensorboard_subprocess: diff --git a/modules/util/ui/UIState.py b/modules/util/ui/UIState.py index 0c858094b..8b13d23f7 100644 --- a/modules/util/ui/UIState.py +++ b/modules/util/ui/UIState.py @@ -1,4 +1,3 @@ -import contextlib import tkinter as tk from collections.abc import Callable from dataclasses import dataclass @@ -119,8 +118,10 @@ def update(_0, _1, _2): elif string_var == "-inf": obj[name] = int("-inf") else: - with contextlib.suppress(ValueError): + try: obj[name] = int(string_var) + except ValueError: + obj[name] = None self.__call_var_traces(name) else: def update(_0, _1, _2): @@ -132,8 +133,10 @@ def update(_0, _1, _2): elif string_var == "-inf": setattr(obj, name, int("-inf")) else: - with contextlib.suppress(ValueError): + try: setattr(obj, name, int(string_var)) + except ValueError: + setattr(obj, name, None) self.__call_var_traces(name) return update @@ -149,8 +152,10 @@ def update(_0, _1, _2): elif string_var == "-inf": obj[name] = float("-inf") else: - with contextlib.suppress(ValueError): + try: obj[name] = float(string_var) + except ValueError: + obj[name] = None self.__call_var_traces(name) else: def update(_0, _1, _2): @@ -162,8 +167,10 @@ def update(_0, _1, _2): elif string_var == "-inf": setattr(obj, name, float("-inf")) else: - with contextlib.suppress(ValueError): + try: setattr(obj, name, float(string_var)) + except ValueError: + setattr(obj, name, None) self.__call_var_traces(name) return update diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 961d213a0..55abbb87d 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -1,12 +1,12 @@ -import contextlib from collections.abc import Callable from tkinter import filedialog -from typing import Any +from typing import Any, Literal from modules.util.enum.TimeUnit import TimeUnit from modules.util.path_util import supported_image_extensions from modules.util.ui.ToolTip import ToolTip from modules.util.ui.UIState import UIState +from modules.util.ui.validation import FieldValidator import customtkinter as ctk from customtkinter.windows.widgets.scaling import CTkScalingBaseClass @@ -44,11 +44,12 @@ def entry( column, ui_state: UIState, var_name: str, - command: Callable[[], None] = None, + command: Callable[[], None] | None = None, tooltip: str = "", wide_tooltip: bool = False, width: int = 140, sticky: str = "new", + max_undo: int | None = None, ): var = ui_state.get_var(var_name) trace_id = None @@ -58,140 +59,27 @@ def entry( component = ctk.CTkEntry(master, textvariable=var, width=width) component.grid(row=row, column=column, padx=PAD, pady=PAD, sticky=sticky) - try: - original_border_color = component.cget("border_color") - except Exception: - original_border_color = "gray50" - - error_border_color = "#dc3545" - - validation_after_id = None - revert_after_id = None - touched = False - - DEBOUNCE_STOP_TYPING_MS = 1500 - DEBOUNCED_INVALID_REVERT_MS = 1000 - FOCUSOUT_INVALID_REVERT_MS = 1200 - - last_valid_value = var.get() - - def validate_value(value: str, revert_delay_ms: int | None) -> bool: - nonlocal revert_after_id, last_valid_value - meta = ui_state.get_field_metadata(var_name) - declared_type = meta.type - nullable = meta.nullable - default_val = meta.default - - if revert_after_id: - with contextlib.suppress(Exception): - component.after_cancel(revert_after_id) - revert_after_id = None - - def success(): - nonlocal last_valid_value - component.configure(border_color=original_border_color) - last_valid_value = value - return True - - def do_revert(): - var.set(last_valid_value) - component.configure(border_color=original_border_color) - - def fail(_reason: str): - nonlocal revert_after_id - component.configure(border_color=error_border_color) - if revert_delay_ms is not None: - revert_after_id = component.after(revert_delay_ms, do_revert) - else: - do_revert() - return False - - if value == "": - if nullable: - return success() - if declared_type is str: - if default_val == "": - return success() - return fail("Value required") - - try: - if declared_type is int: - int(value) - elif declared_type is float: - float(value) - elif declared_type is bool: - if value.lower() not in ("true", "false", "0", "1"): - return fail("Invalid bool") - return success() - except ValueError: - return fail("Invalid value") - - def debounced_validate(*_): - nonlocal validation_after_id, revert_after_id - if not touched: - if validation_after_id: - with contextlib.suppress(Exception): - component.after_cancel(validation_after_id) - validation_after_id = None - return - if revert_after_id: - with contextlib.suppress(Exception): - component.after_cancel(revert_after_id) - revert_after_id = None - if validation_after_id: - with contextlib.suppress(Exception): - component.after_cancel(validation_after_id) - validation_after_id = component.after( - DEBOUNCE_STOP_TYPING_MS, - lambda: validate_value(var.get(), DEBOUNCED_INVALID_REVERT_MS) - ) - - validation_trace_name = var.trace_add("write", debounced_validate) - - def on_focus_in(_e=None): - nonlocal touched - touched = False - - def on_user_input(_e=None): - nonlocal touched - touched = True - - def on_focus_out(_e=None): - # only validate on focus-out if the user interacted with the field. - if touched: - validate_value(var.get(), FOCUSOUT_INVALID_REVERT_MS) - - component.bind("", on_focus_in) - component.bind("", on_user_input) - component.bind("<>", on_user_input) - component.bind("<>", on_user_input) - component.bind("", on_focus_out) + undo_kwargs = {"max_undo": max_undo} if max_undo is not None else {} + validator = FieldValidator(component, var, ui_state, var_name, **undo_kwargs) + validator.attach() original_destroy = component.destroy def new_destroy(): # 'temporary' fix until https://github.com/TomSchimansky/CustomTkinter/pull/2077 is merged # unfortunately Tom has admitted to forgetting about how to maintain CTK so this likely will never be merged - nonlocal validation_after_id, revert_after_id if component._textvariable_callback_name: - component._textvariable.trace_remove("write", component._textvariable_callback_name) + component._textvariable.trace_remove("write", component._textvariable_callback_name) # type: ignore[union-attr] component._textvariable_callback_name = "" - if validation_after_id: - with contextlib.suppress(Exception): - component.after_cancel(validation_after_id) - if revert_after_id: - with contextlib.suppress(Exception): - component.after_cancel(revert_after_id) - - var.trace_remove("write", validation_trace_name) + validator.detach() if command is not None and trace_id is not None: ui_state.remove_var_trace(var_name, trace_id) original_destroy() - component.destroy = new_destroy + component.destroy = new_destroy # type: ignore[assignment] if tooltip: ToolTip(component, tooltip, wide=wide_tooltip) @@ -199,50 +87,55 @@ def new_destroy(): return component -def file_entry( +def path_entry( master, row, column, ui_state: UIState, var_name: str, + *, + mode: Literal["file", "dir"] = "file", is_output: bool = False, - path_modifier: Callable[[str], str] = None, + path_modifier: Callable[[str], str] | None = None, allow_model_files: bool = True, allow_image_files: bool = False, - command: Callable[[str], None] = None, + command: Callable[[str], None] | None = None, ): frame = ctk.CTkFrame(master, fg_color="transparent") frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") frame.grid_columnconfigure(0, weight=1) - entry(frame,row=0, column=0, ui_state=ui_state, var_name=var_name) + entry(frame, row=0, column=0, ui_state=ui_state, var_name=var_name) def __open_dialog(): - filetypes = [ - ("All Files", "*.*"), - ] - - if allow_model_files: - filetypes.extend([ - ("Diffusers", "model_index.json"), - ("Checkpoint", "*.ckpt *.pt *.bin"), - ("Safetensors", "*.safetensors"), - ]) - if allow_image_files: - filetypes.extend([ - ("Image", ' '.join([f"*.{x}" for x in supported_image_extensions()])), - ]) - - if is_output: - file_path = filedialog.asksaveasfilename(filetypes=filetypes) + if mode == "dir": + chosen = filedialog.askdirectory() else: - file_path = filedialog.askopenfilename(filetypes=filetypes) + filetypes = [ + ("All Files", "*.*"), + ] + + if allow_model_files: + filetypes.extend([ + ("Diffusers", "model_index.json"), + ("Checkpoint", "*.ckpt *.pt *.bin"), + ("Safetensors", "*.safetensors"), + ]) + if allow_image_files: + filetypes.extend([ + ("Image", ' '.join([f"*.{x}" for x in supported_image_extensions()])), + ]) + + if is_output: + chosen = filedialog.asksaveasfilename(filetypes=filetypes) + else: + chosen = filedialog.askopenfilename(filetypes=filetypes) - if file_path: + if chosen: if path_modifier: - file_path = path_modifier(file_path) + chosen = path_modifier(chosen) - ui_state.get_var(var_name).set(file_path) + ui_state.get_var(var_name).set(chosen) if command: - command(file_path) + command(chosen) button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") @@ -250,27 +143,28 @@ def __open_dialog(): return frame -def dir_entry(master, row, column, ui_state: UIState, var_name: str, command: Callable[[str], None] = None): - frame = ctk.CTkFrame(master, fg_color="transparent") - frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") - - frame.grid_columnconfigure(0, weight=1) - - entry(frame, row=0, column=0, ui_state=ui_state, var_name=var_name) - - def __open_dialog(): - dir_path = filedialog.askdirectory() - - if dir_path: - ui_state.get_var(var_name).set(dir_path) - - if command: - command(dir_path) +# Keep backwards-compatible aliases so any not-yet-migrated callers still work +def file_entry( + master, row, column, ui_state: UIState, var_name: str, + is_output: bool = False, + path_modifier: Callable[[str], str] | None = None, + allow_model_files: bool = True, + allow_image_files: bool = False, + command: Callable[[str], None] | None = None, +): + return path_entry( + master, row, column, ui_state, var_name, + mode="file", is_output=is_output, path_modifier=path_modifier, + allow_model_files=allow_model_files, allow_image_files=allow_image_files, + command=command, + ) - button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) - button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") - return frame +def dir_entry(master, row, column, ui_state: UIState, var_name: str, command: Callable[[str], None] | None = None): + return path_entry( + master, row, column, ui_state, var_name, + mode="dir", command=command, + ) def time_entry(master, row, column, ui_state: UIState, var_name: str, unit_var_name, supports_time_units: bool = True): @@ -422,7 +316,7 @@ def button(master, row, column, text, command, tooltip=None, **kwargs): return component -def options(master, row, column, values, ui_state: UIState, var_name: str, command: Callable[[str], None] = None): +def options(master, row, column, values, ui_state: UIState, var_name: str, command: Callable[[str], None] | None = None): component = ctk.CTkOptionMenu(master, values=values, variable=ui_state.get_var(var_name), command=command) component.grid(row=row, column=column, padx=PAD, pady=(PAD, PAD), sticky="new") @@ -437,13 +331,13 @@ def destroy(self): return destroy destroy = create_destroy(component._dropdown_menu) - component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) + component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) # type: ignore[assignment] return component def options_adv(master, row, column, values, ui_state: UIState, var_name: str, - command: Callable[[str], None] = None, adv_command: Callable[[], None] = None): + command: Callable[[str], None] | None = None, adv_command: Callable[[], None] | None = None): frame = ctk.CTkFrame(master, fg_color="transparent") frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") @@ -469,13 +363,13 @@ def destroy(self): return destroy destroy = create_destroy(component._dropdown_menu) - component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) + component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) # type: ignore[assignment] return frame, {'component': component, 'button_component': button_component} def options_kv(master, row, column, values: list[tuple[str, Any]], ui_state: UIState, var_name: str, - command: Callable[[Any], None] = None): + command: Callable[[Any], None] | None = None): var = ui_state.get_var(var_name) keys = [key for key, value in values] @@ -523,7 +417,7 @@ def destroy(self): return destroy destroy = create_destroy(component._dropdown_menu) - component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) + component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) # type: ignore[assignment] return component @@ -534,7 +428,7 @@ def switch( column, ui_state: UIState, var_name: str, - command: Callable[[], None] = None, + command: Callable[[], None] | None = None, text: str = "", ): var = ui_state.get_var(var_name) @@ -556,7 +450,7 @@ def destroy(self): return destroy destroy = create_destroy(component) - component.destroy = lambda: destroy(component) + component.destroy = lambda: destroy(component) # type: ignore[assignment] return component diff --git a/modules/util/ui/ui_utils.py b/modules/util/ui/ui_utils.py index ba09e1038..c1c9d0a68 100644 --- a/modules/util/ui/ui_utils.py +++ b/modules/util/ui/ui_utils.py @@ -1,4 +1,3 @@ -import contextlib import platform import sys import tkinter as tk @@ -100,22 +99,3 @@ def set_icon(): except Exception as e: print(f"Failed to set window icon: {e}") - -class DebounceTimer: - def __init__(self, widget, delay_ms: int, callback: Callable[..., Any]): - self.widget = widget - self.delay_ms = delay_ms - self.callback = callback - self._after_id: str | None = None - - def call(self, *args, **kwargs): - if self._after_id: - with contextlib.suppress(tk.TclError): - self.widget.after_cancel(self._after_id) - - def fire(): - self._after_id = None - self.callback(*args, **kwargs) - - with contextlib.suppress(tk.TclError): - self._after_id = self.widget.after(self.delay_ms, fire) diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py new file mode 100644 index 000000000..be41494b1 --- /dev/null +++ b/modules/util/ui/validation.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import contextlib +import tkinter as tk +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from modules.util.ui.UIState import UIState + + import customtkinter as ctk + + +DEBOUNCE_TYPING_MS = 250 +UNDO_DEBOUNCE_MS = 500 +ERROR_BORDER_COLOR = "#dc3545" + +_active_validators: set[FieldValidator] = set() + +DEFAULT_MAX_UNDO = 20 + + +class UndoHistory: + def __init__(self, max_size: int = DEFAULT_MAX_UNDO): + self._stack: deque[str] = deque(maxlen=max_size) + self._redo_stack: list[str] = [] + + def push(self, value: str): + if self._stack and self._stack[-1] == value: + return + self._stack.append(value) + self._redo_stack.clear() + + def undo(self, current: str) -> str | None: + if not self._stack: + return None + top = self._stack[-1] + if top == current and len(self._stack) > 1: + self._redo_stack.append(self._stack.pop()) + return self._stack[-1] + elif top != current: + self._redo_stack.append(current) + return top + return None + + def redo(self) -> str | None: + if not self._redo_stack: + return None + value = self._redo_stack.pop() + self._stack.append(value) + return value + + +class DebounceTimer: + def __init__(self, widget, delay_ms: int, callback: Callable[..., Any]): + self.widget = widget + self.delay_ms = delay_ms + self.callback = callback + self._after_id: str | None = None + + def call(self, *args, **kwargs): + if self._after_id: + with contextlib.suppress(tk.TclError): + self.widget.after_cancel(self._after_id) + + def fire(): + self._after_id = None + self.callback(*args, **kwargs) + + with contextlib.suppress(tk.TclError): + self._after_id = self.widget.after(self.delay_ms, fire) + + def cancel(self): + if self._after_id: + with contextlib.suppress(tk.TclError): + self.widget.after_cancel(self._after_id) + self._after_id = None + + +@dataclass +class _ValidationState: + touched: bool = False + trace_name: str | None = None + bound: bool = False + + +class FieldValidator: + def __init__( + self, + component: ctk.CTkEntry, + var: tk.Variable, + ui_state: UIState, + var_name: str, + max_undo: int = DEFAULT_MAX_UNDO, + ): + self.component = component + self.var = var + self.ui_state = ui_state + self.var_name = var_name + + try: + self._original_border_color = component.cget("border_color") + except Exception: + self._original_border_color = "gray50" + + self._state = _ValidationState() + self._debounce: DebounceTimer | None = None + self._undo_debounce: DebounceTimer | None = None + self._undo = UndoHistory(max_undo) + self._applying_undo = False + + def attach(self) -> None: + s = self._state + self._debounce = DebounceTimer( + self.component, DEBOUNCE_TYPING_MS, self._on_debounce_fire + ) + self._undo_debounce = DebounceTimer( + self.component, UNDO_DEBOUNCE_MS, self._push_undo_snapshot + ) + + s.trace_name = self.var.trace_add("write", self._on_var_write) + + self.component.bind("", self._on_focus_in) + self.component.bind("", self._on_user_input) + self.component.bind("<>", self._on_user_input) + self.component.bind("<>", self._on_user_input) + self.component.bind("", self._on_focus_out) + self.component.bind("", self._on_undo) + self.component.bind("", self._on_undo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_redo) + + s.bound = True + _active_validators.add(self) + + def detach(self) -> None: + s = self._state + if not s.bound: + return + s.bound = False + _active_validators.discard(self) + + if self._debounce: + self._debounce.cancel() + if self._undo_debounce: + self._undo_debounce.cancel() + + if s.trace_name: + with contextlib.suppress(Exception): + self.var.trace_remove("write", s.trace_name) + s.trace_name = None + + def validate(self, value: str) -> str | None: + """Return an error string if *value* is invalid, else None.""" + meta = self.ui_state.get_field_metadata(self.var_name) + declared_type = meta.type + nullable = meta.nullable + default_val = meta.default + + if value == "": + if nullable: + return None + if declared_type is str: + if default_val == "": + return None + return "Value required" + return None + + try: + if declared_type is int: + int(value) + elif declared_type is float: + float(value) + elif declared_type is bool: + if value.lower() not in ("true", "false", "0", "1"): + return "Invalid bool" + except ValueError: + return "Invalid value" + + return None + + def _apply_error(self) -> None: + self.component.configure(border_color=ERROR_BORDER_COLOR) + + def _clear_error(self) -> None: + self.component.configure(border_color=self._original_border_color) + + def _validate_and_style(self, value: str) -> bool: + error = self.validate(value) + if error is None: + self._clear_error() + return True + else: + self._apply_error() + return False + + def _on_var_write(self, *_args) -> None: + if self._applying_undo: + return + s = self._state + if not s.touched: + if self._debounce: + self._debounce.cancel() + return + if self._debounce: + self._debounce.call() + if self._undo_debounce: + self._undo_debounce.call() + + def _push_undo_snapshot(self) -> None: + self._undo.push(self.var.get()) + + def _on_debounce_fire(self) -> None: + self._validate_and_style(self.var.get()) + + def _on_focus_in(self, _e=None) -> None: + self._state.touched = False + self._undo.push(self.var.get()) + + def _on_user_input(self, _e=None) -> None: + self._state.touched = True + + def _on_focus_out(self, _e=None) -> None: + if self._state.touched: + self._validate_and_style(self.var.get()) + if self._undo_debounce: + self._undo_debounce.cancel() + self._undo.push(self.var.get()) + + def _set_value(self, value: str) -> None: + self._applying_undo = True + self.var.set(value) + self._applying_undo = False + self._validate_and_style(value) + + def _on_undo(self, _e=None) -> str: + previous = self._undo.undo(self.var.get()) + if previous is not None: + self._set_value(previous) + return "break" + + def _on_redo(self, _e=None) -> str: + next_val = self._undo.redo() + if next_val is not None: + self._set_value(next_val) + return "break" + + +def flush_and_validate_all() -> list[str]: + invalid: list[str] = [] + + for v in list(_active_validators): + if v._debounce: + v._debounce.cancel() + + value = v.var.get() + error = v.validate(value) + + if error is not None: + v._apply_error() + invalid.append(f"{v.var_name}: {error}") + else: + v._clear_error() + + return invalid From 6ba5c9a3d38bd702329c01bd7220ba565917fe05 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Sun, 8 Feb 2026 06:06:52 +1100 Subject: [PATCH 02/14] Adds path_entry validation, prevent overwrite toggle and remove file_entry and dir_entry --- modules/ui/ConvertModelUI.py | 8 +- modules/ui/ModelTab.py | 20 +++- modules/ui/TrainUI.py | 5 + modules/util/config/TrainConfig.py | 10 +- modules/util/enum/PathIOType.py | 10 ++ modules/util/ui/components.py | 60 ++++++------ modules/util/ui/validation.py | 151 +++++++++++++++++++++++++++++ 7 files changed, 232 insertions(+), 32 deletions(-) create mode 100644 modules/util/enum/PathIOType.py diff --git a/modules/ui/ConvertModelUI.py b/modules/ui/ConvertModelUI.py index d601a4158..f24055502 100644 --- a/modules/ui/ConvertModelUI.py +++ b/modules/ui/ConvertModelUI.py @@ -7,6 +7,7 @@ from modules.util.enum.DataType import DataType from modules.util.enum.ModelFormat import ModelFormat from modules.util.enum.ModelType import ModelType +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TrainingMethod import TrainingMethod from modules.util.ModelNames import EmbeddingName, ModelNames from modules.util.torch_util import torch_gc @@ -107,7 +108,12 @@ def main_frame(self, master): # output model destination components.label(master, 5, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - components.path_entry(master, 5, 1, self.ui_state, "output_model_destination", mode="file", is_output=True) + components.path_entry( + master, 5, 1, self.ui_state, "output_model_destination", + mode="file", + io_type=PathIOType.OUTPUT, + output_format_var=self.ui_state.get_var("output_model_format"), + ) self.button = components.button(master, 6, 1, "Convert", self.convert_model) diff --git a/modules/ui/ModelTab.py b/modules/ui/ModelTab.py index 182ee7ff2..02c0aa824 100644 --- a/modules/ui/ModelTab.py +++ b/modules/ui/ModelTab.py @@ -5,6 +5,7 @@ from modules.util.enum.ConfigPart import ConfigPart from modules.util.enum.DataType import DataType from modules.util.enum.ModelFormat import ModelFormat +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TrainingMethod import TrainingMethod from modules.util.ui import components from modules.util.ui.UIState import UIState @@ -613,10 +614,19 @@ def __create_output_components( allow_legacy_safetensors: bool = False, allow_comfy: bool = False, ) -> int: + prevent_overwrites_var = self.ui_state.get_var("prevent_overwrites") + output_format_var = self.ui_state.get_var("output_model_format") + # output model destination components.label(frame, row, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - components.path_entry(frame, row, 1, self.ui_state, "output_model_destination", mode="file", is_output=True) + dest_frame = components.path_entry( + frame, row, 1, self.ui_state, "output_model_destination", + mode="file", + io_type=PathIOType.MODEL, + prevent_overwrites_var=prevent_overwrites_var, + output_format_var=output_format_var, + ) # output data type components.label(frame, row, 3, "Output Data Type", @@ -646,6 +656,14 @@ def __create_output_components( tooltip="Format to use when saving the output model") components.options_kv(frame, row, 1, formats, self.ui_state, "output_model_format") + path_validator = getattr(dest_frame, '_path_validator', None) + if path_validator is not None: + def _on_format_or_overwrite_change(*_args): + path_validator.revalidate() + + output_format_var.trace_add("write", _on_format_or_overwrite_change) + prevent_overwrites_var.trace_add("write", _on_format_or_overwrite_change) + # include config components.label(frame, row, 3, "Include Config", tooltip="Include the training configuration in the final model. Only supported for safetensors files. " diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 7d0fe1939..b6feb17f8 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -257,6 +257,11 @@ def create_general_tab(self, master): tooltip="Only populate the cache, without any training") components.switch(frame, 2, 3, self.ui_state, "only_cache") + # prevent overwrites + components.label(frame, 3, 0, "Prevent Overwrites", + tooltip="When enabled, output paths that already exist on disk will be flagged as invalid to avoid accidental overwrites") + components.switch(frame, 3, 1, self.ui_state, "prevent_overwrites") + # debug components.label(frame, 4, 0, "Debug mode", tooltip="Save debug information during the training into the debug directory") diff --git a/modules/util/config/TrainConfig.py b/modules/util/config/TrainConfig.py index 088a9308f..81cc1c0bf 100644 --- a/modules/util/config/TrainConfig.py +++ b/modules/util/config/TrainConfig.py @@ -368,6 +368,7 @@ class TrainConfig(BaseConfig): validate_after: float validate_after_unit: TimeUnit continue_last_backup: bool + prevent_overwrites: bool include_train_config: ConfigPart # multi-GPU @@ -569,7 +570,7 @@ class TrainConfig(BaseConfig): def __init__(self, data: list[(str, Any, type, bool)]): super().__init__( data, - config_version=10, + config_version=11, config_migrations={ 0: self.__migration_0, 1: self.__migration_1, @@ -581,6 +582,7 @@ def __init__(self, data: list[(str, Any, type, bool)]): 7: self.__migration_7, 8: self.__migration_8, 9: self.__migration_9, + 10: self.__migration_10, } ) @@ -800,6 +802,11 @@ def replace_dtype(part: str): return migrated_data + def __migration_10(self, data: dict) -> dict: + migrated_data = data.copy() + migrated_data.setdefault("prevent_overwrites", True) + return migrated_data + def weight_dtypes(self) -> ModelWeightDtypes: return ModelWeightDtypes( self.train_dtype, @@ -953,6 +960,7 @@ def default_values() -> 'TrainConfig': data.append(("validate_after", 1, int, False)) data.append(("validate_after_unit", TimeUnit.EPOCH, TimeUnit, False)) data.append(("continue_last_backup", False, bool, False)) + data.append(("prevent_overwrites", True, bool, False)) data.append(("include_train_config", ConfigPart.NONE, ConfigPart, False)) #multi-GPU diff --git a/modules/util/enum/PathIOType.py b/modules/util/enum/PathIOType.py new file mode 100644 index 000000000..6aae2fcb0 --- /dev/null +++ b/modules/util/enum/PathIOType.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class PathIOType(Enum): + INPUT = "INPUT" + OUTPUT = "OUTPUT" + MODEL = "MODEL" + + def __str__(self): + return self.value diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 55abbb87d..09f2a1708 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -1,12 +1,14 @@ +import tkinter as tk from collections.abc import Callable from tkinter import filedialog from typing import Any, Literal +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TimeUnit import TimeUnit from modules.util.path_util import supported_image_extensions from modules.util.ui.ToolTip import ToolTip from modules.util.ui.UIState import UIState -from modules.util.ui.validation import FieldValidator +from modules.util.ui.validation import FieldValidator, PathContext, PathValidator import customtkinter as ctk from customtkinter.windows.widgets.scaling import CTkScalingBaseClass @@ -50,6 +52,7 @@ def entry( width: int = 140, sticky: str = "new", max_undo: int | None = None, + validator_factory: Callable[..., FieldValidator] | None = None, ): var = ui_state.get_var(var_name) trace_id = None @@ -60,8 +63,12 @@ def entry( component.grid(row=row, column=column, padx=PAD, pady=PAD, sticky=sticky) undo_kwargs = {"max_undo": max_undo} if max_undo is not None else {} - validator = FieldValidator(component, var, ui_state, var_name, **undo_kwargs) + if validator_factory is not None: + validator = validator_factory(component, var, ui_state, var_name, **undo_kwargs) + else: + validator = FieldValidator(component, var, ui_state, var_name, **undo_kwargs) validator.attach() + component._validator = validator # type: ignore[attr-defined] original_destroy = component.destroy @@ -91,18 +98,35 @@ def path_entry( master, row, column, ui_state: UIState, var_name: str, *, mode: Literal["file", "dir"] = "file", - is_output: bool = False, + io_type: PathIOType = PathIOType.INPUT, path_modifier: Callable[[str], str] | None = None, allow_model_files: bool = True, allow_image_files: bool = False, command: Callable[[str], None] | None = None, + prevent_overwrites_var: tk.BooleanVar | None = None, + output_format_var: tk.StringVar | None = None, ): + frame = ctk.CTkFrame(master, fg_color="transparent") frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") frame.grid_columnconfigure(0, weight=1) - entry(frame, row=0, column=0, ui_state=ui_state, var_name=var_name) + path_ctx = PathContext( + io_type=io_type, + prevent_overwrites_var=prevent_overwrites_var, + output_format_var=output_format_var, + ) + + def _path_validator_factory(comp, var, state, name, **kw): + return PathValidator(comp, var, state, name, path_ctx=path_ctx, **kw) + + entry_component = entry( + frame, row=0, column=0, ui_state=ui_state, var_name=var_name, + validator_factory=_path_validator_factory, + ) + + use_save_dialog = io_type in (PathIOType.OUTPUT, PathIOType.MODEL) def __open_dialog(): if mode == "dir": @@ -123,7 +147,7 @@ def __open_dialog(): ("Image", ' '.join([f"*.{x}" for x in supported_image_extensions()])), ]) - if is_output: + if use_save_dialog: chosen = filedialog.asksaveasfilename(filetypes=filetypes) else: chosen = filedialog.askopenfilename(filetypes=filetypes) @@ -140,31 +164,9 @@ def __open_dialog(): button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") - return frame - + frame._path_validator = getattr(entry_component, '_validator', None) # type: ignore[attr-defined] -# Keep backwards-compatible aliases so any not-yet-migrated callers still work -def file_entry( - master, row, column, ui_state: UIState, var_name: str, - is_output: bool = False, - path_modifier: Callable[[str], str] | None = None, - allow_model_files: bool = True, - allow_image_files: bool = False, - command: Callable[[str], None] | None = None, -): - return path_entry( - master, row, column, ui_state, var_name, - mode="file", is_output=is_output, path_modifier=path_modifier, - allow_model_files=allow_model_files, allow_image_files=allow_image_files, - command=command, - ) - - -def dir_entry(master, row, column, ui_state: UIState, var_name: str, command: Callable[[str], None] | None = None): - return path_entry( - master, row, column, ui_state, var_name, - mode="dir", command=command, - ) + return frame def time_entry(master, row, column, ui_state: UIState, var_name: str, unit_var_name, supports_time_units: bool = True): diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py index be41494b1..2a47dbc7f 100644 --- a/modules/util/ui/validation.py +++ b/modules/util/ui/validation.py @@ -1,11 +1,18 @@ from __future__ import annotations import contextlib +import os +import re +import sys import tkinter as tk from collections import deque from collections.abc import Callable from dataclasses import dataclass +from pathlib import PurePosixPath, PureWindowsPath from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from modules.util.enum.PathIOType import PathIOType if TYPE_CHECKING: from modules.util.ui.UIState import UIState @@ -19,6 +26,108 @@ _active_validators: set[FieldValidator] = set() +TRAILING_SLASH_RE = re.compile(r"[\\/]$") +ENDS_WITH_EXT = re.compile(r"\.[A-Za-z0-9]+$") +HUGGINGFACE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + +_INVALID_CHARS = {chr(c) for c in range(32)} +_IS_WINDOWS = sys.platform == "win32" +if _IS_WINDOWS: + _INVALID_CHARS |= set('<>"|?*') + +_FORMAT_EXTENSIONS: dict[str, str] = { + "CKPT": ".ckpt", + "SAFETENSORS": ".safetensors", + "LEGACY_SAFETENSORS": ".safetensors", + "COMFY_LORA": ".safetensors", +} + + +def _is_huggingface_repo_or_file(value: str) -> bool: + trimmed = value.strip() + + if trimmed.startswith("https://"): + parsed = urlparse(trimmed) + if parsed.netloc not in {"huggingface.co", "huggingface.com"}: + return False + parts = parsed.path.strip("/").split("/") + if len(parts) >= 5 and parts[2] in {"resolve", "blob"}: + return bool(ENDS_WITH_EXT.search(parts[-1])) + return False + + if len(trimmed) > 96: + return False + if " " in trimmed or "\t" in trimmed: + return False + if "—" in trimmed or ".." in trimmed: + return False + if trimmed.startswith(("\\\\", "//", "/")): + return False + if len(trimmed) >= 2 and trimmed[1] == ":" and trimmed[0].isalpha(): + return False + if trimmed.count("/") != 1: + return False + + return bool(HUGGINGFACE_REPO_RE.match(trimmed)) + + +def _has_invalid_chars(value: str) -> bool: + return bool(_INVALID_CHARS.intersection(value)) + + +def _check_overwrite(path: str, *, is_dir: bool, prevent: bool) -> str | None: + if not prevent: + return None + abs_path = os.path.abspath(path) + if is_dir and os.path.isdir(abs_path): + return "Output folder already exists (overwrite prevented)" + if not is_dir and os.path.isfile(abs_path): + return "Output file already exists (overwrite prevented)" + return None + + +def validate_path( + value: str, + io_type: PathIOType = PathIOType.INPUT, + *, + prevent_overwrites: bool = False, + output_format: str | None = None, +) -> str | None: + """Return an error string if *value* is an invalid path, else ``None``.""" + trimmed = value.strip() + + if not trimmed: + return "Path is empty" + if TRAILING_SLASH_RE.search(trimmed): + return "Path must not end with a slash" + if _has_invalid_chars(trimmed): + return "Path contains invalid characters" + + if io_type == PathIOType.INPUT and _is_huggingface_repo_or_file(trimmed): + return None + + if io_type in (PathIOType.OUTPUT, PathIOType.MODEL): + if not os.path.isdir(os.path.dirname(os.path.abspath(trimmed))): + return "Parent folder does not exist" + + if io_type == PathIOType.MODEL and output_format is not None: + if output_format == "DIFFUSERS": + if ENDS_WITH_EXT.search(trimmed): + return "Diffusers output must be a directory path, not a file" + return _check_overwrite(trimmed, is_dir=True, prevent=prevent_overwrites) + + expected_ext = _FORMAT_EXTENSIONS.get(output_format, "") + if expected_ext: + suffix = (PureWindowsPath(trimmed) if _IS_WINDOWS else PurePosixPath(trimmed)).suffix.lower() + if suffix != expected_ext: + return f"Extension must be '{expected_ext}' for {output_format} format" + return _check_overwrite(trimmed, is_dir=False, prevent=prevent_overwrites) + + if io_type == PathIOType.OUTPUT: + return _check_overwrite(trimmed, is_dir=False, prevent=prevent_overwrites) + + return None + DEFAULT_MAX_UNDO = 20 @@ -250,6 +359,48 @@ def _on_redo(self, _e=None) -> str: return "break" +@dataclass +class PathContext: + io_type: PathIOType = PathIOType.INPUT + prevent_overwrites_var: tk.BooleanVar | None = None + output_format_var: tk.StringVar | None = None + + +class PathValidator(FieldValidator): + """FieldValidator with additional path-specific checks.""" + + def __init__( + self, + component: ctk.CTkEntry, + var: tk.Variable, + ui_state: UIState, + var_name: str, + path_ctx: PathContext | None = None, + max_undo: int = DEFAULT_MAX_UNDO, + ): + super().__init__(component, var, ui_state, var_name, max_undo=max_undo) + self.path_ctx = path_ctx or PathContext() + + def validate(self, value: str) -> str | None: + base_err = super().validate(value) + if base_err is not None: + return base_err + if value == "": + return None + + ctx = self.path_ctx + return validate_path( + value, + io_type=ctx.io_type, + prevent_overwrites=ctx.prevent_overwrites_var.get() if ctx.prevent_overwrites_var is not None else False, + output_format=ctx.output_format_var.get() if ctx.output_format_var is not None else None, + ) + + def revalidate(self) -> None: + if self.component.winfo_exists(): + self._validate_and_style(self.var.get()) + + def flush_and_validate_all() -> list[str]: invalid: list[str] = [] From 9052e8e839251d576212e506c6548b330b05512b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:19:14 +0000 Subject: [PATCH 03/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- modules/ui/ConceptTab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui/ConceptTab.py b/modules/ui/ConceptTab.py index c610a6850..f96134682 100644 --- a/modules/ui/ConceptTab.py +++ b/modules/ui/ConceptTab.py @@ -10,8 +10,8 @@ from modules.util.enum.ConceptType import ConceptType from modules.util.image_util import load_image from modules.util.ui import components -from modules.util.ui.validation import DebounceTimer from modules.util.ui.UIState import UIState +from modules.util.ui.validation import DebounceTimer import customtkinter as ctk from PIL import Image From cf18297b0c4bac90639495748d4d1243023aa8e0 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:42:07 +1100 Subject: [PATCH 04/14] Adds extra validation behaviour (range, required), required int and float to be positive, delete dead code --- modules/ui/ConvertModelUI.py | 3 +- modules/ui/LoraTab.py | 7 +- modules/ui/ModelTab.py | 15 +---- modules/ui/TrainUI.py | 92 +++++++++++++-------------- modules/ui/TrainingTab.py | 49 +++++++------- modules/util/ui/components.py | 52 ++++++++++----- modules/util/ui/validation.py | 47 +++++++++----- modules/util/ui/validation_helpers.py | 58 +++++++++++++++++ 8 files changed, 204 insertions(+), 119 deletions(-) create mode 100644 modules/util/ui/validation_helpers.py diff --git a/modules/ui/ConvertModelUI.py b/modules/ui/ConvertModelUI.py index f24055502..ff57344f5 100644 --- a/modules/ui/ConvertModelUI.py +++ b/modules/ui/ConvertModelUI.py @@ -111,8 +111,7 @@ def main_frame(self, master): components.path_entry( master, 5, 1, self.ui_state, "output_model_destination", mode="file", - io_type=PathIOType.OUTPUT, - output_format_var=self.ui_state.get_var("output_model_format"), + io_type=PathIOType.MODEL, ) self.button = components.button(master, 6, 1, "Convert", self.convert_model) diff --git a/modules/ui/LoraTab.py b/modules/ui/LoraTab.py index 78b815d81..08a6d5507 100644 --- a/modules/ui/LoraTab.py +++ b/modules/ui/LoraTab.py @@ -5,6 +5,7 @@ from modules.util.enum.ModelType import PeftType from modules.util.ui import components from modules.util.ui.UIState import UIState +from modules.util.ui.validation_helpers import check_range import customtkinter as ctk @@ -89,12 +90,12 @@ def setup_lora(self, peft_type: PeftType): # rank components.label(master, 1, 0, f"{name} rank", tooltip=f"The rank parameter used when creating a new {name}") - components.entry(master, 1, 1, self.ui_state, "lora_rank") + components.entry(master, 1, 1, self.ui_state, "lora_rank", required=True, extra_validate=check_range(lower=1, message="Rank must be at least 1")) # alpha components.label(master, 2, 0, f"{name} alpha", tooltip=f"The alpha parameter used when creating a new {name}") - components.entry(master, 2, 1, self.ui_state, "lora_alpha") + components.entry(master, 2, 1, self.ui_state, "lora_alpha", required=True) # Dropout Percentage components.label(master, 3, 0, "Dropout Probability", @@ -119,7 +120,7 @@ def setup_lora(self, peft_type: PeftType): # Block Size components.label(master, 1, 0, f"{name} Block Size", tooltip=f"The block size parameter used when creating a new {name}") - components.entry(master, 1, 1, self.ui_state, "oft_block_size") + components.entry(master, 1, 1, self.ui_state, "oft_block_size", required=True) # COFT components.label(master, 1, 3, "Constrained OFT (COFT)", diff --git a/modules/ui/ModelTab.py b/modules/ui/ModelTab.py index 02c0aa824..24a59e3e1 100644 --- a/modules/ui/ModelTab.py +++ b/modules/ui/ModelTab.py @@ -614,18 +614,13 @@ def __create_output_components( allow_legacy_safetensors: bool = False, allow_comfy: bool = False, ) -> int: - prevent_overwrites_var = self.ui_state.get_var("prevent_overwrites") - output_format_var = self.ui_state.get_var("output_model_format") - # output model destination components.label(frame, row, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - dest_frame = components.path_entry( + components.path_entry( frame, row, 1, self.ui_state, "output_model_destination", mode="file", io_type=PathIOType.MODEL, - prevent_overwrites_var=prevent_overwrites_var, - output_format_var=output_format_var, ) # output data type @@ -656,14 +651,6 @@ def __create_output_components( tooltip="Format to use when saving the output model") components.options_kv(frame, row, 1, formats, self.ui_state, "output_model_format") - path_validator = getattr(dest_frame, '_path_validator', None) - if path_validator is not None: - def _on_format_or_overwrite_change(*_args): - path_validator.revalidate() - - output_format_var.trace_add("write", _on_format_or_overwrite_change) - prevent_overwrites_var.trace_add("write", _on_format_or_overwrite_change) - # include config components.label(frame, row, 3, "Include Config", tooltip="Include the training configuration in the final model. Only supported for safetensors files. " diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index b6feb17f8..482991c60 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -474,52 +474,52 @@ def create_backup_tab(self, master): frame.pack(fill="both", expand=1) return frame - def lora_tab(self, master): - frame = ctk.CTkScrollableFrame(master, fg_color="transparent") - frame.grid_columnconfigure(0, weight=0) - frame.grid_columnconfigure(1, weight=1) - frame.grid_columnconfigure(2, minsize=50) - frame.grid_columnconfigure(3, weight=0) - frame.grid_columnconfigure(4, weight=1) - - # lora model name - components.label(frame, 0, 0, "LoRA base model", - tooltip="The base LoRA to train on. Leave empty to create a new LoRA") - components.path_entry( - frame, 0, 1, self.ui_state, "lora_model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x - ) - - # lora rank - components.label(frame, 1, 0, "LoRA rank", - tooltip="The rank parameter used when creating a new LoRA") - components.entry(frame, 1, 1, self.ui_state, "lora_rank") - - # lora rank - components.label(frame, 2, 0, "LoRA alpha", - tooltip="The alpha parameter used when creating a new LoRA") - components.entry(frame, 2, 1, self.ui_state, "lora_alpha") - - # Dropout Percentage - components.label(frame, 3, 0, "Dropout Probability", - tooltip="Dropout probability. This percentage of model nodes will be randomly ignored at each training step. Helps with overfitting. 0 disables, 1 maximum.") - components.entry(frame, 3, 1, self.ui_state, "dropout_probability") - - # lora weight dtype - components.label(frame, 4, 0, "LoRA Weight Data Type", - tooltip="The LoRA weight data type used for training. This can reduce memory consumption, but reduces precision") - components.options_kv(frame, 4, 1, [ - ("float32", DataType.FLOAT_32), - ("bfloat16", DataType.BFLOAT_16), - ], self.ui_state, "lora_weight_dtype") - - # For use with additional embeddings. - components.label(frame, 5, 0, "Bundle Embeddings", - tooltip="Bundles any additional embeddings into the LoRA output file, rather than as separate files") - components.switch(frame, 5, 1, self.ui_state, "bundle_additional_embeddings") - - frame.pack(fill="both", expand=1) - return frame + # def lora_tab(self, master): + # frame = ctk.CTkScrollableFrame(master, fg_color="transparent") + # frame.grid_columnconfigure(0, weight=0) + # frame.grid_columnconfigure(1, weight=1) + # frame.grid_columnconfigure(2, minsize=50) + # frame.grid_columnconfigure(3, weight=0) + # frame.grid_columnconfigure(4, weight=1) + + # # lora model name + # components.label(frame, 0, 0, "LoRA base model", + # tooltip="The base LoRA to train on. Leave empty to create a new LoRA") + # components.path_entry( + # frame, 0, 1, self.ui_state, "lora_model_name", + # mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + # ) + + # # lora rank + # components.label(frame, 1, 0, "LoRA rank", + # tooltip="The rank parameter used when creating a new LoRA") + # components.entry(frame, 1, 1, self.ui_state, "lora_rank", required=True) + + # # lora alpha + # components.label(frame, 2, 0, "LoRA alpha", + # tooltip="The alpha parameter used when creating a new LoRA") + # components.entry(frame, 2, 1, self.ui_state, "lora_alpha", required=True) + + # # Dropout Percentage + # components.label(frame, 3, 0, "Dropout Probability", + # tooltip="Dropout probability. This percentage of model nodes will be randomly ignored at each training step. Helps with overfitting. 0 disables, 1 maximum.") + # components.entry(frame, 3, 1, self.ui_state, "dropout_probability") + + # # lora weight dtype + # components.label(frame, 4, 0, "LoRA Weight Data Type", + # tooltip="The LoRA weight data type used for training. This can reduce memory consumption, but reduces precision") + # components.options_kv(frame, 4, 1, [ + # ("float32", DataType.FLOAT_32), + # ("bfloat16", DataType.BFLOAT_16), + # ], self.ui_state, "lora_weight_dtype") + + # # For use with additional embeddings. + # components.label(frame, 5, 0, "Bundle Embeddings", + # tooltip="Bundles any additional embeddings into the LoRA output file, rather than as separate files") + # components.switch(frame, 5, 1, self.ui_state, "bundle_additional_embeddings") + + # frame.pack(fill="both", expand=1) + # return frame def embedding_tab(self, master): frame = ctk.CTkScrollableFrame(master, fg_color="transparent") diff --git a/modules/ui/TrainingTab.py b/modules/ui/TrainingTab.py index eec2a51e4..284c3d604 100644 --- a/modules/ui/TrainingTab.py +++ b/modules/ui/TrainingTab.py @@ -16,6 +16,7 @@ from modules.util.optimizer_util import change_optimizer from modules.util.ui import components from modules.util.ui.UIState import UIState +from modules.util.ui.validation_helpers import check_range import customtkinter as ctk @@ -290,7 +291,7 @@ def __create_base_frame(self, master, row): # learning rate components.label(frame, 2, 0, "Learning Rate", tooltip="The base learning rate") - components.entry(frame, 2, 1, self.ui_state, "learning_rate") + components.entry(frame, 2, 1, self.ui_state, "learning_rate", required=True) # learning rate warmup steps components.label(frame, 3, 0, "Learning Rate Warmup Steps", @@ -300,7 +301,7 @@ def __create_base_frame(self, master, row): # learning rate min factor components.label(frame, 4, 0, "Learning Rate Min Factor", tooltip="Unit = float. Method = percentage. For a factor of 0.1, the final LR will be 10% of the initial LR. If the initial LR is 1e-4, the final LR will be 1e-5.") - components.entry(frame, 4, 1, self.ui_state, "learning_rate_min_factor") + components.entry(frame, 4, 1, self.ui_state, "learning_rate_min_factor", extra_validate=check_range(lower=0, upper=0.99, message="Learning rate min factor must be between 0 and 0.99")) # learning rate cycles components.label(frame, 5, 0, "Learning Rate Cycles", @@ -310,17 +311,17 @@ def __create_base_frame(self, master, row): # epochs components.label(frame, 6, 0, "Epochs", tooltip="The number of epochs for a full training run") - components.entry(frame, 6, 1, self.ui_state, "epochs") + components.entry(frame, 6, 1, self.ui_state, "epochs", required=True) # batch size components.label(frame, 7, 0, "Local Batch Size", tooltip="The batch size of one training step. If you use multiple GPUs, this is the batch size of each GPU (local batch size).") - components.entry(frame, 7, 1, self.ui_state, "batch_size") + components.entry(frame, 7, 1, self.ui_state, "batch_size", required=True, ) # accumulation steps components.label(frame, 8, 0, "Accumulation Steps", tooltip="Number of accumulation steps. Increase this number to trade batch size for training speed") - components.entry(frame, 8, 1, self.ui_state, "gradient_accumulation_steps") + components.entry(frame, 8, 1, self.ui_state, "gradient_accumulation_steps", required=True) # Learning Rate Scaler components.label(frame, 9, 0, "Learning Rate Scaler", @@ -348,7 +349,9 @@ def __create_base2_frame(self, master, row, video_training_enabled: bool = False # ema decay components.label(frame, row, 0, "EMA Decay", tooltip="Decay parameter of the EMA model. Higher numbers will average more steps. For datasets of hundreds or thousands of images, set this to 0.9999. For smaller datasets, set it to 0.999 or even 0.998") - components.entry(frame, row, 1, self.ui_state, "ema_decay") + components.entry(frame, row, 1, self.ui_state, "ema_decay", + extra_validate=check_range(lower=0.5, upper=1, + message="EMA decay must be between 0.5 and 1")) row += 1 # ema update step interval @@ -399,14 +402,14 @@ def __create_base2_frame(self, master, row, video_training_enabled: bool = False # resolution components.label(frame, row, 0, "Resolution", tooltip="The resolution used for training. Optionally specify multiple resolutions separated by a comma, or a single exact resolution in the format x") - components.entry(frame, row, 1, self.ui_state, "resolution") + components.entry(frame, row, 1, self.ui_state, "resolution", required=True) row += 1 # frames if video_training_enabled: components.label(frame, row, 0, "Frames", tooltip="The number of frames used for training.") - components.entry(frame, row, 1, self.ui_state, "frames") + components.entry(frame, row, 1, self.ui_state, "frames", required=True) row += 1 # force circular padding @@ -649,27 +652,27 @@ def __create_noise_frame(self, master, row, supports_generalized_offset_noise: b # min noising strength components.label(frame, 4, 0, "Min Noising Strength", tooltip="Specifies the minimum noising strength used during training. This can help to improve composition, but prevents finer details from being trained") - components.entry(frame, 4, 1, self.ui_state, "min_noising_strength") + components.entry(frame, 4, 1, self.ui_state, "min_noising_strength", required=True) # max noising strength components.label(frame, 5, 0, "Max Noising Strength", tooltip="Specifies the maximum noising strength used during training. This can be useful to reduce overfitting, but also reduces the impact of training samples on the overall image composition") - components.entry(frame, 5, 1, self.ui_state, "max_noising_strength") + components.entry(frame, 5, 1, self.ui_state, "max_noising_strength", required=True) # noising weight components.label(frame, 6, 0, "Noising Weight", tooltip="Controls the weight parameter of the timestep distribution function. Use the preview to see more details.") - components.entry(frame, 6, 1, self.ui_state, "noising_weight") + components.entry(frame, 6, 1, self.ui_state, "noising_weight", required=True) # noising bias components.label(frame, 7, 0, "Noising Bias", tooltip="Controls the bias parameter of the timestep distribution function. Use the preview to see more details.") - components.entry(frame, 7, 1, self.ui_state, "noising_bias") + components.entry(frame, 7, 1, self.ui_state, "noising_bias", required=True) # timestep shift components.label(frame, 8, 0, "Timestep Shift", tooltip="Shift the timestep distribution. Use the preview to see more details.") - components.entry(frame, 8, 1, self.ui_state, "timestep_shift") + components.entry(frame, 8, 1, self.ui_state, "timestep_shift", required=True) if supports_dynamic_timestep_shifting: # dynamic timestep shifting @@ -692,12 +695,12 @@ def __create_masked_frame(self, master, row): # unmasked probability components.label(frame, 1, 0, "Unmasked Probability", tooltip="When masked training is enabled, specifies the number of training steps done on unmasked samples") - components.entry(frame, 1, 1, self.ui_state, "unmasked_probability") + components.entry(frame, 1, 1, self.ui_state, "unmasked_probability", extra_validate=check_range(lower=0, upper=1, message="Unmasked probability must be between 0 and 1")) # unmasked weight components.label(frame, 2, 0, "Unmasked Weight", tooltip="When masked training is enabled, specifies the loss weight of areas outside the masked region") - components.entry(frame, 2, 1, self.ui_state, "unmasked_weight") + components.entry(frame, 2, 1, self.ui_state, "unmasked_weight", extra_validate=check_range(lower=0, upper=1, message="Unmasked weight must be between 0 and 1")) # normalize masked area loss components.label(frame, 3, 0, "Normalize Masked Area Loss", @@ -707,7 +710,7 @@ def __create_masked_frame(self, master, row): # masked prior preservation components.label(frame, 4, 0, "Masked Prior Preservation Weight", tooltip="Preserves regions outside the mask using the original untrained model output as a target. Only available for LoRA training. If enabled, use a low unmasked weight.") - components.entry(frame, 4, 1, self.ui_state, "masked_prior_preservation_weight") + components.entry(frame, 4, 1, self.ui_state, "masked_prior_preservation_weight", extra_validate=check_range(lower=0, upper=1, message="Masked prior preservation weight must be between 0 and 1")) # use custom conditioning image components.label(frame, 5, 0, "Custom Conditioning Image", @@ -722,33 +725,33 @@ def __create_loss_frame(self, master, row, supports_vb_loss: bool = False): # MSE Strength components.label(frame, 0, 0, "MSE Strength", tooltip="Mean Squared Error strength for custom loss settings. Strengths should generally sum to 1.") - components.entry(frame, 0, 1, self.ui_state, "mse_strength") + components.entry(frame, 0, 1, self.ui_state, "mse_strength", required=True) # MAE Strength components.label(frame, 1, 0, "MAE Strength", tooltip="Mean Absolute Error strength for custom loss settings. Strengths should generally sum to 1.") - components.entry(frame, 1, 1, self.ui_state, "mae_strength") + components.entry(frame, 1, 1, self.ui_state, "mae_strength", required=True) # log-cosh Strength components.label(frame, 2, 0, "log-cosh Strength", tooltip="Log - Hyperbolic cosine Error strength for custom loss settings. Strengths should generally sum to 1.") - components.entry(frame, 2, 1, self.ui_state, "log_cosh_strength") + components.entry(frame, 2, 1, self.ui_state, "log_cosh_strength", required=True) # Huber Strength components.label(frame, 3, 0, "Huber Strength", tooltip="Huber loss strength for custom loss settings. Less sensitive to outliers than MSE. Strengths should generally sum to 1.") - components.entry(frame, 3, 1, self.ui_state, "huber_strength") + components.entry(frame, 3, 1, self.ui_state, "huber_strength", required=True) # Huber Delta components.label(frame, 4, 0, "Huber Delta", tooltip="Delta parameter for huber loss") - components.entry(frame, 4, 1, self.ui_state, "huber_delta") + components.entry(frame, 4, 1, self.ui_state, "huber_delta", required=True) if supports_vb_loss: # VB Strength components.label(frame, 5, 0, "VB Strength", tooltip="Variational lower-bound strength for custom loss settings. Should be set to 1 for variational diffusion models") - components.entry(frame, 5, 1, self.ui_state, "vb_loss_strength") + components.entry(frame, 5, 1, self.ui_state, "vb_loss_strength", required=True) # Loss Weight function components.label(frame, 6, 0, "Loss Weight Function", @@ -765,7 +768,7 @@ def __create_loss_frame(self, master, row, supports_vb_loss: bool = False): if not self.train_config.model_type.is_flow_matching(): components.label(frame, row, 0, "Gamma", tooltip="Inverse strength of loss weighting. Range: 1-20, only applies to Min SNR and P2.") - components.entry(frame, row, 1, self.ui_state, "loss_weight_strength") + components.entry(frame, row, 1, self.ui_state, "loss_weight_strength", extra_validate=check_range(lower=1, upper=20, message="Gamma must be between 1 and 20")) row += 1 # Loss Scaler diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 09f2a1708..3a5ec465b 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -1,3 +1,4 @@ +import contextlib import tkinter as tk from collections.abc import Callable from tkinter import filedialog @@ -8,7 +9,7 @@ from modules.util.path_util import supported_image_extensions from modules.util.ui.ToolTip import ToolTip from modules.util.ui.UIState import UIState -from modules.util.ui.validation import FieldValidator, PathContext, PathValidator +from modules.util.ui.validation import DEFAULT_MAX_UNDO, FieldValidator, PathValidator import customtkinter as ctk from customtkinter.windows.widgets.scaling import CTkScalingBaseClass @@ -53,6 +54,8 @@ def entry( sticky: str = "new", max_undo: int | None = None, validator_factory: Callable[..., FieldValidator] | None = None, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, ): var = ui_state.get_var(var_name) trace_id = None @@ -62,11 +65,20 @@ def entry( component = ctk.CTkEntry(master, textvariable=var, width=width) component.grid(row=row, column=column, padx=PAD, pady=PAD, sticky=sticky) - undo_kwargs = {"max_undo": max_undo} if max_undo is not None else {} if validator_factory is not None: - validator = validator_factory(component, var, ui_state, var_name, **undo_kwargs) + validator = validator_factory( + component, var, ui_state, var_name, + max_undo=max_undo or DEFAULT_MAX_UNDO, + extra_validate=extra_validate, + required=required, + ) else: - validator = FieldValidator(component, var, ui_state, var_name, **undo_kwargs) + validator = FieldValidator( + component, var, ui_state, var_name, + max_undo=max_undo or DEFAULT_MAX_UNDO, + extra_validate=extra_validate, + required=required, + ) validator.attach() component._validator = validator # type: ignore[attr-defined] @@ -103,29 +115,32 @@ def path_entry( allow_model_files: bool = True, allow_image_files: bool = False, command: Callable[[str], None] | None = None, - prevent_overwrites_var: tk.BooleanVar | None = None, - output_format_var: tk.StringVar | None = None, + required: bool = False, ): - frame = ctk.CTkFrame(master, fg_color="transparent") frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") frame.grid_columnconfigure(0, weight=1) - path_ctx = PathContext( - io_type=io_type, - prevent_overwrites_var=prevent_overwrites_var, - output_format_var=output_format_var, - ) - def _path_validator_factory(comp, var, state, name, **kw): - return PathValidator(comp, var, state, name, path_ctx=path_ctx, **kw) + return PathValidator(comp, var, state, name, io_type=io_type, **kw) entry_component = entry( frame, row=0, column=0, ui_state=ui_state, var_name=var_name, validator_factory=_path_validator_factory, + required=required, ) + trace_ids = [] + if io_type in (PathIOType.OUTPUT, PathIOType.MODEL): + validator = getattr(entry_component, '_validator', None) + if validator is not None: + for dep_var_name in ("prevent_overwrites", "output_model_format"): + with contextlib.suppress(KeyError, AttributeError): + dep_var = ui_state.get_var(dep_var_name) + tid = dep_var.trace_add("write", lambda *_a: validator.revalidate()) + trace_ids.append((dep_var, tid)) + use_save_dialog = io_type in (PathIOType.OUTPUT, PathIOType.MODEL) def __open_dialog(): @@ -164,7 +179,14 @@ def __open_dialog(): button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") - frame._path_validator = getattr(entry_component, '_validator', None) # type: ignore[attr-defined] + if trace_ids: + original_frame_destroy = frame.destroy + def _frame_destroy(): + for dep_var, tid in trace_ids: + with contextlib.suppress(tk.TclError, ValueError): + dep_var.trace_remove("write", tid) + original_frame_destroy() + frame.destroy = _frame_destroy # type: ignore[assignment] return frame diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py index 2a47dbc7f..abddec821 100644 --- a/modules/util/ui/validation.py +++ b/modules/util/ui/validation.py @@ -203,11 +203,15 @@ def __init__( ui_state: UIState, var_name: str, max_undo: int = DEFAULT_MAX_UNDO, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, ): self.component = component self.var = var self.ui_state = ui_state self.var_name = var_name + self._extra_validate = extra_validate + self._required = required try: self._original_border_color = component.cget("border_color") @@ -271,6 +275,8 @@ def validate(self, value: str) -> str | None: default_val = meta.default if value == "": + if self._required: + return "Value required" if nullable: return None if declared_type is str: @@ -281,15 +287,22 @@ def validate(self, value: str) -> str | None: try: if declared_type is int: - int(value) + v = int(value) + if v < 0: + return "Value must be non-negative" elif declared_type is float: - float(value) + v = float(value) + if v < 0: + return "Value must be non-negative" elif declared_type is bool: if value.lower() not in ("true", "false", "0", "1"): return "Invalid bool" except ValueError: return "Invalid value" + if self._extra_validate is not None: + return self._extra_validate(value) + return None def _apply_error(self) -> None: @@ -359,13 +372,6 @@ def _on_redo(self, _e=None) -> str: return "break" -@dataclass -class PathContext: - io_type: PathIOType = PathIOType.INPUT - prevent_overwrites_var: tk.BooleanVar | None = None - output_format_var: tk.StringVar | None = None - - class PathValidator(FieldValidator): """FieldValidator with additional path-specific checks.""" @@ -375,11 +381,19 @@ def __init__( var: tk.Variable, ui_state: UIState, var_name: str, - path_ctx: PathContext | None = None, + io_type: PathIOType = PathIOType.INPUT, max_undo: int = DEFAULT_MAX_UNDO, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, ): - super().__init__(component, var, ui_state, var_name, max_undo=max_undo) - self.path_ctx = path_ctx or PathContext() + super().__init__(component, var, ui_state, var_name, max_undo=max_undo, extra_validate=extra_validate, required=required) + self.io_type = io_type + + def _get_var_safe(self, name: str) -> tk.Variable | None: + try: + return self.ui_state.get_var(name) + except (KeyError, AttributeError): + return None def validate(self, value: str) -> str | None: base_err = super().validate(value) @@ -388,12 +402,13 @@ def validate(self, value: str) -> str | None: if value == "": return None - ctx = self.path_ctx + prevent_var = self._get_var_safe("prevent_overwrites") + format_var = self._get_var_safe("output_model_format") return validate_path( value, - io_type=ctx.io_type, - prevent_overwrites=ctx.prevent_overwrites_var.get() if ctx.prevent_overwrites_var is not None else False, - output_format=ctx.output_format_var.get() if ctx.output_format_var is not None else None, + io_type=self.io_type, + prevent_overwrites=prevent_var.get() if prevent_var is not None else False, + output_format=format_var.get() if format_var is not None else None, ) def revalidate(self) -> None: diff --git a/modules/util/ui/validation_helpers.py b/modules/util/ui/validation_helpers.py new file mode 100644 index 000000000..12cdcc4d6 --- /dev/null +++ b/modules/util/ui/validation_helpers.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from collections.abc import Callable + + +def check_range( + *, + lower: float | None = None, + upper: float | None = None, + lower_inclusive: bool = True, + upper_inclusive: bool = True, + message: str | None = None, +) -> Callable[[str], str | None]: + """Validate that a numeric value falls within specified range, by default both bounds are inclusive. + """ + + def _check(value: str) -> str | None: + try: + v = float(value) + except (ValueError, TypeError): + return None # type checking is handled by baseline validation + + if lower is not None: + if lower_inclusive and v < lower: + return message or f"Value must be at least {lower}" + if not lower_inclusive and v <= lower: + return message or f"Value must be greater than {lower}" + + if upper is not None: + if upper_inclusive and v > upper: + return message or f"Value must be at most {upper}" + if not upper_inclusive and v >= upper: + return message or f"Value must be less than {upper}" + + return None + + return _check + + +def compose(*checks: Callable[[str], str | None]) -> Callable[[str], str | None]: + """Chain multiple ``extra_validate`` checks; return the first error. + + Usage:: + + extra_validate=compose( + check_range(lower=0, upper=1), + , + ) + """ + + def _check(value: str) -> str | None: + for fn in checks: + err = fn(value) + if err is not None: + return err + return None + + return _check From 06504226c076c41f348fd5f8489e940ad4ceaa1f Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:46:15 +1100 Subject: [PATCH 05/14] Delete dead code found in TrainUI.py --- modules/ui/TrainUI.py | 47 ------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 482991c60..5b8ffce26 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -474,53 +474,6 @@ def create_backup_tab(self, master): frame.pack(fill="both", expand=1) return frame - # def lora_tab(self, master): - # frame = ctk.CTkScrollableFrame(master, fg_color="transparent") - # frame.grid_columnconfigure(0, weight=0) - # frame.grid_columnconfigure(1, weight=1) - # frame.grid_columnconfigure(2, minsize=50) - # frame.grid_columnconfigure(3, weight=0) - # frame.grid_columnconfigure(4, weight=1) - - # # lora model name - # components.label(frame, 0, 0, "LoRA base model", - # tooltip="The base LoRA to train on. Leave empty to create a new LoRA") - # components.path_entry( - # frame, 0, 1, self.ui_state, "lora_model_name", - # mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x - # ) - - # # lora rank - # components.label(frame, 1, 0, "LoRA rank", - # tooltip="The rank parameter used when creating a new LoRA") - # components.entry(frame, 1, 1, self.ui_state, "lora_rank", required=True) - - # # lora alpha - # components.label(frame, 2, 0, "LoRA alpha", - # tooltip="The alpha parameter used when creating a new LoRA") - # components.entry(frame, 2, 1, self.ui_state, "lora_alpha", required=True) - - # # Dropout Percentage - # components.label(frame, 3, 0, "Dropout Probability", - # tooltip="Dropout probability. This percentage of model nodes will be randomly ignored at each training step. Helps with overfitting. 0 disables, 1 maximum.") - # components.entry(frame, 3, 1, self.ui_state, "dropout_probability") - - # # lora weight dtype - # components.label(frame, 4, 0, "LoRA Weight Data Type", - # tooltip="The LoRA weight data type used for training. This can reduce memory consumption, but reduces precision") - # components.options_kv(frame, 4, 1, [ - # ("float32", DataType.FLOAT_32), - # ("bfloat16", DataType.BFLOAT_16), - # ], self.ui_state, "lora_weight_dtype") - - # # For use with additional embeddings. - # components.label(frame, 5, 0, "Bundle Embeddings", - # tooltip="Bundles any additional embeddings into the LoRA output file, rather than as separate files") - # components.switch(frame, 5, 1, self.ui_state, "bundle_additional_embeddings") - - # frame.pack(fill="both", expand=1) - # return frame - def embedding_tab(self, master): frame = ctk.CTkScrollableFrame(master, fg_color="transparent") frame.grid_columnconfigure(0, weight=0) From 18bfe6f97cdb89ff1d954c6788e7753075b439fb Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:20:13 +1100 Subject: [PATCH 06/14] Make commits require validation to pass in order to write to trainConfig --- modules/util/ui/validation.py | 130 ++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py index abddec821..0de2a798e 100644 --- a/modules/util/ui/validation.py +++ b/modules/util/ui/validation.py @@ -7,7 +7,6 @@ import tkinter as tk from collections import deque from collections.abc import Callable -from dataclasses import dataclass from pathlib import PurePosixPath, PureWindowsPath from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -188,13 +187,6 @@ def cancel(self): self._after_id = None -@dataclass -class _ValidationState: - touched: bool = False - trace_name: str | None = None - bound: bool = False - - class FieldValidator: def __init__( self, @@ -218,14 +210,21 @@ def __init__( except Exception: self._original_border_color = "gray50" - self._state = _ValidationState() + self._shadow_var = tk.StringVar(master=component) + self._shadow_trace_name: str | None = None + self._real_var_trace_name: str | None = None + self._syncing = False + self._touched = False + self._bound = False + self._debounce: DebounceTimer | None = None self._undo_debounce: DebounceTimer | None = None self._undo = UndoHistory(max_undo) - self._applying_undo = False def attach(self) -> None: - s = self._state + self._shadow_var.set(self.var.get()) + self._swap_textvariable(self._shadow_var) + self._debounce = DebounceTimer( self.component, DEBOUNCE_TYPING_MS, self._on_debounce_fire ) @@ -233,7 +232,8 @@ def attach(self) -> None: self.component, UNDO_DEBOUNCE_MS, self._push_undo_snapshot ) - s.trace_name = self.var.trace_add("write", self._on_var_write) + self._shadow_trace_name = self._shadow_var.trace_add("write", self._on_shadow_write) + self._real_var_trace_name = self.var.trace_add("write", self._on_real_var_write) self.component.bind("", self._on_focus_in) self.component.bind("", self._on_user_input) @@ -246,26 +246,56 @@ def attach(self) -> None: self.component.bind("", self._on_redo) self.component.bind("", self._on_redo) self.component.bind("", self._on_redo) + self.component.bind("", self._on_enter) - s.bound = True + self._bound = True _active_validators.add(self) def detach(self) -> None: - s = self._state - if not s.bound: + if not self._bound: return - s.bound = False + self._bound = False _active_validators.discard(self) + self._commit() + if self._debounce: self._debounce.cancel() if self._undo_debounce: self._undo_debounce.cancel() - if s.trace_name: + if self._shadow_trace_name: + with contextlib.suppress(Exception): + self._shadow_var.trace_remove("write", self._shadow_trace_name) + self._shadow_trace_name = None + + if self._real_var_trace_name: + with contextlib.suppress(Exception): + self.var.trace_remove("write", self._real_var_trace_name) + self._real_var_trace_name = None + + self._swap_textvariable(self.var) + + def _swap_textvariable(self, new_var: tk.Variable) -> None: + comp = self.component + if comp._textvariable_callback_name: with contextlib.suppress(Exception): - self.var.trace_remove("write", s.trace_name) - s.trace_name = None + comp._textvariable.trace_remove("write", comp._textvariable_callback_name) # type: ignore[union-attr] + comp._textvariable_callback_name = "" + + comp.configure(textvariable=new_var) + + if new_var is not None: + comp._textvariable_callback_name = new_var.trace_add( + "write", comp._textvariable_callback + ) + + def _commit(self) -> None: + shadow_val = self._shadow_var.get() + if shadow_val != self.var.get(): + self._syncing = True + self.var.set(shadow_val) + self._syncing = False def validate(self, value: str) -> str | None: """Return an error string if *value* is invalid, else None.""" @@ -320,11 +350,12 @@ def _validate_and_style(self, value: str) -> bool: self._apply_error() return False - def _on_var_write(self, *_args) -> None: - if self._applying_undo: + def _on_shadow_write(self, *_args) -> None: + if self._syncing: return - s = self._state - if not s.touched: + if not self._touched: + # external sync or initial set — commit immediately + self._commit() if self._debounce: self._debounce.cancel() return @@ -333,34 +364,56 @@ def _on_var_write(self, *_args) -> None: if self._undo_debounce: self._undo_debounce.call() + def _on_real_var_write(self, *_args) -> None: + if self._syncing: + return + # external change (preset load, file dialog, etc) — sync to shadow var + self._syncing = True + self._shadow_var.set(self.var.get()) + self._syncing = False + self._validate_and_style(self._shadow_var.get()) + def _push_undo_snapshot(self) -> None: - self._undo.push(self.var.get()) + self._undo.push(self._shadow_var.get()) def _on_debounce_fire(self) -> None: - self._validate_and_style(self.var.get()) + val = self._shadow_var.get() + if self._validate_and_style(val): + self._commit() def _on_focus_in(self, _e=None) -> None: - self._state.touched = False - self._undo.push(self.var.get()) + self._touched = False + self._undo.push(self._shadow_var.get()) def _on_user_input(self, _e=None) -> None: - self._state.touched = True + self._touched = True def _on_focus_out(self, _e=None) -> None: - if self._state.touched: - self._validate_and_style(self.var.get()) + if self._debounce: + self._debounce.cancel() if self._undo_debounce: self._undo_debounce.cancel() - self._undo.push(self.var.get()) + if self._touched: + if self._validate_and_style(self._shadow_var.get()): + self._commit() + self._undo.push(self._shadow_var.get()) + + def _on_enter(self, _e=None) -> None: + if self._debounce: + self._debounce.cancel() + if self._touched: + if self._validate_and_style(self._shadow_var.get()): + self._commit() def _set_value(self, value: str) -> None: - self._applying_undo = True - self.var.set(value) - self._applying_undo = False - self._validate_and_style(value) + self._syncing = True + self._shadow_var.set(value) + self._syncing = False + if self._validate_and_style(value): + self._commit() def _on_undo(self, _e=None) -> str: - previous = self._undo.undo(self.var.get()) + previous = self._undo.undo(self._shadow_var.get()) if previous is not None: self._set_value(previous) return "break" @@ -413,7 +466,7 @@ def validate(self, value: str) -> str | None: def revalidate(self) -> None: if self.component.winfo_exists(): - self._validate_and_style(self.var.get()) + self._validate_and_style(self._shadow_var.get()) def flush_and_validate_all() -> list[str]: @@ -423,7 +476,7 @@ def flush_and_validate_all() -> list[str]: if v._debounce: v._debounce.cancel() - value = v.var.get() + value = v._shadow_var.get() error = v.validate(value) if error is not None: @@ -431,5 +484,6 @@ def flush_and_validate_all() -> list[str]: invalid.append(f"{v.var_name}: {error}") else: v._clear_error() + v._commit() return invalid From 39300fa5a74b6e9014d14afaca605a69178cfb32 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:45:22 +1100 Subject: [PATCH 07/14] Reintroduce if IOType == input that we validate the file exists --- modules/util/ui/components.py | 7 ++++--- modules/util/ui/validation.py | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 3a5ec465b..14872627a 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -85,14 +85,15 @@ def entry( original_destroy = component.destroy def new_destroy(): + validator.detach() + # 'temporary' fix until https://github.com/TomSchimansky/CustomTkinter/pull/2077 is merged # unfortunately Tom has admitted to forgetting about how to maintain CTK so this likely will never be merged if component._textvariable_callback_name: - component._textvariable.trace_remove("write", component._textvariable_callback_name) # type: ignore[union-attr] + with contextlib.suppress(tk.TclError): + component._textvariable.trace_remove("write", component._textvariable_callback_name) # type: ignore[union-attr] component._textvariable_callback_name = "" - validator.detach() - if command is not None and trace_id is not None: ui_state.remove_var_trace(var_name, trace_id) diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py index 0de2a798e..110cd9d07 100644 --- a/modules/util/ui/validation.py +++ b/modules/util/ui/validation.py @@ -105,6 +105,10 @@ def validate_path( if io_type == PathIOType.INPUT and _is_huggingface_repo_or_file(trimmed): return None + if io_type == PathIOType.INPUT: + if not os.path.exists(os.path.abspath(trimmed)): + return "Input path does not exist" + if io_type in (PathIOType.OUTPUT, PathIOType.MODEL): if not os.path.isdir(os.path.dirname(os.path.abspath(trimmed))): return "Parent folder does not exist" From 77f7c92c16d89e9890f8418a9222919bffd2ad17 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:56:17 +1100 Subject: [PATCH 08/14] Specify that debug dir is an output not input (default) --- modules/ui/TrainUI.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 5b8ffce26..faac79c14 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -36,6 +36,7 @@ from modules.util.enum.GradientReducePrecision import GradientReducePrecision from modules.util.enum.ImageFormat import ImageFormat from modules.util.enum.ModelType import ModelType +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TrainingMethod import TrainingMethod from modules.util.torch_util import torch_gc from modules.util.TrainProgress import TrainProgress @@ -269,7 +270,7 @@ def create_general_tab(self, master): components.label(frame, 4, 2, "Debug Directory", tooltip="The directory where debug data is saved") - components.path_entry(frame, 4, 3, self.ui_state, "debug_dir", mode="dir") + components.path_entry(frame, 4, 3, self.ui_state, "debug_dir", mode="dir", io_type=PathIOType.OUTPUT) # tensorboard components.label(frame, 6, 0, "Tensorboard", From d58b3463d847b9fd91ca76bc42b6b274def6e93f Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:32:33 +1100 Subject: [PATCH 09/14] Make a few more fields required. --- modules/ui/TrainUI.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index faac79c14..2189f6faa 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -301,11 +301,11 @@ def create_general_tab(self, master): # device components.label(frame, 10, 0, "Dataloader Threads", tooltip="Number of threads used for the data loader. Increase if your GPU has room during caching, decrease if it's going out of memory during caching.") - components.entry(frame, 10, 1, self.ui_state, "dataloader_threads") + components.entry(frame, 10, 1, self.ui_state, "dataloader_threads", required=True) components.label(frame, 11, 0, "Train Device", tooltip="The device used for training. Can be \"cuda\", \"cuda:0\", \"cuda:1\" etc. Default:\"cuda\". Must be \"cuda\" for multi-GPU training.") - components.entry(frame, 11, 1, self.ui_state, "train_device") + components.entry(frame, 11, 1, self.ui_state, "train_device", required=True) components.label(frame, 12, 0, "Multi-GPU", tooltip="Enable multi-GPU training") From 2e47f21cdc966915a73021aa6bce2015fd2e9ce9 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:21:20 +1100 Subject: [PATCH 10/14] Add a validation specifically for resolution, add missing extra_validate param to path entry --- modules/ui/TrainingTab.py | 5 +-- modules/util/ui/components.py | 2 ++ modules/util/ui/validation_helpers.py | 48 +++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/modules/ui/TrainingTab.py b/modules/ui/TrainingTab.py index 284c3d604..c7efbecd2 100644 --- a/modules/ui/TrainingTab.py +++ b/modules/ui/TrainingTab.py @@ -16,7 +16,7 @@ from modules.util.optimizer_util import change_optimizer from modules.util.ui import components from modules.util.ui.UIState import UIState -from modules.util.ui.validation_helpers import check_range +from modules.util.ui.validation_helpers import check_range, validate_resolution import customtkinter as ctk @@ -402,7 +402,8 @@ def __create_base2_frame(self, master, row, video_training_enabled: bool = False # resolution components.label(frame, row, 0, "Resolution", tooltip="The resolution used for training. Optionally specify multiple resolutions separated by a comma, or a single exact resolution in the format x") - components.entry(frame, row, 1, self.ui_state, "resolution", required=True) + components.entry(frame, row, 1, self.ui_state, "resolution", required=True, + extra_validate=validate_resolution(self.train_config.model_type)) row += 1 # frames diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 14872627a..3525fd826 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -116,6 +116,7 @@ def path_entry( allow_model_files: bool = True, allow_image_files: bool = False, command: Callable[[str], None] | None = None, + extra_validate: Callable[[str], str | None] | None = None, required: bool = False, ): frame = ctk.CTkFrame(master, fg_color="transparent") @@ -129,6 +130,7 @@ def _path_validator_factory(comp, var, state, name, **kw): entry_component = entry( frame, row=0, column=0, ui_state=ui_state, var_name=var_name, validator_factory=_path_validator_factory, + extra_validate=extra_validate, required=required, ) diff --git a/modules/util/ui/validation_helpers.py b/modules/util/ui/validation_helpers.py index 12cdcc4d6..a2f7783d8 100644 --- a/modules/util/ui/validation_helpers.py +++ b/modules/util/ui/validation_helpers.py @@ -3,6 +3,51 @@ from collections.abc import Callable +def validate_resolution(model_type) -> Callable[[str], str | None]: + """Return a resolution validator bound to a specific model_type.""" + + def _check(value: str) -> str | None: + value = value.strip() + if not value: + return None + + multiple = 64 + if model_type.is_stable_diffusion(): + multiple = 8 + elif model_type.is_pixart() or model_type.is_flow_matching(): + multiple = 16 + elif model_type.is_sana() or model_type.is_qwen(): + multiple = 32 + elif model_type.is_wuerstchen(): + multiple = 128 + + dims = [] + + if 'x' in value: + parts = value.split('x') + if len(parts) == 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + dims = [int(parts[0].strip()), int(parts[1].strip())] + else: + return "Invalid format. Use x (e.g., 1024x768)" + + else: + parts = value.split(',') + if all(p.strip().isdigit() for p in parts): + dims = [int(p.strip()) for p in parts] + else: + return "Must be a single integer, x, or comma-separated integers" + + for d in dims: + if d <= 0: + return f"Resolution cannot be less than or equal to 0 (found {d})." + if d % multiple != 0: + return f"Dimensions must be multiples of {multiple} for {model_type.value} (found {d})." + + return None + + return _check + + def check_range( *, lower: float | None = None, @@ -11,8 +56,7 @@ def check_range( upper_inclusive: bool = True, message: str | None = None, ) -> Callable[[str], str | None]: - """Validate that a numeric value falls within specified range, by default both bounds are inclusive. - """ + """Validate that a numeric value falls within specified range, by default both bounds are inclusive.""" def _check(value: str) -> str | None: try: From 43a8733701131710d51b1ad59918b2db7d3c1bb5 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:29:22 +1100 Subject: [PATCH 11/14] Implement review suggestions --- modules/ui/AdditionalEmbeddingsTab.py | 3 +-- modules/ui/ConvertModelUI.py | 3 +-- modules/ui/LoraTab.py | 3 +-- modules/ui/ModelTab.py | 15 +++++++-------- modules/ui/TrainUI.py | 6 +++--- modules/ui/TrainingTab.py | 17 +++++++++++------ modules/util/ui/components.py | 13 ++++++++++--- 7 files changed, 34 insertions(+), 26 deletions(-) diff --git a/modules/ui/AdditionalEmbeddingsTab.py b/modules/ui/AdditionalEmbeddingsTab.py index bf929ce8b..6a5e3fbe7 100644 --- a/modules/ui/AdditionalEmbeddingsTab.py +++ b/modules/ui/AdditionalEmbeddingsTab.py @@ -1,4 +1,3 @@ -from pathlib import Path from modules.ui.ConfigList import ConfigList from modules.util.config.TrainConfig import TrainConfig, TrainEmbeddingConfig @@ -91,7 +90,7 @@ def __init__(self, master, element, i, open_command, remove_command, clone_comma tooltip="The base embedding to train on. Leave empty to create a new embedding") components.path_entry( top_frame, 0, 3, self.ui_state, "model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # placeholder diff --git a/modules/ui/ConvertModelUI.py b/modules/ui/ConvertModelUI.py index ff57344f5..5518f519a 100644 --- a/modules/ui/ConvertModelUI.py +++ b/modules/ui/ConvertModelUI.py @@ -1,5 +1,4 @@ import traceback -from pathlib import Path from uuid import uuid4 from modules.util import create @@ -85,7 +84,7 @@ def main_frame(self, master): tooltip="Filename, directory or hugging face repository of the base model") components.path_entry( master, 2, 1, self.ui_state, "input_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # output data type diff --git a/modules/ui/LoraTab.py b/modules/ui/LoraTab.py index 08a6d5507..1c73d90ce 100644 --- a/modules/ui/LoraTab.py +++ b/modules/ui/LoraTab.py @@ -1,4 +1,3 @@ -from pathlib import Path from modules.util.config.TrainConfig import TrainConfig from modules.util.enum.DataType import DataType @@ -67,7 +66,7 @@ def setup_lora(self, peft_type: PeftType): tooltip=f"The base {name} to train on. Leave empty to create a new {name}") entry = components.path_entry( master, 0, 1, self.ui_state, "lora_model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) entry.grid(row=0, column=1, columnspan=4) diff --git a/modules/ui/ModelTab.py b/modules/ui/ModelTab.py index 24a59e3e1..6ff98d086 100644 --- a/modules/ui/ModelTab.py +++ b/modules/ui/ModelTab.py @@ -1,4 +1,3 @@ -from pathlib import Path from modules.util import create from modules.util.config.TrainConfig import TrainConfig @@ -368,7 +367,7 @@ def __create_base_dtype_components(self, frame, row: int) -> int: tooltip="Filename, directory or Hugging Face repository of the base model") components.path_entry( frame, row, 1, self.ui_state, "base_model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # compile @@ -413,7 +412,7 @@ def __create_base_components( tooltip="Filename, directory or Hugging Face repository of the prior model") components.path_entry( frame, row, 1, self.ui_state, "prior.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # prior weight dtype @@ -431,7 +430,7 @@ def __create_base_components( tooltip="Can be used to override the transformer in the base model. Safetensors and GGUF files are supported, local and on Huggingface. If a GGUF file is used, the DataType must also be set to GGUF") components.path_entry( frame, row, 1, self.ui_state, "transformer.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # transformer weight dtype @@ -515,7 +514,7 @@ def __create_base_components( tooltip="Filename, directory or Hugging Face repository of the text encoder 4 model") components.path_entry( frame, row, 1, self.ui_state, "text_encoder_4.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # text encoder 4 weight dtype @@ -532,7 +531,7 @@ def __create_base_components( tooltip="Directory or Hugging Face repository of a VAE model in diffusers format. Can be used to override the VAE included in the base model. Using a safetensor VAE file will cause an error that the model cannot be loaded.") components.path_entry( frame, row, 1, self.ui_state, "vae.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # vae weight dtype @@ -551,7 +550,7 @@ def __create_effnet_encoder_components(self, frame, row: int): tooltip="Filename, directory or Hugging Face repository of the effnet encoder model") components.path_entry( frame, row, 1, self.ui_state, "effnet_encoder.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # effnet encoder weight dtype @@ -575,7 +574,7 @@ def __create_decoder_components( tooltip="Filename, directory or Hugging Face repository of the decoder model") components.path_entry( frame, row, 1, self.ui_state, "decoder.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # decoder weight dtype diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 2189f6faa..414e121f6 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -488,7 +488,7 @@ def embedding_tab(self, master): tooltip="The base embedding to train on. Leave empty to create a new embedding") components.path_entry( frame, 0, 1, self.ui_state, "embedding.model_name", - mode="file", path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # token count @@ -749,10 +749,10 @@ def start_training(self): errors = flush_and_validate_all() if errors: + bullet_list = "\n".join(f"• {e}" for e in errors) messagebox.showerror( "Cannot Start Training", - "Please fix the following errors before training:\n\n" - + "\n".join(f"• {e}" for e in errors), + f"Please fix the following errors before training:\n\n{bullet_list}", ) return diff --git a/modules/ui/TrainingTab.py b/modules/ui/TrainingTab.py index c7efbecd2..94df92ab8 100644 --- a/modules/ui/TrainingTab.py +++ b/modules/ui/TrainingTab.py @@ -301,7 +301,8 @@ def __create_base_frame(self, master, row): # learning rate min factor components.label(frame, 4, 0, "Learning Rate Min Factor", tooltip="Unit = float. Method = percentage. For a factor of 0.1, the final LR will be 10% of the initial LR. If the initial LR is 1e-4, the final LR will be 1e-5.") - components.entry(frame, 4, 1, self.ui_state, "learning_rate_min_factor", extra_validate=check_range(lower=0, upper=0.99, message="Learning rate min factor must be between 0 and 0.99")) + components.entry(frame, 4, 1, self.ui_state, "learning_rate_min_factor", + extra_validate=check_range(lower=0, upper=0.99, message="Learning rate min factor must be between 0 and 0.99")) # learning rate cycles components.label(frame, 5, 0, "Learning Rate Cycles", @@ -316,7 +317,7 @@ def __create_base_frame(self, master, row): # batch size components.label(frame, 7, 0, "Local Batch Size", tooltip="The batch size of one training step. If you use multiple GPUs, this is the batch size of each GPU (local batch size).") - components.entry(frame, 7, 1, self.ui_state, "batch_size", required=True, ) + components.entry(frame, 7, 1, self.ui_state, "batch_size", required=True) # accumulation steps components.label(frame, 8, 0, "Accumulation Steps", @@ -696,12 +697,14 @@ def __create_masked_frame(self, master, row): # unmasked probability components.label(frame, 1, 0, "Unmasked Probability", tooltip="When masked training is enabled, specifies the number of training steps done on unmasked samples") - components.entry(frame, 1, 1, self.ui_state, "unmasked_probability", extra_validate=check_range(lower=0, upper=1, message="Unmasked probability must be between 0 and 1")) + components.entry(frame, 1, 1, self.ui_state, "unmasked_probability", + extra_validate=check_range(lower=0, upper=1, message="Unmasked probability must be between 0 and 1")) # unmasked weight components.label(frame, 2, 0, "Unmasked Weight", tooltip="When masked training is enabled, specifies the loss weight of areas outside the masked region") - components.entry(frame, 2, 1, self.ui_state, "unmasked_weight", extra_validate=check_range(lower=0, upper=1, message="Unmasked weight must be between 0 and 1")) + components.entry(frame, 2, 1, self.ui_state, "unmasked_weight", + extra_validate=check_range(lower=0, upper=1, message="Unmasked weight must be between 0 and 1")) # normalize masked area loss components.label(frame, 3, 0, "Normalize Masked Area Loss", @@ -711,7 +714,8 @@ def __create_masked_frame(self, master, row): # masked prior preservation components.label(frame, 4, 0, "Masked Prior Preservation Weight", tooltip="Preserves regions outside the mask using the original untrained model output as a target. Only available for LoRA training. If enabled, use a low unmasked weight.") - components.entry(frame, 4, 1, self.ui_state, "masked_prior_preservation_weight", extra_validate=check_range(lower=0, upper=1, message="Masked prior preservation weight must be between 0 and 1")) + components.entry(frame, 4, 1, self.ui_state, "masked_prior_preservation_weight", + extra_validate=check_range(lower=0, upper=1, message="Masked prior preservation weight must be between 0 and 1")) # use custom conditioning image components.label(frame, 5, 0, "Custom Conditioning Image", @@ -769,7 +773,8 @@ def __create_loss_frame(self, master, row, supports_vb_loss: bool = False): if not self.train_config.model_type.is_flow_matching(): components.label(frame, row, 0, "Gamma", tooltip="Inverse strength of loss weighting. Range: 1-20, only applies to Min SNR and P2.") - components.entry(frame, row, 1, self.ui_state, "loss_weight_strength", extra_validate=check_range(lower=1, upper=20, message="Gamma must be between 1 and 20")) + components.entry(frame, row, 1, self.ui_state, "loss_weight_strength", + extra_validate=check_range(lower=1, upper=20, message="Gamma must be between 1 and 20")) row += 1 # Loss Scaler diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 3525fd826..20e8c9390 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -1,6 +1,7 @@ import contextlib import tkinter as tk from collections.abc import Callable +from pathlib import Path from tkinter import filedialog from typing import Any, Literal @@ -107,12 +108,17 @@ def new_destroy(): return component +def json_path_modifier(x: str | Path) -> Path: + x = Path(x).absolute() + return x.parent if x.suffix == ".json" else x + + def path_entry( master, row, column, ui_state: UIState, var_name: str, *, mode: Literal["file", "dir"] = "file", io_type: PathIOType = PathIOType.INPUT, - path_modifier: Callable[[str], str] | None = None, + path_modifier: Callable[[str], str | Path] | None = None, allow_model_files: bool = True, allow_image_files: bool = False, command: Callable[[str], None] | None = None, @@ -174,10 +180,11 @@ def __open_dialog(): if path_modifier: chosen = path_modifier(chosen) - ui_state.get_var(var_name).set(chosen) + chosen_str = str(chosen) + ui_state.get_var(var_name).set(chosen_str) if command: - command(chosen) + command(chosen_str) button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") From 8d6faf60be6e142865bcec96561c21e669c22d6c Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:43:19 +1100 Subject: [PATCH 12/14] Correct multiples logic --- modules/util/ui/validation_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/util/ui/validation_helpers.py b/modules/util/ui/validation_helpers.py index a2f7783d8..2ae006d63 100644 --- a/modules/util/ui/validation_helpers.py +++ b/modules/util/ui/validation_helpers.py @@ -14,10 +14,10 @@ def _check(value: str) -> str | None: multiple = 64 if model_type.is_stable_diffusion(): multiple = 8 - elif model_type.is_pixart() or model_type.is_flow_matching(): - multiple = 16 elif model_type.is_sana() or model_type.is_qwen(): multiple = 32 + elif model_type.is_pixart(): + multiple = 16 elif model_type.is_wuerstchen(): multiple = 128 From 4e8701935b966a956fdd873c3fdce5379c005005 Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:32:14 +1100 Subject: [PATCH 13/14] Address final review comments --- modules/ui/TrainUI.py | 1 + modules/ui/TrainingTab.py | 2 +- modules/util/config/TrainConfig.py | 8 +------- modules/util/ui/validation.py | 24 ++++++++++++++++-------- modules/util/ui/validation_helpers.py | 16 ++-------------- 5 files changed, 21 insertions(+), 30 deletions(-) diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 414e121f6..2b6cc6383 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -258,6 +258,7 @@ def create_general_tab(self, master): tooltip="Only populate the cache, without any training") components.switch(frame, 2, 3, self.ui_state, "only_cache") + # TODO: In Phase 4 rework the general tab. # prevent overwrites components.label(frame, 3, 0, "Prevent Overwrites", tooltip="When enabled, output paths that already exist on disk will be flagged as invalid to avoid accidental overwrites") diff --git a/modules/ui/TrainingTab.py b/modules/ui/TrainingTab.py index 94df92ab8..7928b6832 100644 --- a/modules/ui/TrainingTab.py +++ b/modules/ui/TrainingTab.py @@ -404,7 +404,7 @@ def __create_base2_frame(self, master, row, video_training_enabled: bool = False components.label(frame, row, 0, "Resolution", tooltip="The resolution used for training. Optionally specify multiple resolutions separated by a comma, or a single exact resolution in the format x") components.entry(frame, row, 1, self.ui_state, "resolution", required=True, - extra_validate=validate_resolution(self.train_config.model_type)) + extra_validate=validate_resolution()) row += 1 # frames diff --git a/modules/util/config/TrainConfig.py b/modules/util/config/TrainConfig.py index 81cc1c0bf..e0c74987f 100644 --- a/modules/util/config/TrainConfig.py +++ b/modules/util/config/TrainConfig.py @@ -570,7 +570,7 @@ class TrainConfig(BaseConfig): def __init__(self, data: list[(str, Any, type, bool)]): super().__init__( data, - config_version=11, + config_version=10, config_migrations={ 0: self.__migration_0, 1: self.__migration_1, @@ -582,7 +582,6 @@ def __init__(self, data: list[(str, Any, type, bool)]): 7: self.__migration_7, 8: self.__migration_8, 9: self.__migration_9, - 10: self.__migration_10, } ) @@ -802,11 +801,6 @@ def replace_dtype(part: str): return migrated_data - def __migration_10(self, data: dict) -> dict: - migrated_data = data.copy() - migrated_data.setdefault("prevent_overwrites", True) - return migrated_data - def weight_dtypes(self) -> ModelWeightDtypes: return ModelWeightDtypes( self.train_dtype, diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py index 110cd9d07..9f44de8eb 100644 --- a/modules/util/ui/validation.py +++ b/modules/util/ui/validation.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse +from modules.util.enum.ModelFormat import ModelFormat from modules.util.enum.PathIOType import PathIOType if TYPE_CHECKING: @@ -34,13 +35,6 @@ if _IS_WINDOWS: _INVALID_CHARS |= set('<>"|?*') -_FORMAT_EXTENSIONS: dict[str, str] = { - "CKPT": ".ckpt", - "SAFETENSORS": ".safetensors", - "LEGACY_SAFETENSORS": ".safetensors", - "COMFY_LORA": ".safetensors", -} - def _is_huggingface_repo_or_file(value: str) -> bool: trimmed = value.strip() @@ -102,6 +96,16 @@ def validate_path( if _has_invalid_chars(trimmed): return "Path contains invalid characters" + if trimmed.startswith("cloud:"): + cloud_path = trimmed[6:] + if not cloud_path: + return "Cloud path is empty" + if cloud_path.startswith(("http://", "https://")): + return "Cloud path cannot be a URL" + if "\\" in cloud_path: + return "Cloud path must use forward slashes (/)" + return None + if io_type == PathIOType.INPUT and _is_huggingface_repo_or_file(trimmed): return None @@ -119,7 +123,11 @@ def validate_path( return "Diffusers output must be a directory path, not a file" return _check_overwrite(trimmed, is_dir=True, prevent=prevent_overwrites) - expected_ext = _FORMAT_EXTENSIONS.get(output_format, "") + try: + expected_ext = ModelFormat[output_format].file_extension() + except KeyError: + expected_ext = "" + if expected_ext: suffix = (PureWindowsPath(trimmed) if _IS_WINDOWS else PurePosixPath(trimmed)).suffix.lower() if suffix != expected_ext: diff --git a/modules/util/ui/validation_helpers.py b/modules/util/ui/validation_helpers.py index 2ae006d63..1ae1c504e 100644 --- a/modules/util/ui/validation_helpers.py +++ b/modules/util/ui/validation_helpers.py @@ -3,24 +3,14 @@ from collections.abc import Callable -def validate_resolution(model_type) -> Callable[[str], str | None]: - """Return a resolution validator bound to a specific model_type.""" +def validate_resolution() -> Callable[[str], str | None]: + """Return a resolution validator.""" def _check(value: str) -> str | None: value = value.strip() if not value: return None - multiple = 64 - if model_type.is_stable_diffusion(): - multiple = 8 - elif model_type.is_sana() or model_type.is_qwen(): - multiple = 32 - elif model_type.is_pixart(): - multiple = 16 - elif model_type.is_wuerstchen(): - multiple = 128 - dims = [] if 'x' in value: @@ -40,8 +30,6 @@ def _check(value: str) -> str | None: for d in dims: if d <= 0: return f"Resolution cannot be less than or equal to 0 (found {d})." - if d % multiple != 0: - return f"Dimensions must be multiples of {multiple} for {model_type.value} (found {d})." return None From 9bce3b5096efbc2c082085cc323e4b0e96c51e3a Mon Sep 17 00:00:00 2001 From: O-J1 <18110006+O-J1@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:36:17 +1100 Subject: [PATCH 14/14] Make prevent overwrites disabled by default as compromise --- modules/util/config/TrainConfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/util/config/TrainConfig.py b/modules/util/config/TrainConfig.py index e0c74987f..306a5cfb2 100644 --- a/modules/util/config/TrainConfig.py +++ b/modules/util/config/TrainConfig.py @@ -954,7 +954,7 @@ def default_values() -> 'TrainConfig': data.append(("validate_after", 1, int, False)) data.append(("validate_after_unit", TimeUnit.EPOCH, TimeUnit, False)) data.append(("continue_last_backup", False, bool, False)) - data.append(("prevent_overwrites", True, bool, False)) + data.append(("prevent_overwrites", False, bool, False)) data.append(("include_train_config", ConfigPart.NONE, ConfigPart, False)) #multi-GPU