diff --git a/README.md b/README.md index 2bbb9c5..96d4b80 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,27 @@ Example workflows are available in the `example_workflows/` folder. In ComfyUI, The sidebar tabs (LLM Chat, Prompt Builder) will appear automatically in the ComfyUI interface. +## Logging Configuration + +SageUtils uses a dedicated logging system that is separate from ComfyUI's logging. You can control log verbosity using the `SAGEUTILS_LOG_LEVEL` environment variable: + +```bash +# Show detailed debug information +SAGEUTILS_LOG_LEVEL=DEBUG python main.py + +# Show only warnings and errors (quieter) +SAGEUTILS_LOG_LEVEL=WARNING python main.py + +# Default level is INFO (normal operation messages) +python main.py +``` + +Available log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` + +All SageUtils logs are prefixed with `[SageUtils.*]` for easy identification. + +For developers, see the [Logging Guide](docs/LOGGING.md) for information on using the logger in your code. + ## Documentation - 📖 [LLM Tab Guide](docs/LLM_TAB_GUIDE.md) - Complete guide to using the LLM chat interface diff --git a/__init__.py b/__init__.py index 50d004b..3043d43 100644 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,15 @@ import os -import logging -# Uncomment the next line to enable debug logging -# logging.basicConfig(level=logging.DEBUG) +# Initialize SageUtils logging infrastructure +from .utils.logger import configure_logging, configure_third_party_logging, get_logger + +# Configure SageUtils logger early in initialization +# Uses SAGEUTILS_LOG_LEVEL environment variable or defaults to INFO +configure_logging() +configure_third_party_logging() + +# Get logger for this module +logger = get_logger('init') SAGEUTILS_PRINT_TIMING = False # Set to True to enable timing report # Print timing report if enabled via environment variable @@ -55,8 +62,7 @@ try: from . import server_routes log_init("SERVER_ROUTES_LOADED") except Exception as e: - import logging - logging.warning(f"Warning: Failed to load SageUtils custom routes: {e}") + logger.warning(f"Failed to load SageUtils custom routes: {e}") log_init("SERVER_ROUTES_FAILED") WEB_DIRECTORY = "./js" @@ -70,7 +76,7 @@ try: from .utils.performance_fix import populate_llm_cache_async populate_llm_cache_async() except Exception as e: - logging.warning(f"Warning: Failed to start background LLM cache population: {e}") + logger.warning(f"Failed to start background LLM cache population: {e}") # Print timing report if enabled. if SAGEUTILS_PRINT_TIMING: diff --git a/nodes/selector_v3.py b/nodes/selector_v3.py index c544600..dd3e0c5 100644 --- a/nodes/selector_v3.py +++ b/nodes/selector_v3.py @@ -212,10 +212,22 @@ class Sage_FlexibleCLIPSelector(io.ComfyNode): io.Combo.Input(f"clip_name_{i}", display_name=f"clip_name_{i}", options=clip_list) ) # Add clip_type selector - default_type = "chroma" if num_clips == 1 else "sdxl" - clip_options[num_clips].append( - io.Combo.Input("clip_type", display_name="clip_type", options=mi.single_clip_loader_options, default=default_type) - ) + options = None + + if num_clips == 1: + options = mi.single_clip_loader_options + default_type = "chroma" + elif num_clips == 2: + options = mi.dual_clip_loader_options + default_type = "sdxl" + else: + options = None + default_type = "" + + if options: + clip_options[num_clips].append( + io.Combo.Input("clip_type", display_name="clip_type", options=options, default=default_type) + ) # Build dynamic combo options dynamic_options = [ @@ -250,6 +262,78 @@ class Sage_FlexibleCLIPSelector(io.ComfyNode): info = mi.get_model_info_clips(clip_names, clip_type) return io.NodeOutput(info) +class Sage_MultiSelectorFlexibleClip(io.ComfyNode): + @classmethod + def define_schema(cls): + unet_options = get_model_list("unet") + vae_options = get_model_list("vae") + clip_list = get_model_list("clip") + clip_options = {} + + # Build clip options for 1-4 clips + for num_clips in range(1, 5): + clip_options[num_clips] = [] + for i in range(1, num_clips + 1): + clip_options[num_clips].append( + io.Combo.Input(f"clip_name_{i}", display_name=f"clip_name_{i}", options=clip_list) + ) + # Add clip_type selector + options = None + + if num_clips == 1: + options = mi.single_clip_loader_options + default_type = "chroma" + elif num_clips == 2: + options = mi.dual_clip_loader_options + default_type = "sdxl" + else: + options = None + default_type = "" + + if options: + clip_options[num_clips].append( + io.Combo.Input("clip_type", display_name="clip_type", options=options, default=default_type) + ) + + # Build dynamic combo options + dynamic_options = [ + io.DynamicCombo.Option(str(num), list(clip_options[num])) + for num in range(1, 5) + ] + + return io.Schema( + node_id="Sage_MultiSelectorFlexibleClip", + display_name="Multi Selector Flexible CLIP", + description="Selects checkpoint, UNET, VAE, and a flexible number of CLIP models from lists.", + category="Sage Utils/selector", + inputs=[ + io.Combo.Input("unet_name", display_name="unet_name", options=unet_options), + io.Combo.Input("weight_dtype", display_name="weight_dtype", options=mi.weight_dtype_options, default="default"), + io.DynamicCombo.Input("num_of_clips", display_name="num_of_clips", options=dynamic_options), + io.Combo.Input("vae_name", display_name="vae_name", options=vae_options), + ], + outputs=[ + ModelInfo.Output("model_info", display_name="model_info") + ] + ) + + @classmethod + def execute(cls, **kwargs): + unet_name = kwargs.get("unet_name", "") + weight_dtype = kwargs.get("weight_dtype", "default") + vae_name = kwargs.get("vae_name", "") + + args = kwargs.get("num_of_clips", {}) + clip_names = [args.get(key, "") for key in sorted(args.keys()) if key.startswith("clip_name_")] + clip_type = kwargs.get("clip_type", "chroma") + + unet_info = mi.get_model_info_unet(unet_name, weight_dtype) + clip_info = mi.get_model_info_clips(clip_names, clip_type) + vae_info = mi.get_model_info_vae(vae_name) + + ret = (unet_info[0], clip_info[0], vae_info[0]) + return io.NodeOutput(ret,) + class Sage_MultiSelectorSingleClip(io.ComfyNode): @classmethod def define_schema(cls): @@ -311,13 +395,12 @@ class Sage_MultiSelectorDoubleClip(io.ComfyNode): def execute(cls, **kwargs): unet_name = kwargs.get("unet_name", "") weight_dtype = kwargs.get("weight_dtype", "default") - clip_name_1 = kwargs.get("clip_name_1", "") - clip_name_2 = kwargs.get("clip_name_2", "") + clip_name = [kwargs.get("clip_name_1", ""), kwargs.get("clip_name_2", "")] clip_type = kwargs.get("clip_type", "sdxl") vae_name = kwargs.get("vae_name", "") unet_info = mi.get_model_info_unet(unet_name, weight_dtype) - clip_info = mi.get_model_info_clips([clip_name_1, clip_name_2], clip_type) + clip_info = mi.get_model_info_clips(clip_name, clip_type) vae_info = mi.get_model_info_vae(vae_name) ret = (unet_info[0], clip_info[0], vae_info[0]) @@ -349,13 +432,11 @@ class Sage_MultiSelectorTripleClip(io.ComfyNode): def execute(cls, **kwargs): unet_name = kwargs.get("unet_name", "") weight_dtype = kwargs.get("weight_dtype", "default") - clip_name_1 = kwargs.get("clip_name_1", "") - clip_name_2 = kwargs.get("clip_name_2", "") - clip_name_3 = kwargs.get("clip_name_3", "") + clip_name = [kwargs.get("clip_name_1", ""), kwargs.get("clip_name_2", ""), kwargs.get("clip_name_3", "")] vae_name = kwargs.get("vae_name", "") unet_info = mi.get_model_info_unet(unet_name, weight_dtype) - clip_info = mi.get_model_info_clips([clip_name_1, clip_name_2, clip_name_3]) + clip_info = mi.get_model_info_clips(clip_name) vae_info = mi.get_model_info_vae(vae_name) ret = (unet_info[0], clip_info[0], vae_info[0]) @@ -388,14 +469,11 @@ class Sage_MultiSelectorQuadClip(io.ComfyNode): def execute(cls, **kwargs): unet_name = kwargs.get("unet_name", "") weight_dtype = kwargs.get("weight_dtype", "default") - clip_name_1 = kwargs.get("clip_name_1", "") - clip_name_2 = kwargs.get("clip_name_2", "") - clip_name_3 = kwargs.get("clip_name_3", "") - clip_name_4 = kwargs.get("clip_name_4", "") + clip_name = [kwargs.get("clip_name_1", ""), kwargs.get("clip_name_2", ""), kwargs.get("clip_name_3", ""), kwargs.get("clip_name_4", "")] vae_name = kwargs.get("vae_name", "") unet_info = mi.get_model_info_unet(unet_name, weight_dtype) - clip_info = mi.get_model_info_clips([clip_name_1, clip_name_2, clip_name_3, clip_name_4]) + clip_info = mi.get_model_info_clips(clip_name) vae_info = mi.get_model_info_vae(vae_name) ret = (unet_info[0], clip_info[0], vae_info[0]) @@ -946,6 +1024,7 @@ SELECTOR_NODES = [ Sage_MultiSelectorTripleClip, Sage_MultiSelectorQuadClip, Sage_FlexibleCLIPSelector, + Sage_MultiSelectorFlexibleClip, Sage_ModelShifts, Sage_ModelShiftOnly, Sage_FreeU2, diff --git a/utils/helpers.py b/utils/helpers.py index 51156a3..dcdddb1 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -334,7 +334,7 @@ def pull_metadata(file_paths, timestamp = True, force_all = False, pbar = None, force = force_all hash = cache.hash.get(str(file_path), None) if hash is None: - print(f"Hash not found in cache for {file_path}. Adding to cache.") + logging.debug(f"Hash not found in cache for {file_path}. Adding to cache.") hash = add_file_to_cache(file_path) file_cache = cache.by_path(file_path) @@ -344,7 +344,7 @@ def pull_metadata(file_paths, timestamp = True, force_all = False, pbar = None, modified = get_file_modification_date(file_path) # If file was modified after last used, force metadata pull if last_used_date is not None and modified is not None and modified > last_used_date: - print(f"File was modified after last used. Pulling metadata.") + logging.info(f"File was modified after last used. Pulling metadata.") force = True # Only skip pull if not forced and civitai is True and recently pulled @@ -361,16 +361,16 @@ def pull_metadata(file_paths, timestamp = True, force_all = False, pbar = None, if file_cache.get('blacklist'): if not force: - print(f"File {file_path} is blacklisted (previously not found). Skipping metadata pull.") + logging.warning(f"File {file_path} is blacklisted (previously not found). Skipping metadata pull.") pull_json = False # If force, recalculate hash before any API call if force: - print(f"Force flag is set. Recalculating hash for {file_path}.") + logging.debug(f"Force flag is set. Recalculating hash for {file_path}.") hash = recheck_hash(file_path, hash) if pull_json or force: - print(f"Currently pulling metadata for {file_path}.") + logging.debug(f"Currently pulling metadata for {file_path}.") json = get_civitai_model_version_json_by_hash(hash) if 'error' in json: @@ -383,15 +383,15 @@ def pull_metadata(file_paths, timestamp = True, force_all = False, pbar = None, # Try fallback with modelId if available if dead_model is False: if 'modelId' in file_cache: - print(f"Using cached model id {file_cache.get('id', None)}") + logging.debug(f"Using cached model id {file_cache.get('id', None)}") json = get_civitai_model_version_json_by_id(file_cache['id']) retried = True else: - print(f"No cached model id.") + logging.debug(f"No cached model id.") if 'error' in json: if retried: - print(f"Error: {json['error']}") + logging.error(f"Error: {json['error']}") if dead_model: file_cache['blacklist'] = True print(f"Unable to find on civitai.") @@ -669,14 +669,6 @@ def clean_keywords(keywords): return ', '.join(keywords) def clean_text(text): - # ret = ' '.join(filter(None, (x.strip() for x in text.split()))) - # ret = ', '.join(filter(None, (x.strip() for x in ret.split(',')))) - # ret = '\n'.join(filter(None, (x.strip() for x in ret.split('\n')))) - - - # # Strip whitespace from the start and end of text in parentheses - # ret = ' ('.join(part.strip() for part in ret.split('(')) - # ret = ')'.join(part.strip() for part in ret.split(')')) ret = normalize_prompt_weights(text) return ret diff --git a/utils/llm_wrapper.py b/utils/llm_wrapper.py index f2a51ac..0acbd7f 100644 --- a/utils/llm_wrapper.py +++ b/utils/llm_wrapper.py @@ -6,6 +6,8 @@ from .llm_cache import get_llm_cache _ollama_initialized = False _lmstudio_initialized = False +logging.getLogger('httpx').setLevel(logging.WARNING) + # Attempt to import ollama, if available. Set a flag if it is not available. try: import ollama @@ -13,6 +15,7 @@ try: ollama_client = None # Will be initialized in init_ollama ollama_logger = logging.getLogger('ollama') ollama_logger.setLevel(logging.ERROR) + except ImportError: ollama = None diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..b0d8403 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,185 @@ +""" +SageUtils logging infrastructure. +Provides a dedicated logger for all SageUtils operations. +""" +import logging +import os +from typing import Optional + +# Logger name for all SageUtils components +LOGGER_NAME = "SageUtils" + +# Default log level (can be overridden via environment variable) +DEFAULT_LOG_LEVEL = logging.INFO + +# Environment variable for log level control +LOG_LEVEL_ENV_VAR = "SAGEUTILS_LOG_LEVEL" + +# Valid log level names +LOG_LEVELS = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, +} + + +def get_logger(name: Optional[str] = None) -> logging.Logger: + """ + Get a logger instance for SageUtils. + + Args: + name: Optional sub-module name (e.g., 'cache', 'routes', 'helpers') + If provided, creates logger as 'SageUtils.{name}' + If None, returns the root SageUtils logger + + Returns: + Logger instance configured for SageUtils + + Example: + # In utils/helpers.py + logger = get_logger('helpers') + logger.info("Processing model...") # Logs as [SageUtils.helpers] + + # In routes/cache_routes.py + logger = get_logger('routes.cache') + logger.debug("Cache route called") # Logs as [SageUtils.routes.cache] + """ + if name: + logger_name = f"{LOGGER_NAME}.{name}" + else: + logger_name = LOGGER_NAME + + return logging.getLogger(logger_name) + + +def configure_logging(level: Optional[int] = None, handler: Optional[logging.Handler] = None): + """ + Configure the SageUtils logger with desired level and handler. + + This should be called once during initialization (__init__.py). + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + If None, reads from SAGEUTILS_LOG_LEVEL env var or uses DEFAULT_LOG_LEVEL + handler: Custom handler to use. If None, uses StreamHandler with standard formatting + + Notes: + - Only affects SageUtils logger, not ComfyUI's logging + - Sub-loggers (e.g., SageUtils.helpers) inherit this configuration + - Third-party library loggers can be configured separately + + Returns: + The configured root SageUtils logger + """ + # Get or create the root SageUtils logger + logger = logging.getLogger(LOGGER_NAME) + + # Determine log level + if level is None: + # Check environment variable + env_level = os.environ.get(LOG_LEVEL_ENV_VAR, "").upper() + level = LOG_LEVELS.get(env_level, DEFAULT_LOG_LEVEL) + + logger.setLevel(level) + + # Clear existing handlers to avoid duplicates + logger.handlers.clear() + + # Add handler + if handler is None: + handler = logging.StreamHandler() + formatter = logging.Formatter( + '[%(name)s] %(levelname)s: %(message)s' + ) + handler.setFormatter(formatter) + + logger.addHandler(handler) + + # Prevent propagation to root logger to avoid duplicate messages + logger.propagate = False + + return logger + + +def configure_third_party_logging(): + """ + Configure logging levels for third-party libraries used by SageUtils. + + Some libraries are very verbose and should have higher logging thresholds. + This function sets appropriate levels for known chatty libraries. + """ + # HTTP libraries + logging.getLogger('httpx').setLevel(logging.WARNING) + logging.getLogger('urllib3').setLevel(logging.WARNING) + + # LLM libraries (if available) + try: + logging.getLogger('ollama').setLevel(logging.ERROR) + except: + pass + + try: + logging.getLogger('lmstudio').setLevel(logging.ERROR) + except: + pass + + # Add more as needed + logging.getLogger('asyncio').setLevel(logging.WARNING) + + +def get_sageutils_logger() -> logging.Logger: + """ + Get the root SageUtils logger. + + This is a convenience function for getting the main logger. + For module-specific loggers, use get_logger(name) instead. + + Returns: + The root SageUtils logger + """ + return logging.getLogger(LOGGER_NAME) + + +def set_log_level(level: str): + """ + Dynamically change the log level for SageUtils logger. + + Args: + level: Log level name ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') + + Raises: + ValueError: If level is not a valid log level name + + Example: + set_log_level('DEBUG') # Enable debug logging + set_log_level('WARNING') # Only show warnings and errors + """ + level_upper = level.upper() + if level_upper not in LOG_LEVELS: + raise ValueError( + f"Invalid log level: {level}. " + f"Valid levels are: {', '.join(LOG_LEVELS.keys())}" + ) + + logger = logging.getLogger(LOGGER_NAME) + logger.setLevel(LOG_LEVELS[level_upper]) + + +def get_log_level() -> str: + """ + Get the current log level name for SageUtils logger. + + Returns: + Log level name ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') + """ + logger = logging.getLogger(LOGGER_NAME) + level = logger.level + + # Reverse lookup + for name, value in LOG_LEVELS.items(): + if value == level: + return name + + return "UNKNOWN" diff --git a/utils/model_info.py b/utils/model_info.py index 70f735c..7152223 100644 --- a/utils/model_info.py +++ b/utils/model_info.py @@ -7,8 +7,8 @@ from .model_cache import cache from .lora_stack import norm_lora_stack weight_dtype_options = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"] -single_clip_loader_options = ["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image"] -dual_clip_loader_options = ["sdxl", "sd3", "flux", "hunyuan_video", "hidream", "ace"] +single_clip_loader_options = ["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis"] +dual_clip_loader_options = ["sdxl", "sd3", "flux", "hunyuan_video", "hidream", "hunyuan_image", "hunyuan_video_15", "kandinsky5", "kandinsky5_image", "ltxv", "newbie", "ace"] def get_model_info_ckpt(ckpt_name: str) -> tuple: """