mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2025-12-15 01:27:05 +08:00
Compare commits
28 Commits
ec8d004839
...
ad64277f32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad64277f32 | ||
|
|
bba55d4d5a | ||
|
|
87111bd889 | ||
|
|
3661ffd3ab | ||
|
|
d8f111a5e3 | ||
|
|
ae5565ce68 | ||
|
|
e4c370a7d9 | ||
|
|
891005bcd3 | ||
|
|
d3a4a7a0fa | ||
|
|
10211d1a93 | ||
|
|
7f019a932b | ||
|
|
fae909de2f | ||
|
|
d8455ef6e5 | ||
|
|
934c994783 | ||
|
|
d0961d596d | ||
|
|
382df24764 | ||
|
|
bfcfa42125 | ||
|
|
2333886c34 | ||
|
|
0cdad3c886 | ||
|
|
eee23c543b | ||
|
|
f0a8812f5e | ||
|
|
a8d603f753 | ||
|
|
22acaa1d2c | ||
|
|
fe791ccee9 | ||
|
|
414557eee0 | ||
|
|
97d2741360 | ||
|
|
b95e5f1eae | ||
|
|
da87651e53 |
@ -14868,6 +14868,16 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A collection of utility nodes for lora operations in ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "lrzjason",
|
||||
"title": "Comfyui-LatentUtils",
|
||||
"reference": "https://github.com/lrzjason/Comfyui-LatentUtils",
|
||||
"files": [
|
||||
"https://github.com/lrzjason/Comfyui-LatentUtils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node performing selective latent denoising and detail enhancement using Fourier Transform (FFT) to separate and enhance image frequencies while suppressing noise. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "cozy_comm",
|
||||
"title": "Cozy Communication",
|
||||
@ -19172,16 +19182,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom ComfyUI node using Together AI's Vision models for free image descriptions, image generation, and image-to-image transformation. Features include customizable prompts, advanced parameters, and robust error handling."
|
||||
},
|
||||
{
|
||||
"author": "jeffrey2212",
|
||||
"title": "Pony Character Prompt Picker for ComfyUI",
|
||||
"reference": "https://github.com/jeffrey2212/ComfyUI-PonyCharacterPrompt",
|
||||
"files": [
|
||||
"https://github.com/jeffrey2212/ComfyUI-PonyCharacterPrompt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "The Pony Character Prompt Picker node reads an Excel file specified by the user, allows manual selection of a tab, and randomly picks a cell value from a specified column, starting from row 3 to the end. The selected value is output as a string to the next node in the ComfyUI workflow."
|
||||
},
|
||||
{
|
||||
"author": "theshubzworld",
|
||||
"title": "ComfyUI-FaceCalloutNode",
|
||||
@ -19192,6 +19192,36 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A collection of custom nodes for ComfyUI that provide advanced face callout, annotation, and compositing effects using OpenCV and PIL. These nodes are designed for image processing workflows that require face detection, annotation, and creative compositing."
|
||||
},
|
||||
{
|
||||
"author": "theshubzworld",
|
||||
"title": "ComfyUI-NvidiaCaptioner",
|
||||
"reference": "https://github.com/theshubzworld/ComfyUI-NvidiaCaptioner",
|
||||
"files": [
|
||||
"https://github.com/theshubzworld/ComfyUI-NvidiaCaptioner"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI node for generating rich, detailed captions for images using NVIDIA's vision models. Supports batch processing, multiple captioning styles, and includes built-in caching for efficient workflows."
|
||||
},
|
||||
{
|
||||
"author": "theshubzworld",
|
||||
"title": "ComfyUI-Universal-Latent",
|
||||
"reference": "https://github.com/theshubzworld/ComfyUI-Universal-Latent",
|
||||
"files": [
|
||||
"https://github.com/theshubzworld/ComfyUI-Universal-Latent"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Enhanced empty latent node with extended aspect ratio support for ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "jeffrey2212",
|
||||
"title": "Pony Character Prompt Picker for ComfyUI",
|
||||
"reference": "https://github.com/jeffrey2212/ComfyUI-PonyCharacterPrompt",
|
||||
"files": [
|
||||
"https://github.com/jeffrey2212/ComfyUI-PonyCharacterPrompt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "The Pony Character Prompt Picker node reads an Excel file specified by the user, allows manual selection of a tab, and randomly picks a cell value from a specified column, starting from row 3 to the end. The selected value is output as a string to the next node in the ComfyUI workflow."
|
||||
},
|
||||
{
|
||||
"author": "Jonseed",
|
||||
"title": "ComfyUI-Detail-Daemon",
|
||||
@ -20190,10 +20220,9 @@
|
||||
{
|
||||
"author": "Black-Lioness",
|
||||
"title": "ComfyUI-PromptUtils",
|
||||
"reference": "https://github.com/Black-Lioness/ComfyUI-PromptUtils",
|
||||
"reference2": "https://github.com/RunningOverGlowies/ComfyUI-PromptUtils",
|
||||
"reference": "https://github.com/RunningOverGlowies/ComfyUI-PromptUtils",
|
||||
"files": [
|
||||
"https://github.com/Black-Lioness/ComfyUI-PromptUtils"
|
||||
"https://github.com/RunningOverGlowies/ComfyUI-PromptUtils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A set of ComfyUI nodes designed to enhance your workflow with realistic filename generation and keyword generation."
|
||||
@ -22734,6 +22763,16 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Calculates the percentage of a mask area compared to the total image size and outputs a boolean based on a defined threshold."
|
||||
},
|
||||
{
|
||||
"author": "a-und-b",
|
||||
"title": "ComfyUI_AB_Wildcard",
|
||||
"reference": "https://github.com/a-und-b/ComfyUI_AB_Wildcard",
|
||||
"files": [
|
||||
"https://github.com/a-und-b/ComfyUI_AB_Wildcard"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simple node for advanced wildcard text processing. Supports variables, conditionals, tag aggregation, weighted selection, and deep nesting. Lightweight, fast, zero external dependencies."
|
||||
},
|
||||
{
|
||||
"author": "r3dial",
|
||||
"title": "Redial Discomphy - Discord Integration for ComfyUI",
|
||||
@ -25682,16 +25721,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Comfyui-raw-image provides the ability to load raw image files for ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "DiffusionWave",
|
||||
"title": "PickResolution_DiffusionWave",
|
||||
"reference": "https://github.com/DiffusionWave/PickResolution_DiffusionWave",
|
||||
"files": [
|
||||
"https://github.com/DiffusionWave/PickResolution_DiffusionWave"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that allows selecting a base resolution, applying a custom scaling value based on FLOAT (up to 10 decimal places), and adding an extra integer value. Outputs include both INT and FLOAT resolutions, making it perfect for you to play around with."
|
||||
},
|
||||
{
|
||||
"author": "Zar4X",
|
||||
"title": "ComfyUI-Batch-Process",
|
||||
@ -29356,16 +29385,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Load images with automatic prompt extraction from Civitai URLs, caption files, or EXIF metadata. Features smart dataset detection and dynamic preview updates."
|
||||
},
|
||||
{
|
||||
"author": "LargeModGames",
|
||||
"title": "ComfyUI LoRA Auto Downloader",
|
||||
"reference": "https://github.com/LargeModGames/comfyui-smart-lora-downloader",
|
||||
"files": [
|
||||
"https://github.com/LargeModGames/comfyui-smart-lora-downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Automatically download missing LoRAs from CivitAI and detect missing LoRAs in workflows. Features smart directory detection and easy installation."
|
||||
},
|
||||
{
|
||||
"author": "benjamin-bertram",
|
||||
"title": "ComfyUI OIDN Denoiser",
|
||||
@ -29583,6 +29602,17 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Stylized RPG character prompt generator for ComfyUI. Supports standard and Ollama-based prompts, works with SD, SDXL, Flux, and more."
|
||||
},
|
||||
{
|
||||
"author": "Lord Lethris",
|
||||
"title": "Dia2 TTS & Captions Generators for ComfyUI",
|
||||
"id": "dia2_tts_captions",
|
||||
"reference": "https://github.com/lord-lethris/ComfyUI-lethris-dia2",
|
||||
"files": [
|
||||
"https://github.com/lord-lethris/ComfyUI-lethris-dia2"
|
||||
],
|
||||
"install_type": "Git-Clone",
|
||||
"description": "This package provides two ComfyUI nodes: 🗣️ Dia2 TTS Generator for text-to-speech using Dia2-2B, and 💬 Dia2 Captions Generator to convert TTS timestamps into SRT/SSA/VTT subtitles. Includes example workflow and voice samples. GPU users require CUDA 12.8+."
|
||||
},
|
||||
{
|
||||
"author": "ialhabbal",
|
||||
"title": "OcclusionMask",
|
||||
@ -30507,6 +30537,16 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "An advanced image stitching node for ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "RamonGuthrie",
|
||||
"title": "ComfyUI-RBG-SmartSeedVariance",
|
||||
"reference": "https://github.com/RamonGuthrie/ComfyUI-RBG-SmartSeedVariance",
|
||||
"files": [
|
||||
"https://github.com/RamonGuthrie/ComfyUI-RBG-SmartSeedVariance"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Advanced seed diversity enhancement for ComfyUI with intelligent noise injection and directional biasing."
|
||||
},
|
||||
{
|
||||
"author": "vrgamegirl19",
|
||||
"title": "VRGameDevGirl Video Enhancement Nodes",
|
||||
@ -34069,16 +34109,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "This is a custom node for ComfyUI that provides a dynamic 'Switch' for routing purposes. It allows you to define a list of named labels and select one, outputting the corresponding index and label name. This is useful for controlling the flow of your workflow based on a selection."
|
||||
},
|
||||
{
|
||||
"author": "mcrataobrabo",
|
||||
"title": "comfyui-smart-lora-downloader - Automatically Fetch Missing LoRAs",
|
||||
"reference": "https://github.com/mcrataobrabo/comfyui-smart-lora-downloader",
|
||||
"files": [
|
||||
"https://github.com/mcrataobrabo/comfyui-smart-lora-downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Automatically detect and download missing LoRAs for ComfyUI workflows"
|
||||
},
|
||||
{
|
||||
"author": "3dgopnik",
|
||||
"title": "ComfyUI Arena Suite",
|
||||
@ -36752,6 +36782,16 @@
|
||||
"description": "Enhance first and last frames for smooth video loop generation in ComfyUI. Based on WAN Video workflow.",
|
||||
"tags": ["video", "frame", "loop", "workflow"]
|
||||
},
|
||||
{
|
||||
"author": "princepainter",
|
||||
"title": "Comfyui-PainterAudioLength",
|
||||
"reference": "https://github.com/princepainter/Comfyui-PainterAudioLength",
|
||||
"files": [
|
||||
"https://github.com/princepainter/Comfyui-PainterAudioLength"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A tool for calculating audio duration that takes audio input and outputs the audio length as a floating-point number. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "rafacost",
|
||||
"title": "rafacostComfy",
|
||||
@ -37638,16 +37678,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for loading images and drawing polygon masks interactively on them"
|
||||
},
|
||||
{
|
||||
"author": "cdanielp",
|
||||
"title": "COMFYUI_PROMPTMODELS",
|
||||
"reference": "https://github.com/cdanielp/COMFYUI_PROMPTMODELS",
|
||||
"files": [
|
||||
"https://github.com/cdanielp/COMFYUI_PROMPTMODELS"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI by PROMPTMODELS."
|
||||
},
|
||||
{
|
||||
"author": "supElement",
|
||||
"title": "ComfyUI_Element_easy",
|
||||
@ -37776,6 +37806,7 @@
|
||||
"https://github.com/fredlef/Comfyui_FSL_Nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes: FSLGeminiChat, FSLGeminiGenerateImage, Transparent Background helpers, and more." ,
|
||||
"tags": ["image", "chat", "gemini", "fsl"]
|
||||
},
|
||||
{
|
||||
@ -37807,6 +37838,9 @@
|
||||
"title": "Img Label Tools",
|
||||
"id": "Img-Label-Tools",
|
||||
"reference": "https://github.com/rjgoif/ComfyUI-Img-Label-Tools",
|
||||
"files": [
|
||||
"https://github.com/rjgoif/ComfyUI-Img-Label-Tools"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Tools to help annotate images for sharing on Reddit, Discord, etc."
|
||||
},
|
||||
@ -37861,6 +37895,17 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Professional audio processing and mastering suite for ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "jeankassio",
|
||||
"title": "JK AceStep Nodes",
|
||||
"id": "JK-AceStep-Nodes",
|
||||
"reference": "https://github.com/jeankassio/JK-AceStep-Nodes",
|
||||
"files": [
|
||||
"https://github.com/jeankassio/JK-AceStep-Nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Advanced nodes optimized for ACE-Step audio generation in ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "ameyukisora",
|
||||
"title": "ComfyUI Empty Latent Advanced",
|
||||
@ -38000,6 +38045,16 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Two simple nodes that help you author sequence-friendly prompts from a single script that contains multiple clips. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "TuonoMindCode",
|
||||
"title": "ComfyUI-Resolution-Suggest-Downscale",
|
||||
"reference": "https://github.com/TuonoMindCode/ComfyUI-Resolution-Suggest-Downscale",
|
||||
"files": [
|
||||
"https://github.com/TuonoMindCode/ComfyUI-Resolution-Suggest-Downscale"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Resolution suggestion and downscale helper node for ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "sooxt98",
|
||||
"title": "comfyui_longcat_image",
|
||||
@ -38190,16 +38245,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A suite of powerful and versatile utility nodes for ComfyUI, designed to streamline complex workflows involving Large Language Models and text manipulation."
|
||||
},
|
||||
{
|
||||
"author": "KANAsho34636",
|
||||
"title": "ComfyUI-NaturalSort-ImageLoader",
|
||||
"reference": "https://github.com/KANAsho34636/ComfyUI-NaturalSort-ImageLoader",
|
||||
"files": [
|
||||
"https://github.com/KANAsho34636/ComfyUI-NaturalSort-ImageLoader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom image loader node supporting natural number sorting with multiple sort modes (natural, lexicographic, modification time, creation time, reverse natural). (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "systemaiofinterest-wq",
|
||||
"title": "ComfyUI-MetaAI",
|
||||
@ -38220,16 +38265,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI model loader that uses the fastsafetensors library to perform very fast, zero-copy loading from storage to VRAM."
|
||||
},
|
||||
{
|
||||
"author": "johninthewinter",
|
||||
"title": "comfyui-fal-flux-2-John",
|
||||
"reference": "https://github.com/johninthewinter/comfyui-fal-flux-2-John",
|
||||
"files": [
|
||||
"https://github.com/johninthewinter/comfyui-fal-flux-2-John"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI that integrate with fal.ai's FLUX 2 and FLUX 1 LoRA APIs for text-to-image generation."
|
||||
},
|
||||
{
|
||||
"author": "Merserk",
|
||||
"title": "ComfyUI-Flow-Assistor",
|
||||
@ -38240,16 +38275,6 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Essential utility nodes for ComfyUI including prompt queue, batch processing, camera angle control, and resolution selector. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Zeknes",
|
||||
"title": "Comfyui-LLM-Chat",
|
||||
"reference": "https://github.com/Zeknes/Comfyui-LLM-Chat",
|
||||
"files": [
|
||||
"https://github.com/Zeknes/Comfyui-LLM-Chat"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node providing unified access to multiple Large Language Models including OpenAI-compatible APIs and local Ollama instances with image support."
|
||||
},
|
||||
{
|
||||
"author": "dandancow874",
|
||||
"title": "ComfyUI-LMStudio-Controller",
|
||||
@ -38280,8 +38305,169 @@
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node for generating consistent character images using Ideogram API v3's character reference feature. (Description by CC)"
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"author": "Moeblack",
|
||||
"title": "ComfyUI-SimpleChat",
|
||||
"reference": "https://github.com/Moeblack/ComfyUI-SimpleChat",
|
||||
"files": [
|
||||
"https://github.com/Moeblack/ComfyUI-SimpleChat"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simple, no-nonsense LLM chat nodes for ComfyUI. Support OpenAI, Claude, Gemini and NoASS Roleplay.",
|
||||
"tags": [
|
||||
"LLM",
|
||||
"chat",
|
||||
"openai",
|
||||
"claude",
|
||||
"gemini",
|
||||
"roleplay",
|
||||
"noass"
|
||||
]
|
||||
},
|
||||
{
|
||||
"author": "Braeden90000",
|
||||
"title": "ComfyUI Load Image URL",
|
||||
"id": "load-image-url",
|
||||
"reference": "https://github.com/Braeden90000/comfyui-load-image-url",
|
||||
"files": [
|
||||
"https://github.com/Braeden90000/comfyui-load-image-url"
|
||||
],
|
||||
"pip": ["requests"],
|
||||
"install_type": "git-clone",
|
||||
"description": "Load images from files or URLs with live preview and source switching."
|
||||
},
|
||||
{
|
||||
"author": "lovelybbq",
|
||||
"title": "ComfyUI Custom Node Color",
|
||||
"reference": "https://github.com/lovelybbq/comfyui-custom-node-color",
|
||||
"files": [
|
||||
"https://github.com/lovelybbq/comfyui-custom-node-color"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A modern GUI-based color picker for ComfyUI nodes. Features visual spectrum, HEX/RGB inputs, eyedropper tool, and favorite colors support."
|
||||
},
|
||||
{
|
||||
"author": "huihuihuiz",
|
||||
"title": "LoRA Downloader for ComfyUI",
|
||||
"id": "lora_downloader",
|
||||
"reference": "https://github.com/huihuihuiz/lora_downloader",
|
||||
"files": [
|
||||
"https://github.com/huihuihuiz/lora_downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node for downloading and managing LoRA models directly within the UI."
|
||||
},
|
||||
{
|
||||
"author": "aTanguay",
|
||||
"title": "ComfyUI_Detonate",
|
||||
"reference": "https://github.com/aTanguay/ComfyUI_Detonate",
|
||||
"files": [
|
||||
"https://github.com/aTanguay/ComfyUI_Detonate"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Professional compositing nodes for ComfyUI - bringing Nuke and Fusion workflows to AI-powered image generation"
|
||||
},
|
||||
{
|
||||
"author": "akaugun",
|
||||
"title": "comfyui-lora-hook-trigger",
|
||||
"reference": "https://github.com/akaugun/comfyui-lora-hook-trigger",
|
||||
"files": [
|
||||
"https://github.com/akaugun/comfyui-lora-hook-trigger"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A clean and simple ComfyUI custom node that creates a LoRA Hook and automatically finds and loads trigger TXT files placed in a folder named after the LoRA file."
|
||||
},
|
||||
{
|
||||
"author": "tumbowungus",
|
||||
"title": "MultiMaskCouple",
|
||||
"reference": "https://github.com/tumbowungus/MultiMaskCouple",
|
||||
"files": [
|
||||
"https://github.com/tumbowungus/MultiMaskCouple"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI which simplifies the process of masking multiple prompts, making it easier to manage scenes with multiple distinct characters."
|
||||
},
|
||||
{
|
||||
"author": "fudosanit",
|
||||
"title": "ComfyUI-Random-Resolution",
|
||||
"reference": "https://github.com/fudosanit/ComfyUI-Random-Resolution",
|
||||
"files": [
|
||||
"https://github.com/fudosanit/ComfyUI-Random-Resolution"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI. Based on the specified resolution, it randomly selects and outputs one of three patterns: original orientation, swapped width/height, or a square format. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "SuLU-K",
|
||||
"title": "comfyui-easy-sam3-tools",
|
||||
"reference": "https://github.com/SuLU-K/comfyui-easy-sam3-tools",
|
||||
"files": [
|
||||
"https://github.com/SuLU-K/comfyui-easy-sam3-tools"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for building SAM3-centric editing pipelines inside ComfyUI, featuring mask merging, bounding box rendering, and DOM-based interactive editing. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "EMkrtchyan",
|
||||
"title": "ComfyUI-NormalsToDepth",
|
||||
"reference": "https://github.com/EMkrtchyan/ComfyUI-NormalsToDepth",
|
||||
"files": [
|
||||
"https://github.com/EMkrtchyan/ComfyUI-NormalsToDepth"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Converts normal maps to depth maps for use in ComfyUI. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "SKFRMSEHF",
|
||||
"title": "SK-ComfyUI-FolderingLoader",
|
||||
"id": "skloader",
|
||||
"reference": "https://github.com/SKFRMSEHF/comfyui_SK_Loader",
|
||||
"files": [
|
||||
"https://github.com/SKFRMSEHF/comfyui_SK_Loader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Unified loader nodes with folder organization support for checkpoints, LoRA, VAE, and diffusion models"
|
||||
},
|
||||
{
|
||||
"author": "dcyd-lun",
|
||||
"title": "ComfyUI_text_diff",
|
||||
"reference": "https://github.com/dcyd-lun/ComfyUI_text_diff",
|
||||
"files": [
|
||||
"https://github.com/dcyd-lun/ComfyUI_text_diff"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Compare two texts with GitHub/GitLab-style diff highlighting in ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "darrell-goh",
|
||||
"title": "ComfyUI-NanoBanana_node",
|
||||
"reference": "https://github.com/darrell-goh/ComfyUI-NanoBanana_node",
|
||||
"files": [
|
||||
"https://github.com/darrell-goh/ComfyUI-NanoBanana_node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that allows you to interact with Nano Banana's Vertex API proxy, providing access to Gemini models with dynamic image inputs."
|
||||
},
|
||||
{
|
||||
"author": "This-is-Uncut",
|
||||
"title": "Custom-Node-ComfyUI-NBP-Uncut",
|
||||
"reference": "https://github.com/This-is-Uncut/Custom-Node-ComfyUI-NBP-Uncut",
|
||||
"files": [
|
||||
"https://github.com/This-is-Uncut/Custom-Node-ComfyUI-NBP-Uncut"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Node to use NBP with custom API key to improve data privacy control."
|
||||
},
|
||||
{
|
||||
"author": "drawthingsai",
|
||||
"title": "draw-things-comfyui",
|
||||
"reference": "https://github.com/drawthingsai/draw-things-comfyui",
|
||||
"files": [
|
||||
"https://github.com/drawthingsai/draw-things-comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "The official Draw Things extension for ComfyUI. Sends image-generation requests to Draw Things over gRPC. Supports Bridge Mode for DT+ cloud and local model execution."
|
||||
},
|
||||
|
||||
|
||||
|
||||
@ -38696,6 +38882,6 @@
|
||||
],
|
||||
"install_type": "unzip",
|
||||
"description": "This is a node to convert an image into a CMYK Halftone dot image."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,7 @@ import subprocess
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import git
|
||||
import json
|
||||
@ -219,7 +220,14 @@ def gitpull(path):
|
||||
repo.close()
|
||||
return
|
||||
|
||||
remote.pull()
|
||||
try:
|
||||
repo.git.pull('--ff-only')
|
||||
except git.GitCommandError:
|
||||
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
|
||||
repo.create_head(backup_name)
|
||||
print(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
|
||||
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
|
||||
print(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
|
||||
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
new_commit_hash = repo.head.commit.hexsha
|
||||
|
||||
22226
github-stats-cache.json
Normal file
22226
github-stats-cache.json
Normal file
File diff suppressed because it is too large
Load Diff
8853
github-stats.json
8853
github-stats.json
File diff suppressed because it is too large
Load Diff
@ -44,7 +44,7 @@ import manager_migration
|
||||
from node_package import InstalledNodePackage
|
||||
|
||||
|
||||
version_code = [3, 38, 1]
|
||||
version_code = [3, 38, 3]
|
||||
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
|
||||
|
||||
|
||||
@ -2253,9 +2253,17 @@ def git_pull(path):
|
||||
|
||||
current_branch = repo.active_branch
|
||||
remote_name = current_branch.tracking_branch().remote_name
|
||||
remote = repo.remote(name=remote_name)
|
||||
|
||||
remote.pull()
|
||||
try:
|
||||
repo.git.pull('--ff-only')
|
||||
except git.GitCommandError:
|
||||
branch_name = current_branch.name
|
||||
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
|
||||
repo.create_head(backup_name)
|
||||
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
|
||||
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
|
||||
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
|
||||
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
|
||||
repo.close()
|
||||
@ -2523,22 +2531,22 @@ def update_to_stable_comfyui(repo_path):
|
||||
logging.error('\t'+branch.name)
|
||||
return "fail", None
|
||||
|
||||
versions, current_tag, _ = get_comfyui_versions(repo)
|
||||
|
||||
if len(versions) == 0 or (len(versions) == 1 and versions[0] == 'nightly'):
|
||||
versions, current_tag, latest_tag = get_comfyui_versions(repo)
|
||||
|
||||
if latest_tag is None:
|
||||
logging.info("[ComfyUI-Manager] Unable to update to the stable ComfyUI version.")
|
||||
return "fail", None
|
||||
|
||||
if versions[0] == 'nightly':
|
||||
latest_tag = versions[1]
|
||||
else:
|
||||
latest_tag = versions[0]
|
||||
|
||||
if current_tag == latest_tag:
|
||||
tag_ref = next((t for t in repo.tags if t.name == latest_tag), None)
|
||||
if tag_ref is None:
|
||||
logging.info(f"[ComfyUI-Manager] Unable to locate tag '{latest_tag}' in repository.")
|
||||
return "fail", None
|
||||
|
||||
if repo.head.commit == tag_ref.commit:
|
||||
return "skip", None
|
||||
else:
|
||||
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
|
||||
repo.git.checkout(latest_tag)
|
||||
repo.git.checkout(tag_ref.name)
|
||||
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
|
||||
return 'updated', latest_tag
|
||||
except:
|
||||
@ -3362,36 +3370,80 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
|
||||
|
||||
|
||||
def get_comfyui_versions(repo=None):
|
||||
if repo is None:
|
||||
repo = git.Repo(comfy_path)
|
||||
repo = repo or git.Repo(comfy_path)
|
||||
|
||||
remote_name = None
|
||||
try:
|
||||
remote = get_remote_name(repo)
|
||||
repo.remotes[remote].fetch()
|
||||
remote_name = get_remote_name(repo)
|
||||
repo.remotes[remote_name].fetch()
|
||||
except:
|
||||
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
|
||||
|
||||
versions = [x.name for x in repo.tags if x.name.startswith('v')]
|
||||
def parse_semver(tag_name):
|
||||
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', tag_name)
|
||||
return tuple(int(x) for x in match.groups()) if match else None
|
||||
|
||||
# nearest tag
|
||||
versions = sorted(versions, key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
|
||||
versions = versions[:4]
|
||||
def normalize_describe(tag_name):
|
||||
if not tag_name:
|
||||
return None
|
||||
base = tag_name.split('-', 1)[0]
|
||||
return base if parse_semver(base) else None
|
||||
|
||||
current_tag = repo.git.describe('--tags')
|
||||
# Collect semver tags and sort descending (highest first)
|
||||
semver_tags = []
|
||||
for tag in repo.tags:
|
||||
semver = parse_semver(tag.name)
|
||||
if semver:
|
||||
semver_tags.append((semver, tag.name))
|
||||
semver_tags.sort(key=lambda x: x[0], reverse=True)
|
||||
semver_tags = [name for _, name in semver_tags]
|
||||
|
||||
if current_tag not in versions:
|
||||
versions = sorted(versions + [current_tag], key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
|
||||
versions = versions[:4]
|
||||
latest_tag = semver_tags[0] if semver_tags else None
|
||||
|
||||
main_branch = repo.heads.master
|
||||
latest_commit = main_branch.commit
|
||||
latest_tag = repo.git.describe('--tags', latest_commit.hexsha)
|
||||
try:
|
||||
described = repo.git.describe('--tags')
|
||||
except Exception:
|
||||
described = ''
|
||||
|
||||
if latest_tag != versions[0]:
|
||||
versions.insert(0, 'nightly')
|
||||
else:
|
||||
versions[0] = 'nightly'
|
||||
try:
|
||||
exact_tag = repo.git.describe('--tags', '--exact-match')
|
||||
except Exception:
|
||||
exact_tag = ''
|
||||
|
||||
head_is_default = False
|
||||
if remote_name:
|
||||
try:
|
||||
default_head_ref = repo.refs[f'{remote_name}/HEAD']
|
||||
default_commit = default_head_ref.reference.commit
|
||||
head_is_default = repo.head.commit == default_commit
|
||||
except Exception:
|
||||
head_is_default = False
|
||||
|
||||
nearest_semver = normalize_describe(described)
|
||||
exact_semver = exact_tag if parse_semver(exact_tag) else None
|
||||
|
||||
if head_is_default and not exact_tag:
|
||||
current_tag = 'nightly'
|
||||
else:
|
||||
current_tag = exact_tag or described or 'nightly'
|
||||
|
||||
# Prepare semver list for display: top 4 plus the current/nearest semver if missing
|
||||
display_semver_tags = semver_tags[:4]
|
||||
if exact_semver and exact_semver not in display_semver_tags:
|
||||
display_semver_tags.append(exact_semver)
|
||||
elif nearest_semver and nearest_semver not in display_semver_tags:
|
||||
display_semver_tags.append(nearest_semver)
|
||||
|
||||
versions = ['nightly']
|
||||
|
||||
if current_tag and not exact_semver and current_tag not in versions and current_tag not in display_semver_tags:
|
||||
versions.append(current_tag)
|
||||
|
||||
for tag in display_semver_tags:
|
||||
if tag not in versions:
|
||||
versions.append(tag)
|
||||
|
||||
versions = versions[:6]
|
||||
|
||||
return versions, current_tag, latest_tag
|
||||
|
||||
|
||||
273
json-checker.py
273
json-checker.py
@ -1,25 +1,264 @@
|
||||
import json
|
||||
import argparse
|
||||
#!/usr/bin/env python3
|
||||
"""JSON Entry Validator
|
||||
|
||||
def check_json_syntax(file_path):
|
||||
Validates JSON entries based on content structure.
|
||||
|
||||
Validation rules based on JSON content:
|
||||
- {"custom_nodes": [...]}: Validates required fields (author, title, reference, files, install_type, description)
|
||||
- {"models": [...]}: Validates JSON syntax only (no required fields)
|
||||
- Other JSON structures: Validates JSON syntax only
|
||||
|
||||
Git repository URL validation (for custom_nodes):
|
||||
1. URLs must NOT end with .git
|
||||
2. URLs must follow format: https://github.com/{author}/{reponame}
|
||||
3. .py and .js files are exempt from this check
|
||||
|
||||
Supported formats:
|
||||
- Array format: [{...}, {...}]
|
||||
- Object format: {"custom_nodes": [...]} or {"models": [...]}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
# Required fields for each entry type
|
||||
REQUIRED_FIELDS_CUSTOM_NODE = ['author', 'title', 'reference', 'files', 'install_type', 'description']
|
||||
REQUIRED_FIELDS_MODEL = [] # model-list.json doesn't require field validation
|
||||
|
||||
# Pattern for valid GitHub repository URL (without .git suffix)
|
||||
GITHUB_REPO_PATTERN = re.compile(r'^https://github\.com/[^/]+/[^/]+$')
|
||||
|
||||
|
||||
def get_entry_context(entry: Dict) -> str:
|
||||
"""Get identifying information from entry for error messages
|
||||
|
||||
Args:
|
||||
entry: JSON entry
|
||||
|
||||
Returns:
|
||||
String with author and reference info
|
||||
"""
|
||||
parts = []
|
||||
if 'author' in entry:
|
||||
parts.append(f"author={entry['author']}")
|
||||
if 'reference' in entry:
|
||||
parts.append(f"ref={entry['reference']}")
|
||||
if 'title' in entry:
|
||||
parts.append(f"title={entry['title']}")
|
||||
|
||||
if parts:
|
||||
return " | ".join(parts)
|
||||
else:
|
||||
# No identifying info - show actual entry content (truncated)
|
||||
import json
|
||||
entry_str = json.dumps(entry, ensure_ascii=False)
|
||||
if len(entry_str) > 100:
|
||||
entry_str = entry_str[:100] + "..."
|
||||
return f"content={entry_str}"
|
||||
|
||||
|
||||
def validate_required_fields(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
|
||||
"""Validate that all required fields are present
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
required_fields: List of required field names
|
||||
|
||||
Returns:
|
||||
List of error descriptions (without entry prefix/context)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for field in required_fields:
|
||||
if field not in entry:
|
||||
errors.append(f"Missing required field '{field}'")
|
||||
elif entry[field] is None:
|
||||
errors.append(f"Field '{field}' is null")
|
||||
elif isinstance(entry[field], str) and not entry[field].strip():
|
||||
errors.append(f"Field '{field}' is empty")
|
||||
elif field == 'files' and not entry[field]: # Empty array
|
||||
errors.append("Field 'files' is empty array")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_git_repo_urls(entry: Dict, entry_index: int) -> List[str]:
|
||||
"""Validate git repository URLs in 'files' array
|
||||
|
||||
Requirements:
|
||||
- Git repo URLs must NOT end with .git
|
||||
- Must follow format: https://github.com/{author}/{reponame}
|
||||
- .py and .js files are exempt
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
|
||||
Returns:
|
||||
List of error descriptions (without entry prefix/context)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if 'files' not in entry or not isinstance(entry['files'], list):
|
||||
return errors
|
||||
|
||||
for file_url in entry['files']:
|
||||
if not isinstance(file_url, str):
|
||||
continue
|
||||
|
||||
# Skip .py and .js files - they're exempt from git repo validation
|
||||
if file_url.endswith('.py') or file_url.endswith('.js'):
|
||||
continue
|
||||
|
||||
# Check if it's a GitHub URL (likely a git repo)
|
||||
if 'github.com' in file_url:
|
||||
# Error if URL ends with .git
|
||||
if file_url.endswith('.git'):
|
||||
errors.append(f"Git repo URL must NOT end with .git: {file_url}")
|
||||
continue
|
||||
|
||||
# Validate format: https://github.com/{author}/{reponame}
|
||||
if not GITHUB_REPO_PATTERN.match(file_url):
|
||||
errors.append(f"Invalid git repo URL format (expected https://github.com/author/reponame): {file_url}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_entry(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
|
||||
"""Validate a single JSON entry
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
required_fields: List of required field names
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check required fields
|
||||
errors.extend(validate_required_fields(entry, entry_index, required_fields))
|
||||
|
||||
# Check git repository URLs
|
||||
errors.extend(validate_git_repo_urls(entry, entry_index))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_json_file(file_path: str) -> Tuple[bool, List[str]]:
|
||||
"""Validate JSON file containing entries
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check file exists
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return False, [f"File not found: {file_path}"]
|
||||
|
||||
# Load JSON
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
json_str = file.read()
|
||||
json.loads(json_str)
|
||||
print(f"[ OK ] {file_path}")
|
||||
except UnicodeDecodeError as e:
|
||||
print(f"Unicode decode error: {e}")
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[FAIL] {file_path}\n\n {e}\n")
|
||||
except FileNotFoundError:
|
||||
print(f"[FAIL] {file_path}\n\n File not found\n")
|
||||
return False, [f"Invalid JSON: {e}"]
|
||||
except Exception as e:
|
||||
return False, [f"Error reading file: {e}"]
|
||||
|
||||
# Determine required fields based on JSON content
|
||||
required_fields = []
|
||||
|
||||
# Validate structure - support both array and object formats
|
||||
entries_to_validate = []
|
||||
|
||||
if isinstance(data, list):
|
||||
# Direct array format: [{...}, {...}]
|
||||
entries_to_validate = data
|
||||
elif isinstance(data, dict):
|
||||
# Object format: {"custom_nodes": [...]} or {"models": [...]}
|
||||
# Determine validation based on keys
|
||||
if 'custom_nodes' in data and isinstance(data['custom_nodes'], list):
|
||||
required_fields = REQUIRED_FIELDS_CUSTOM_NODE
|
||||
entries_to_validate = data['custom_nodes']
|
||||
elif 'models' in data and isinstance(data['models'], list):
|
||||
required_fields = REQUIRED_FIELDS_MODEL
|
||||
entries_to_validate = data['models']
|
||||
else:
|
||||
# Other JSON structures (extension-node-map.json, etc.) - just validate JSON syntax
|
||||
return True, []
|
||||
else:
|
||||
return False, ["JSON root must be either an array or an object containing arrays"]
|
||||
|
||||
# Validate each entry
|
||||
for idx, entry in enumerate(entries_to_validate, start=1):
|
||||
if not isinstance(entry, dict):
|
||||
# Show actual value for type errors
|
||||
entry_str = json.dumps(entry, ensure_ascii=False) if not isinstance(entry, str) else repr(entry)
|
||||
if len(entry_str) > 150:
|
||||
entry_str = entry_str[:150] + "..."
|
||||
errors.append(f"\n❌ Entry #{idx}: Must be an object, got {type(entry).__name__}")
|
||||
errors.append(f" Actual value: {entry_str}")
|
||||
continue
|
||||
|
||||
entry_errors = validate_entry(entry, idx, required_fields)
|
||||
if entry_errors:
|
||||
# Group errors by entry with context
|
||||
context = get_entry_context(entry)
|
||||
errors.append(f"\n❌ Entry #{idx} ({context}):")
|
||||
for error in entry_errors:
|
||||
errors.append(f" - {error}")
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
return is_valid, errors
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="JSON File Syntax Checker")
|
||||
parser.add_argument("file_path", type=str, help="Path to the JSON file for syntax checking")
|
||||
"""Main entry point"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python json-checker.py <json-file>")
|
||||
print("\nValidates JSON entries based on content:")
|
||||
print(" - {\"custom_nodes\": [...]}: Validates required fields (author, title, reference, files, install_type, description)")
|
||||
print(" - {\"models\": [...]}: Validates JSON syntax only (no required fields)")
|
||||
print(" - Other JSON structures: Validates JSON syntax only")
|
||||
print("\nGit repo URL validation (for custom_nodes):")
|
||||
print(" - URLs must NOT end with .git")
|
||||
print(" - URLs must follow: https://github.com/{author}/{reponame}")
|
||||
sys.exit(1)
|
||||
|
||||
args = parser.parse_args()
|
||||
check_json_syntax(args.file_path)
|
||||
file_path = sys.argv[1]
|
||||
|
||||
if __name__ == "__main__":
|
||||
is_valid, errors = validate_json_file(file_path)
|
||||
|
||||
if is_valid:
|
||||
print(f"✅ {file_path}: Validation passed")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"Validating: {file_path}")
|
||||
print("=" * 60)
|
||||
print("❌ Validation failed!\n")
|
||||
print("Errors:")
|
||||
# Count actual errors (lines starting with " -")
|
||||
error_count = sum(1 for e in errors if e.strip().startswith('-'))
|
||||
for error in errors:
|
||||
# Don't add ❌ prefix to grouped entries (they already have it)
|
||||
if error.strip().startswith('❌'):
|
||||
print(error)
|
||||
else:
|
||||
print(error)
|
||||
print(f"\nTotal errors: {error_count}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@ -1,5 +1,105 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "Nynxz",
|
||||
"title": "ComfyUI_DiffsynthPause",
|
||||
"reference": "https://github.com/Nynxz/ComfyUI_DiffsynthPause",
|
||||
"files": [
|
||||
"https://github.com/Nynxz/ComfyUI_DiffsynthPause"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for controlling Diffsynth checkpoint pausing behavior during image generation workflows. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "binarystatic",
|
||||
"title": "ComfyUI-BinarystaticMasterSeed",
|
||||
"reference": "https://github.com/binarystatic/ComfyUI-BinarystaticMasterSeed",
|
||||
"files": [
|
||||
"https://github.com/binarystatic/ComfyUI-BinarystaticMasterSeed"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "BinarystaticMasterSeed node for ComfyUI. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "Aruntd008",
|
||||
"title": "[WIP] ComfyUI_SeamlessPattern",
|
||||
"reference": "https://github.com/Aruntd008/ComfyUI_SeamlessPattern",
|
||||
"files": [
|
||||
"https://github.com/Aruntd008/ComfyUI_SeamlessPattern"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "SeamlessPatternNode for ComfyUI. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "SilentLuxRay",
|
||||
"title": "[WIP] ComfyUI-Furrey-Super-Prompt",
|
||||
"reference": "https://github.com/SilentLuxRay/ComfyUI-Furrey-Super-Prompt",
|
||||
"files": [
|
||||
"https://github.com/SilentLuxRay/ComfyUI-Furrey-Super-Prompt"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A personalized all-in-one node for ComfyUI that simplifies prompt management and LoRA handling with automatic translation to English. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "Rayen21",
|
||||
"title": "[WIP] ComfyUI-PromptLinePlus",
|
||||
"reference": "https://github.com/Rayen21/ComfyUI-PromptLinePlus",
|
||||
"files": [
|
||||
"https://github.com/Rayen21/ComfyUI-PromptLinePlus"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node that splits multi-line prompts by line, enabling batch image generation with each line triggering one execution and supporting custom prompt boxes. (Description by CC)\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "ashtar1984",
|
||||
"title": "comfyui-switch-bypass-mute-by-group",
|
||||
"reference": "https://github.com/ashtar1984/comfyui-switch-bypass-mute-by-group",
|
||||
"files": [
|
||||
"https://github.com/ashtar1984/comfyui-switch-bypass-mute-by-group"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for group-based node switching, bypassing, and muting control. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "rookiestar28",
|
||||
"title": "ComfyUI_Security_Audit",
|
||||
"reference": "https://github.com/rookiestar28/ComfyUI_Security_Audit",
|
||||
"files": [
|
||||
"https://github.com/rookiestar28/ComfyUI_Security_Audit"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A lightweight, dual-layer security extension for ComfyUI using AST-based static analysis and runtime monitoring to detect malicious code in custom nodes."
|
||||
},
|
||||
{
|
||||
"author": "c1660181647-hash",
|
||||
"title": "ComfyUI-MM-Visual-Encryption",
|
||||
"reference": "https://github.com/c1660181647-hash/ComfyUI-MM-Visual-Encryption",
|
||||
"files": [
|
||||
"https://github.com/c1660181647-hash/ComfyUI-MM-Visual-Encryption"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A visual noise encryption custom node for ComfyUI, supporting Image and Video privacy protection."
|
||||
},
|
||||
{
|
||||
"author": "charlierz",
|
||||
"title": "comfyui-charlierz",
|
||||
"reference": "https://github.com/charlierz/comfyui-charlierz",
|
||||
"files": [
|
||||
"https://github.com/charlierz/comfyui-charlierz"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: BackgroundColor, ScaleDimensions"
|
||||
},
|
||||
{
|
||||
"author": "lrzjason",
|
||||
"title": "Comfyui-DiffusersUtils [WIP]",
|
||||
"reference": "https://github.com/lrzjason/Comfyui-DiffusersUtils",
|
||||
"files": [
|
||||
"https://github.com/lrzjason/Comfyui-DiffusersUtils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A set of nodes which provide flexible inference using diffusers in comfyui env. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "anilstream",
|
||||
"title": "ComfyUI-NanoBananaPro",
|
||||
@ -4779,7 +4879,8 @@
|
||||
"description": "NODES: Face Detector Selector, YC Human Parts Ultra(Advance), Color Match (YC)"
|
||||
},
|
||||
{
|
||||
"author": "virallover",
|
||||
"author": "maizerrr",
|
||||
"title": "comfyui-code-nodes",
|
||||
"reference": "https://github.com/maizerrr/comfyui-code-nodes",
|
||||
"files": [
|
||||
"https://github.com/maizerrr/comfyui-code-nodes"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,65 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "cdanielp",
|
||||
"title": "COMFYUI_PROMPTMODELS [REMOVED]",
|
||||
"reference": "https://github.com/cdanielp/COMFYUI_PROMPTMODELS",
|
||||
"files": [
|
||||
"https://github.com/cdanielp/COMFYUI_PROMPTMODELS"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI by PROMPTMODELS."
|
||||
},
|
||||
{
|
||||
"author": "mcrataobrabo",
|
||||
"title": "comfyui-smart-lora-downloader - Automatically Fetch Missing LoRAs [REMOVED]",
|
||||
"reference": "https://github.com/mcrataobrabo/comfyui-smart-lora-downloader",
|
||||
"files": [
|
||||
"https://github.com/mcrataobrabo/comfyui-smart-lora-downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Automatically detect and download missing LoRAs for ComfyUI workflows"
|
||||
},
|
||||
{
|
||||
"author": "KANAsho34636",
|
||||
"title": "ComfyUI-NaturalSort-ImageLoader [REMOVED]",
|
||||
"reference": "https://github.com/KANAsho34636/ComfyUI-NaturalSort-ImageLoader",
|
||||
"files": [
|
||||
"https://github.com/KANAsho34636/ComfyUI-NaturalSort-ImageLoader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom image loader node supporting natural number sorting with multiple sort modes (natural, lexicographic, modification time, creation time, reverse natural). (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "johninthewinter",
|
||||
"title": "comfyui-fal-flux-2-John [REMOVED]",
|
||||
"reference": "https://github.com/johninthewinter/comfyui-fal-flux-2-John",
|
||||
"files": [
|
||||
"https://github.com/johninthewinter/comfyui-fal-flux-2-John"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI that integrate with fal.ai's FLUX 2 and FLUX 1 LoRA APIs for text-to-image generation."
|
||||
},
|
||||
{
|
||||
"author": "LargeModGames",
|
||||
"title": "ComfyUI LoRA Auto Downloader [REMOVED]",
|
||||
"reference": "https://github.com/LargeModGames/comfyui-smart-lora-downloader",
|
||||
"files": [
|
||||
"https://github.com/LargeModGames/comfyui-smart-lora-downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Automatically download missing LoRAs from CivitAI and detect missing LoRAs in workflows. Features smart directory detection and easy installation."
|
||||
},
|
||||
{
|
||||
"author": "DiffusionWave",
|
||||
"title": "PickResolution_DiffusionWave [DEPRECATED]",
|
||||
"reference": "https://github.com/DiffusionWave/PickResolution_DiffusionWave",
|
||||
"files": [
|
||||
"https://github.com/DiffusionWave/PickResolution_DiffusionWave"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that allows selecting a base resolution, applying a custom scaling value based on FLOAT (up to 10 decimal places), and adding an extra integer value. Outputs include both INT and FLOAT resolutions, making it perfect for you to play around with."
|
||||
},
|
||||
{
|
||||
"author": "geltz",
|
||||
"title": "ComfyUI-geltz [REMOVED]",
|
||||
|
||||
@ -1,5 +1,261 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "TuonoMindCode",
|
||||
"title": "ComfyUI-Resolution-Suggest-Downscale",
|
||||
"reference": "https://github.com/TuonoMindCode/ComfyUI-Resolution-Suggest-Downscale",
|
||||
"files": [
|
||||
"https://github.com/TuonoMindCode/ComfyUI-Resolution-Suggest-Downscale"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Resolution suggestion and downscale helper node for ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "dcyd-lun",
|
||||
"title": "ComfyUI_text_diff",
|
||||
"reference": "https://github.com/dcyd-lun/ComfyUI_text_diff",
|
||||
"files": [
|
||||
"https://github.com/dcyd-lun/ComfyUI_text_diff"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Compare two texts with GitHub/GitLab-style diff highlighting in ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "SKFRMSEHF",
|
||||
"title": "SK-ComfyUI-FolderingLoader",
|
||||
"id": "skloader",
|
||||
"reference": "https://github.com/SKFRMSEHF/comfyui_SK_Loader",
|
||||
"files": [
|
||||
"https://github.com/SKFRMSEHF/comfyui_SK_Loader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Unified loader nodes with folder organization support for checkpoints, LoRA, VAE, and diffusion models"
|
||||
},
|
||||
{
|
||||
"author": "drawthingsai",
|
||||
"title": "draw-things-comfyui",
|
||||
"reference": "https://github.com/drawthingsai/draw-things-comfyui",
|
||||
"files": [
|
||||
"https://github.com/drawthingsai/draw-things-comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "The official Draw Things extension for ComfyUI. Sends image-generation requests to Draw Things over gRPC. Supports Bridge Mode for DT+ cloud and local model execution."
|
||||
},
|
||||
{
|
||||
"author": "darrell-goh",
|
||||
"title": "ComfyUI-NanoBanana_node",
|
||||
"reference": "https://github.com/darrell-goh/ComfyUI-NanoBanana_node",
|
||||
"files": [
|
||||
"https://github.com/darrell-goh/ComfyUI-NanoBanana_node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that allows you to interact with Nano Banana's Vertex API proxy, providing access to Gemini models with dynamic image inputs."
|
||||
},
|
||||
{
|
||||
"author": "This-is-Uncut",
|
||||
"title": "Custom-Node-ComfyUI-NBP-Uncut",
|
||||
"reference": "https://github.com/This-is-Uncut/Custom-Node-ComfyUI-NBP-Uncut",
|
||||
"files": [
|
||||
"https://github.com/This-is-Uncut/Custom-Node-ComfyUI-NBP-Uncut"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Node to use NBP with custom API key to improve data privacy control."
|
||||
},
|
||||
{
|
||||
"author": "princepainter",
|
||||
"title": "Comfyui-PainterAudioLength",
|
||||
"reference": "https://github.com/princepainter/Comfyui-PainterAudioLength",
|
||||
"files": [
|
||||
"https://github.com/princepainter/Comfyui-PainterAudioLength"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A tool for calculating audio duration that takes audio input and outputs the audio length as a floating-point number. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "akaugun",
|
||||
"title": "comfyui-lora-hook-trigger",
|
||||
"reference": "https://github.com/akaugun/comfyui-lora-hook-trigger",
|
||||
"files": [
|
||||
"https://github.com/akaugun/comfyui-lora-hook-trigger"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A clean and simple ComfyUI custom node that creates a LoRA Hook and automatically finds and loads trigger TXT files placed in a folder named after the LoRA file."
|
||||
},
|
||||
{
|
||||
"author": "tumbowungus",
|
||||
"title": "MultiMaskCouple",
|
||||
"reference": "https://github.com/tumbowungus/MultiMaskCouple",
|
||||
"files": [
|
||||
"https://github.com/tumbowungus/MultiMaskCouple"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI which simplifies the process of masking multiple prompts, making it easier to manage scenes with multiple distinct characters."
|
||||
},
|
||||
{
|
||||
"author": "fudosanit",
|
||||
"title": "ComfyUI-Random-Resolution",
|
||||
"reference": "https://github.com/fudosanit/ComfyUI-Random-Resolution",
|
||||
"files": [
|
||||
"https://github.com/fudosanit/ComfyUI-Random-Resolution"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI. Based on the specified resolution, it randomly selects and outputs one of three patterns: original orientation, swapped width/height, or a square format. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "SuLU-K",
|
||||
"title": "comfyui-easy-sam3-tools",
|
||||
"reference": "https://github.com/SuLU-K/comfyui-easy-sam3-tools",
|
||||
"files": [
|
||||
"https://github.com/SuLU-K/comfyui-easy-sam3-tools"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for building SAM3-centric editing pipelines inside ComfyUI, featuring mask merging, bounding box rendering, and DOM-based interactive editing. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "EMkrtchyan",
|
||||
"title": "ComfyUI-NormalsToDepth",
|
||||
"reference": "https://github.com/EMkrtchyan/ComfyUI-NormalsToDepth",
|
||||
"files": [
|
||||
"https://github.com/EMkrtchyan/ComfyUI-NormalsToDepth"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Converts normal maps to depth maps for use in ComfyUI. (Description by CC)"
|
||||
},
|
||||
|
||||
{
|
||||
"author": "jeankassio",
|
||||
"title": "JK AceStep Nodes",
|
||||
"id": "JK-AceStep-Nodes",
|
||||
"reference": "https://github.com/jeankassio/JK-AceStep-Nodes",
|
||||
"files": [
|
||||
"https://github.com/jeankassio/JK-AceStep-Nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Advanced nodes optimized for ACE-Step audio generation in ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "a-und-b",
|
||||
"title": "ComfyUI_AB_Wildcard",
|
||||
"reference": "https://github.com/a-und-b/ComfyUI_AB_Wildcard",
|
||||
"files": [
|
||||
"https://github.com/a-und-b/ComfyUI_AB_Wildcard"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simple node for advanced wildcard text processing. Supports variables, conditionals, tag aggregation, weighted selection, and deep nesting. Lightweight, fast, zero external dependencies."
|
||||
},
|
||||
{
|
||||
"author": "RamonGuthrie",
|
||||
"title": "ComfyUI-RBG-SmartSeedVariance",
|
||||
"reference": "https://github.com/RamonGuthrie/ComfyUI-RBG-SmartSeedVariance",
|
||||
"files": [
|
||||
"https://github.com/RamonGuthrie/ComfyUI-RBG-SmartSeedVariance"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Advanced seed diversity enhancement for ComfyUI with intelligent noise injection and directional biasing."
|
||||
},
|
||||
{
|
||||
"author": "theshubzworld",
|
||||
"title": "ComfyUI-NvidiaCaptioner",
|
||||
"reference": "https://github.com/theshubzworld/ComfyUI-NvidiaCaptioner",
|
||||
"files": [
|
||||
"https://github.com/theshubzworld/ComfyUI-NvidiaCaptioner"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI node for generating rich, detailed captions for images using NVIDIA's vision models. Supports batch processing, multiple captioning styles, and includes built-in caching for efficient workflows."
|
||||
},
|
||||
{
|
||||
"author": "theshubzworld",
|
||||
"title": "ComfyUI-Universal-Latent",
|
||||
"reference": "https://github.com/theshubzworld/ComfyUI-Universal-Latent",
|
||||
"files": [
|
||||
"https://github.com/theshubzworld/ComfyUI-Universal-Latent"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Enhanced empty latent node with extended aspect ratio support for ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "lrzjason",
|
||||
"title": "Comfyui-LatentUtils",
|
||||
"reference": "https://github.com/lrzjason/Comfyui-LatentUtils",
|
||||
"files": [
|
||||
"https://github.com/lrzjason/Comfyui-LatentUtils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node performing selective latent denoising and detail enhancement using Fourier Transform (FFT) to separate and enhance image frequencies while suppressing noise. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "aTanguay",
|
||||
"title": "ComfyUI_Detonate",
|
||||
"reference": "https://github.com/aTanguay/ComfyUI_Detonate",
|
||||
"files": [
|
||||
"https://github.com/aTanguay/ComfyUI_Detonate"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Professional compositing nodes for ComfyUI - bringing Nuke and Fusion workflows to AI-powered image generation"
|
||||
},
|
||||
{
|
||||
"author": "huihuihuiz",
|
||||
"title": "LoRA Downloader for ComfyUI",
|
||||
"id": "lora_downloader",
|
||||
"reference": "https://github.com/huihuihuiz/lora_downloader",
|
||||
"files": [
|
||||
"https://github.com/huihuihuiz/lora_downloader"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node for downloading and managing LoRA models directly within the UI."
|
||||
},
|
||||
{
|
||||
"author": "lovelybbq",
|
||||
"title": "ComfyUI Custom Node Color",
|
||||
"reference": "https://github.com/lovelybbq/comfyui-custom-node-color",
|
||||
"files": [
|
||||
"https://github.com/lovelybbq/comfyui-custom-node-color"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A modern GUI-based color picker for ComfyUI nodes. Features visual spectrum, HEX/RGB inputs, eyedropper tool, and favorite colors support."
|
||||
},
|
||||
{
|
||||
"author": "Moeblack",
|
||||
"title": "ComfyUI-SimpleChat",
|
||||
"reference": "https://github.com/Moeblack/ComfyUI-SimpleChat",
|
||||
"files": [
|
||||
"https://github.com/Moeblack/ComfyUI-SimpleChat"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simple, no-nonsense LLM chat nodes for ComfyUI. Support OpenAI, Claude, Gemini and NoASS Roleplay.",
|
||||
"tags": [
|
||||
"LLM",
|
||||
"chat",
|
||||
"openai",
|
||||
"claude",
|
||||
"gemini",
|
||||
"roleplay",
|
||||
"noass"
|
||||
]
|
||||
},
|
||||
{
|
||||
"author": "Lord Lethris",
|
||||
"title": "Dia2 TTS & Captions Generators for ComfyUI",
|
||||
"id": "dia2_tts_captions",
|
||||
"reference": "https://github.com/lord-lethris/ComfyUI-lethris-dia2",
|
||||
"files": [
|
||||
"https://github.com/lord-lethris/ComfyUI-lethris-dia2"
|
||||
],
|
||||
"install_type": "Git-Clone",
|
||||
"description": "This package provides two ComfyUI nodes: 🗣️ Dia2 TTS Generator for text-to-speech using Dia2-2B, and 💬 Dia2 Captions Generator to convert TTS timestamps into SRT/SSA/VTT subtitles. Includes example workflow and voice samples. GPU users require CUDA 12.8+."
|
||||
},
|
||||
{
|
||||
"author": "Braeden90000",
|
||||
"title": "ComfyUI Load Image URL",
|
||||
"id": "load-image-url",
|
||||
"reference": "https://github.com/Braeden90000/comfyui-load-image-url",
|
||||
"files": [
|
||||
"https://github.com/Braeden90000/comfyui-load-image-url"
|
||||
],
|
||||
"pip": ["requests"],
|
||||
"install_type": "git-clone",
|
||||
"description": "Load images from files or URLs with live preview and source switching."
|
||||
},
|
||||
{
|
||||
"author": "purzbeats",
|
||||
"title": "ComfyUI-Purz",
|
||||
@ -577,161 +833,6 @@
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ShakaNodes: Utility tools for ComfyUI"
|
||||
},
|
||||
{
|
||||
"author": "Matthew-X",
|
||||
"title": "Workflow Importer",
|
||||
"id": "comfyui-workflow_importer",
|
||||
"reference": "https://github.com/Matthew-X/comfyui-workflow_importer",
|
||||
"files": [
|
||||
"https://github.com/Matthew-X/comfyui-workflow_importer"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"js_path": "workflow_importer",
|
||||
"description": "Import ComfyUI workflows from images with embedded metadata. Adds a toolbar Import button, drag-and-drop dialog and Ctrl+Shift+I shortcut. Supports legacy and new UIs, common image formats, opens each image in a new workflow tab."
|
||||
},
|
||||
{
|
||||
"author": "ameyukisora",
|
||||
"title": "ComfyUI Empty Latent Advanced",
|
||||
"reference": "https://github.com/ameyukisora/ComfyUI-Empty-Latent-Advanced",
|
||||
"files": [
|
||||
"https://github.com/ameyukisora/ComfyUI-Empty-Latent-Advanced"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "An advanced Empty Latent selector with visual presets and foldable UI."
|
||||
},
|
||||
{
|
||||
"author": "KLL535",
|
||||
"title": "ComfyUI_Simple_Qwen3-VL-gguf",
|
||||
"reference": "https://github.com/KLL535/ComfyUI_Simple_Qwen3-VL-gguf",
|
||||
"files": [
|
||||
"https://github.com/KLL535/ComfyUI_Simple_Qwen3-VL-gguf"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simple Qwen3-VL gguf LLM model loader"
|
||||
},
|
||||
{
|
||||
"author": "sirouk",
|
||||
"title": "chutes-comfyui-node",
|
||||
"reference": "https://github.com/sirouk/chutes-comfyui-node",
|
||||
"files": [
|
||||
"https://github.com/sirouk/chutes-comfyui-node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom nodes for Chutes.ai video generation API"
|
||||
},
|
||||
{
|
||||
"author": "1038lab",
|
||||
"title": "ComfyUI-QwenVL",
|
||||
"reference": "https://github.com/1038lab/ComfyUI-QwenVL",
|
||||
"files": [
|
||||
"https://github.com/1038lab/ComfyUI-QwenVL"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI-QwenVL custom node: Integrates the Qwen-VL series, including Qwen2.5-VL and the latest Qwen3-VL, to enable advanced multimodal AI for text generation, image understanding, and video analysis."
|
||||
},
|
||||
{
|
||||
"author": "smthemex",
|
||||
"title": "ComfyUI_GRAG_Image_Editing",
|
||||
"reference": "https://github.com/smthemex/ComfyUI_GRAG_Image_Editing",
|
||||
"files": [
|
||||
"https://github.com/smthemex/ComfyUI_GRAG_Image_Editing"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "GRAG-Image-Editing : Group-Relative Attention Guidance for Image Editing,you can try it in comfyUI"
|
||||
},
|
||||
{
|
||||
"author": "dzy1128",
|
||||
"title": "ComfyUI-VertexAI",
|
||||
"reference": "https://github.com/dzy1128/ComfyUI-VertexAI",
|
||||
"files": [
|
||||
"https://github.com/dzy1128/ComfyUI-VertexAI"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Google Gemini image generation node for ComfyUI supporting up to 6 input images with customizable prompts and parameters. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "mrf",
|
||||
"title": "ComfyPoe",
|
||||
"reference": "https://github.com/mrf/ComfyPoe",
|
||||
"files": [
|
||||
"https://github.com/mrf/ComfyPoe"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for image generation via Poe API"
|
||||
},
|
||||
{
|
||||
"author": "Tinuva88",
|
||||
"title": "Comfy-UmiAI",
|
||||
"reference": "https://github.com/Tinuva88/Comfy-UmiAI",
|
||||
"files": [
|
||||
"https://github.com/Tinuva88/Comfy-UmiAI"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A logic engine for ComfyUI prompts that transforms static prompts into dynamic, context-aware workflows with persistent variables, conditional logic, native LoRA loading, and external data fetching. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "fredlef",
|
||||
"title": "ComfyUI FSL Nodes",
|
||||
"reference": "https://github.com/fredlef/Comfyui_FSL_Nodes",
|
||||
"files": [
|
||||
"https://github.com/fredlef/Comfyui_FSL_Nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"tags": ["image", "chat", "gemini", "fsl"]
|
||||
},
|
||||
{
|
||||
"author": "exedesign",
|
||||
"title": "ComfyUI-Hunyuan3D-v3",
|
||||
"id": "hunyuan3d-v3",
|
||||
"reference": "https://github.com/exedesign/Hunyuan-3D-v3",
|
||||
"files": [
|
||||
"https://github.com/exedesign/Hunyuan-3D-v3"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Text-to-3D and Image-to-3D generation using Tencent Cloud Hunyuan 3D Global API. Supports PBR materials, face count control (40K-1.5M faces), and multiple generation types (Normal/LowPoly/Geometry/Sketch). Outputs industry-standard GLB format. Requires Tencent Cloud account with API access.",
|
||||
"nodename_pattern": "Hunyuan",
|
||||
"tags": ["3D", "generation", "text-to-3d", "image-to-3d", "hunyuan", "tencent"]
|
||||
},
|
||||
{
|
||||
"author": "rjgoif",
|
||||
"title": "Img Label Tools",
|
||||
"id": "Img-Label-Tools",
|
||||
"reference": "https://github.com/rjgoif/ComfyUI-Img-Label-Tools",
|
||||
"install_type": "git-clone",
|
||||
"description": "Tools to help annotate images for sharing on Reddit, Discord, etc."
|
||||
},
|
||||
{
|
||||
"author": "akawana",
|
||||
"title": "RGBYP Mask Editor (RGB + YP)",
|
||||
"reference": "https://github.com/akawana/ComfyUI-RGBYP-Mask-Editor",
|
||||
"files": [
|
||||
"https://github.com/akawana/ComfyUI-RGBYP-Mask-Editor"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A JS editor for five-color masks (RGB + Yellow + Pink) that works with any nodes, with three helper nodes — RGBYPLoadImage, RGBYPMaskBridge, and RGBYPMaskToRegularMasks—for convenient RGBYP mask handling.",
|
||||
"tags": ["utility", "mask", "rgb", "bridge"]
|
||||
},
|
||||
{
|
||||
"author": "btitkin",
|
||||
"title": "Random Prompt Builder",
|
||||
"id": "random-prompt-builder",
|
||||
"reference": "https://github.com/btitkin/ComfyUI-RandomPromptBuilder",
|
||||
"files": [
|
||||
"https://github.com/btitkin/ComfyUI-RandomPromptBuilder"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Advanced AI-powered prompt generation using local GGUF models. Generate detailed, structured prompts with character controls, style presets, and model-specific formatting for Pony, SDXL, Flux, and more. Supports GPU acceleration and runs completely offline."
|
||||
},
|
||||
{
|
||||
"author": "erosDiffusion",
|
||||
"title": "ComfyUI-EulerDiscreteScheduler",
|
||||
"reference": "https://github.com/erosDiffusion/ComfyUI-EulerDiscreteScheduler",
|
||||
"files": [
|
||||
"https://github.com/erosDiffusion/ComfyUI-EulerDiscreteScheduler"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Noise Free images with Euler Discrete Scheduler in ComfyUI with Z-Image or other models"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
|
||||
version = "3.38.1"
|
||||
version = "3.38.3"
|
||||
license = { file = "LICENSE.txt" }
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
|
||||
|
||||
|
||||
609
scanner.py
609
scanner.py
@ -16,6 +16,108 @@ import sys
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from github import Github, Auth
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, Optional
|
||||
|
||||
# Scanner version for cache invalidation
|
||||
SCANNER_VERSION = "2.0.11" # Multi-layer detection: class existence + display names
|
||||
|
||||
# Cache for extract_nodes and extract_nodes_enhanced results
|
||||
_extract_nodes_cache: Dict[str, Set[str]] = {}
|
||||
_extract_nodes_enhanced_cache: Dict[str, Set[str]] = {}
|
||||
_file_mtime_cache: Dict[Path, float] = {}
|
||||
|
||||
|
||||
def _get_repo_root(file_path: Path) -> Optional[Path]:
|
||||
"""Find the repository root directory containing .git"""
|
||||
current = file_path if file_path.is_dir() else file_path.parent
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
return None
|
||||
|
||||
|
||||
def _get_repo_hash(repo_path: Path) -> str:
|
||||
"""Get git commit hash or fallback identifier"""
|
||||
git_dir = repo_path / ".git"
|
||||
if not git_dir.exists():
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Read HEAD to get current commit
|
||||
head_file = git_dir / "HEAD"
|
||||
if head_file.exists():
|
||||
head_content = head_file.read_text().strip()
|
||||
if head_content.startswith("ref:"):
|
||||
# HEAD points to a ref
|
||||
ref_path = git_dir / head_content[5:].strip()
|
||||
if ref_path.exists():
|
||||
commit_hash = ref_path.read_text().strip()
|
||||
return commit_hash[:16] # First 16 chars
|
||||
else:
|
||||
# Detached HEAD
|
||||
return head_content[:16]
|
||||
except:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _load_per_repo_cache(repo_path: Path) -> Optional[tuple]:
|
||||
"""Load nodes and metadata from per-repo cache
|
||||
|
||||
Returns:
|
||||
tuple: (nodes_set, metadata_dict) or None if cache invalid
|
||||
"""
|
||||
cache_file = repo_path / ".git" / "nodecache.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Verify scanner version
|
||||
if cache_data.get('scanner_version') != SCANNER_VERSION:
|
||||
return None
|
||||
|
||||
# Verify git hash
|
||||
current_hash = _get_repo_hash(repo_path)
|
||||
if cache_data.get('git_hash') != current_hash:
|
||||
return None
|
||||
|
||||
# Return nodes and metadata
|
||||
nodes = cache_data.get('nodes', [])
|
||||
metadata = cache_data.get('metadata', {})
|
||||
return (set(nodes) if nodes else set(), metadata)
|
||||
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def _save_per_repo_cache(repo_path: Path, all_nodes: Set[str], metadata: dict = None):
|
||||
"""Save nodes and metadata to per-repo cache"""
|
||||
cache_file = repo_path / ".git" / "nodecache.json"
|
||||
|
||||
if not cache_file.parent.exists():
|
||||
return
|
||||
|
||||
git_hash = _get_repo_hash(repo_path)
|
||||
cache_data = {
|
||||
"scanner_version": SCANNER_VERSION,
|
||||
"git_hash": git_hash,
|
||||
"scanned_at": datetime.datetime.now().isoformat(),
|
||||
"nodes": sorted(list(all_nodes)),
|
||||
"metadata": metadata if metadata else {}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
except:
|
||||
pass # Silently fail - cache is optional
|
||||
|
||||
|
||||
def download_url(url, dest_folder, filename=None):
|
||||
@ -51,11 +153,12 @@ Examples:
|
||||
# Standard mode
|
||||
python3 scanner.py
|
||||
python3 scanner.py --skip-update
|
||||
python3 scanner.py --skip-all --force-rescan
|
||||
|
||||
# Scan-only mode
|
||||
python3 scanner.py --scan-only temp-urls-clean.list
|
||||
python3 scanner.py --scan-only urls.list --temp-dir /custom/temp
|
||||
python3 scanner.py --scan-only urls.list --skip-update
|
||||
python3 scanner.py --scan-only urls.list --skip-update --force-rescan
|
||||
'''
|
||||
)
|
||||
|
||||
@ -69,6 +172,8 @@ Examples:
|
||||
help='Skip GitHub stats collection')
|
||||
parser.add_argument('--skip-all', action='store_true',
|
||||
help='Skip all update operations')
|
||||
parser.add_argument('--force-rescan', action='store_true',
|
||||
help='Force rescan all nodes (ignore cache)')
|
||||
|
||||
# Backward compatibility: positional argument for temp_dir
|
||||
parser.add_argument('temp_dir_positional', nargs='?', metavar='TEMP_DIR',
|
||||
@ -94,6 +199,11 @@ parse_cnt = 0
|
||||
def extract_nodes(code_text):
|
||||
global parse_cnt
|
||||
|
||||
# Check cache first
|
||||
cache_key = hash(code_text)
|
||||
if cache_key in _extract_nodes_cache:
|
||||
return _extract_nodes_cache[cache_key].copy()
|
||||
|
||||
try:
|
||||
if parse_cnt % 100 == 0:
|
||||
print(".", end="", flush=True)
|
||||
@ -128,12 +238,458 @@ def extract_nodes(code_text):
|
||||
if key is not None and isinstance(key.value, str):
|
||||
s.add(key.value.strip())
|
||||
|
||||
# Cache the result
|
||||
_extract_nodes_cache[cache_key] = s
|
||||
return s
|
||||
else:
|
||||
# Cache empty result
|
||||
_extract_nodes_cache[cache_key] = set()
|
||||
return set()
|
||||
except:
|
||||
# Cache empty result on error
|
||||
_extract_nodes_cache[cache_key] = set()
|
||||
return set()
|
||||
|
||||
def extract_nodes_from_repo(repo_path: Path, verbose: bool = False, force_rescan: bool = False) -> tuple:
|
||||
"""
|
||||
Extract all nodes and metadata from a repository with per-repo caching.
|
||||
|
||||
Automatically caches results in .git/nodecache.json.
|
||||
Cache is invalidated when:
|
||||
- Git commit hash changes
|
||||
- Scanner version changes
|
||||
- force_rescan flag is True
|
||||
|
||||
Args:
|
||||
repo_path: Path to repository root
|
||||
verbose: If True, print UI-only extension detection messages
|
||||
force_rescan: If True, ignore cache and force fresh scan
|
||||
|
||||
Returns:
|
||||
tuple: (nodes_set, metadata_dict)
|
||||
"""
|
||||
# Ensure path is absolute
|
||||
repo_path = repo_path.resolve()
|
||||
|
||||
# Check per-repo cache first (unless force_rescan is True)
|
||||
if not force_rescan:
|
||||
cached_result = _load_per_repo_cache(repo_path)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# Cache miss - scan all .py files
|
||||
all_nodes = set()
|
||||
all_metadata = {}
|
||||
py_files = list(repo_path.rglob("*.py"))
|
||||
|
||||
# Filter out __pycache__, .git, and other hidden directories
|
||||
filtered_files = []
|
||||
for f in py_files:
|
||||
try:
|
||||
rel_path = f.relative_to(repo_path)
|
||||
# Skip __pycache__, .git, and any directory starting with .
|
||||
if '__pycache__' not in str(rel_path) and not any(part.startswith('.') for part in rel_path.parts):
|
||||
filtered_files.append(f)
|
||||
except:
|
||||
continue
|
||||
py_files = filtered_files
|
||||
|
||||
for py_file in py_files:
|
||||
try:
|
||||
# Read file with proper encoding
|
||||
with open(py_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
code = f.read()
|
||||
|
||||
if code:
|
||||
# Extract nodes using SAME logic as scan_in_file
|
||||
# V1 nodes (enhanced with fallback patterns)
|
||||
nodes = extract_nodes_enhanced(code, py_file, visited=set(), verbose=verbose)
|
||||
all_nodes.update(nodes)
|
||||
|
||||
# V3 nodes detection
|
||||
v3_nodes = extract_v3_nodes(code)
|
||||
all_nodes.update(v3_nodes)
|
||||
|
||||
# Dict parsing - exclude commented NODE_CLASS_MAPPINGS lines
|
||||
pattern = r"_CLASS_MAPPINGS\s*(?::\s*\w+\s*)?=\s*(?:\\\s*)?{([^}]*)}"
|
||||
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
|
||||
|
||||
for match_obj in regex.finditer(code):
|
||||
# Get the line where NODE_CLASS_MAPPINGS is defined
|
||||
match_start = match_obj.start()
|
||||
line_start = code.rfind('\n', 0, match_start) + 1
|
||||
line_end = code.find('\n', match_start)
|
||||
if line_end == -1:
|
||||
line_end = len(code)
|
||||
line = code[line_start:line_end]
|
||||
|
||||
# Skip if line starts with # (commented)
|
||||
if re.match(r'^\s*#', line):
|
||||
continue
|
||||
|
||||
match = match_obj.group(1)
|
||||
|
||||
# Filter out commented lines from dict content
|
||||
match_lines = match.split('\n')
|
||||
match_filtered = '\n'.join(
|
||||
line for line in match_lines
|
||||
if not re.match(r'^\s*#', line)
|
||||
)
|
||||
|
||||
# Extract key-value pairs with double quotes
|
||||
key_value_pairs = re.findall(r"\"([^\"]*)\"\s*:\s*([^,\n]*)", match_filtered)
|
||||
for key, value in key_value_pairs:
|
||||
all_nodes.add(key.strip())
|
||||
|
||||
# Extract key-value pairs with single quotes
|
||||
key_value_pairs = re.findall(r"'([^']*)'\s*:\s*([^,\n]*)", match_filtered)
|
||||
for key, value in key_value_pairs:
|
||||
all_nodes.add(key.strip())
|
||||
|
||||
# Handle .update() pattern (AFTER comment removal)
|
||||
code_cleaned = re.sub(r'^#.*?$', '', code, flags=re.MULTILINE)
|
||||
|
||||
update_pattern = r"_CLASS_MAPPINGS\.update\s*\(\s*{([^}]*)}\s*\)"
|
||||
update_match = re.search(update_pattern, code_cleaned, re.DOTALL)
|
||||
if update_match:
|
||||
update_dict_text = update_match.group(1)
|
||||
# Extract key-value pairs (double quotes)
|
||||
update_pairs = re.findall(r'"([^"]*)"\s*:\s*([^,\n]*)', update_dict_text)
|
||||
for key, value in update_pairs:
|
||||
all_nodes.add(key.strip())
|
||||
# Extract key-value pairs (single quotes)
|
||||
update_pairs_single = re.findall(r"'([^']*)'\s*:\s*([^,\n]*)", update_dict_text)
|
||||
for key, value in update_pairs_single:
|
||||
all_nodes.add(key.strip())
|
||||
|
||||
# Additional regex patterns (AFTER comment removal)
|
||||
patterns = [
|
||||
r'^[^=]*_CLASS_MAPPINGS\["(.*?)"\]',
|
||||
r'^[^=]*_CLASS_MAPPINGS\[\'(.*?)\'\]',
|
||||
r'@register_node\("(.+)",\s*\".+"\)',
|
||||
r'"(\w+)"\s*:\s*{"class":\s*\w+\s*'
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
keys = re.findall(pattern, code_cleaned)
|
||||
all_nodes.update(key.strip() for key in keys)
|
||||
|
||||
# Extract metadata from this file
|
||||
metadata = extract_metadata_only(str(py_file))
|
||||
all_metadata.update(metadata)
|
||||
except Exception:
|
||||
# Silently skip files that can't be read
|
||||
continue
|
||||
|
||||
# Save to per-repo cache
|
||||
_save_per_repo_cache(repo_path, all_nodes, all_metadata)
|
||||
|
||||
return (all_nodes, all_metadata)
|
||||
|
||||
|
||||
def _verify_class_exists(node_name: str, code_text: str, file_path: Optional[Path] = None) -> tuple[bool, Optional[str], Optional[int]]:
|
||||
"""
|
||||
Verify that a node class exists and has ComfyUI node structure.
|
||||
|
||||
Returns: (exists: bool, file_path: str, line_number: int)
|
||||
|
||||
A valid ComfyUI node must have:
|
||||
- Class definition (not commented)
|
||||
- At least one of: INPUT_TYPES, RETURN_TYPES, FUNCTION method/attribute
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
tree = ast.parse(code_text)
|
||||
except:
|
||||
return (False, None, None)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
if node.name == node_name or node.name.replace('_', '') == node_name.replace('_', ''):
|
||||
# Found class definition - check if it has ComfyUI interface
|
||||
has_input_types = False
|
||||
has_return_types = False
|
||||
has_function = False
|
||||
|
||||
for item in node.body:
|
||||
# Check for INPUT_TYPES method
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'INPUT_TYPES':
|
||||
has_input_types = True
|
||||
# Check for RETURN_TYPES attribute
|
||||
elif isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
if target.id == 'RETURN_TYPES':
|
||||
has_return_types = True
|
||||
elif target.id == 'FUNCTION':
|
||||
has_function = True
|
||||
# Check for FUNCTION method
|
||||
elif isinstance(item, ast.FunctionDef):
|
||||
has_function = True
|
||||
|
||||
# Valid if has any ComfyUI signature
|
||||
if has_input_types or has_return_types or has_function:
|
||||
file_str = str(file_path) if file_path else None
|
||||
return (True, file_str, node.lineno)
|
||||
|
||||
return (False, None, None)
|
||||
|
||||
|
||||
def _extract_display_name_mappings(code_text: str) -> Set[str]:
|
||||
"""
|
||||
Extract node names from NODE_DISPLAY_NAME_MAPPINGS.
|
||||
|
||||
Pattern:
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"node_key": "Display Name",
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
Set of node keys from NODE_DISPLAY_NAME_MAPPINGS
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
tree = ast.parse(code_text)
|
||||
except:
|
||||
return set()
|
||||
|
||||
nodes = set()
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == 'NODE_DISPLAY_NAME_MAPPINGS':
|
||||
if isinstance(node.value, ast.Dict):
|
||||
for key in node.value.keys:
|
||||
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
||||
nodes.add(key.value.strip())
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def extract_nodes_enhanced(
|
||||
code_text: str,
|
||||
file_path: Optional[Path] = None,
|
||||
visited: Optional[Set[Path]] = None,
|
||||
verbose: bool = False
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Enhanced node extraction with multi-layer detection system.
|
||||
|
||||
Scanner 2.0.11 - Comprehensive detection strategy:
|
||||
- Phase 1: NODE_CLASS_MAPPINGS dict literal
|
||||
- Phase 2: Class.NAME attribute access (e.g., FreeChat.NAME)
|
||||
- Phase 3: Item assignment (NODE_CLASS_MAPPINGS["key"] = value)
|
||||
- Phase 4: Class existence verification (detects active classes even if registration commented)
|
||||
- Phase 5: NODE_DISPLAY_NAME_MAPPINGS cross-reference
|
||||
- Phase 6: Empty dict detection (UI-only extensions, logging only)
|
||||
|
||||
Fixed Bugs:
|
||||
- Scanner 2.0.9: Fallback cascade prevented Phase 3 execution
|
||||
- Scanner 2.0.10: Missed active classes with commented registrations (15 false negatives)
|
||||
|
||||
Args:
|
||||
code_text: Python source code
|
||||
file_path: Path to file (for logging and caching)
|
||||
visited: Visited paths (for circular import prevention)
|
||||
verbose: If True, print UI-only extension detection messages
|
||||
|
||||
Returns:
|
||||
Set of node names (union of all detected patterns)
|
||||
"""
|
||||
# Check file-based cache if file_path provided
|
||||
if file_path is not None:
|
||||
try:
|
||||
file_path_obj = Path(file_path) if not isinstance(file_path, Path) else file_path
|
||||
if file_path_obj.exists():
|
||||
current_mtime = file_path_obj.stat().st_mtime
|
||||
|
||||
# Check if we have cached result with matching mtime and scanner version
|
||||
if file_path_obj in _file_mtime_cache:
|
||||
cached_mtime = _file_mtime_cache[file_path_obj]
|
||||
cache_key = (str(file_path_obj), cached_mtime, SCANNER_VERSION)
|
||||
|
||||
if current_mtime == cached_mtime and cache_key in _extract_nodes_enhanced_cache:
|
||||
return _extract_nodes_enhanced_cache[cache_key].copy()
|
||||
except:
|
||||
pass # Ignore cache errors, proceed with normal execution
|
||||
|
||||
# Suppress warnings from AST parsing
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
|
||||
# Phase 1: Original extract_nodes() - dict literal
|
||||
phase1_nodes = extract_nodes(code_text)
|
||||
|
||||
# Phase 2: Class.NAME pattern
|
||||
if visited is None:
|
||||
visited = set()
|
||||
phase2_nodes = _fallback_classname_resolver(code_text, file_path)
|
||||
|
||||
# Phase 3: Item assignment pattern
|
||||
phase3_nodes = _fallback_item_assignment(code_text)
|
||||
|
||||
# Phase 4: NODE_DISPLAY_NAME_MAPPINGS cross-reference (NEW in 2.0.11)
|
||||
# This catches nodes that are in display names but not in NODE_CLASS_MAPPINGS
|
||||
phase4_nodes = _extract_display_name_mappings(code_text)
|
||||
|
||||
# Phase 5: Class existence verification ONLY for display name candidates (NEW in 2.0.11)
|
||||
# This phase is CONSERVATIVE - only verify classes that appear in display names
|
||||
# This catches the specific Scanner 2.0.10 bug pattern:
|
||||
# - NODE_CLASS_MAPPINGS registration is commented
|
||||
# - NODE_DISPLAY_NAME_MAPPINGS still has the entry
|
||||
# - Class implementation exists
|
||||
# Example: Bjornulf_ollamaLoader in Bjornulf_custom_nodes
|
||||
phase5_nodes = set()
|
||||
for node_name in phase4_nodes:
|
||||
# Only check classes that appear in display names but not in registrations
|
||||
if node_name not in (phase1_nodes | phase2_nodes | phase3_nodes):
|
||||
exists, _, _ = _verify_class_exists(node_name, code_text, file_path)
|
||||
if exists:
|
||||
phase5_nodes.add(node_name)
|
||||
|
||||
# Union all results (FIX: Scanner 2.0.9 bug + Scanner 2.0.10 bug)
|
||||
# 2.0.9: Used early return which missed Phase 3 nodes
|
||||
# 2.0.10: Only checked registrations, missed classes referenced in display names
|
||||
all_nodes = phase1_nodes | phase2_nodes | phase3_nodes | phase4_nodes | phase5_nodes
|
||||
|
||||
# Phase 6: Empty dict detector (logging only, doesn't add nodes)
|
||||
if not all_nodes:
|
||||
_fallback_empty_dict_detector(code_text, file_path, verbose)
|
||||
|
||||
# Cache the result
|
||||
if file_path is not None:
|
||||
try:
|
||||
file_path_obj = Path(file_path) if not isinstance(file_path, Path) else file_path
|
||||
if file_path_obj.exists():
|
||||
current_mtime = file_path_obj.stat().st_mtime
|
||||
cache_key = (str(file_path_obj), current_mtime, SCANNER_VERSION)
|
||||
_extract_nodes_enhanced_cache[cache_key] = all_nodes
|
||||
_file_mtime_cache[file_path_obj] = current_mtime
|
||||
except:
|
||||
pass
|
||||
|
||||
return all_nodes
|
||||
|
||||
|
||||
def _fallback_classname_resolver(code_text: str, file_path: Optional[Path]) -> Set[str]:
|
||||
"""
|
||||
Detect Class.NAME pattern in NODE_CLASS_MAPPINGS.
|
||||
|
||||
Pattern:
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
FreeChat.NAME: FreeChat,
|
||||
PaidChat.NAME: PaidChat
|
||||
}
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
parsed = ast.parse(code_text)
|
||||
except:
|
||||
return set()
|
||||
|
||||
nodes = set()
|
||||
|
||||
for node in parsed.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == 'NODE_CLASS_MAPPINGS':
|
||||
if isinstance(node.value, ast.Dict):
|
||||
for key in node.value.keys:
|
||||
# Detect Class.NAME pattern
|
||||
if isinstance(key, ast.Attribute):
|
||||
if isinstance(key.value, ast.Name):
|
||||
# Use class name as node name
|
||||
nodes.add(key.value.id)
|
||||
# Also handle literal strings
|
||||
elif isinstance(key, ast.Constant) and isinstance(key.value, str):
|
||||
nodes.add(key.value.strip())
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def _fallback_item_assignment(code_text: str) -> Set[str]:
|
||||
"""
|
||||
Detect item assignment pattern.
|
||||
|
||||
Pattern:
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS["MyNode"] = MyNode
|
||||
"""
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=SyntaxWarning)
|
||||
parsed = ast.parse(code_text)
|
||||
except:
|
||||
return set()
|
||||
|
||||
nodes = set()
|
||||
|
||||
for node in ast.walk(parsed):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Subscript):
|
||||
if (isinstance(target.value, ast.Name) and
|
||||
target.value.id in ['NODE_CLASS_MAPPINGS', 'NODE_CONFIG']):
|
||||
# Extract key
|
||||
if isinstance(target.slice, ast.Constant):
|
||||
if isinstance(target.slice.value, str):
|
||||
nodes.add(target.slice.value)
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def _extract_repo_name(file_path: Path) -> str:
|
||||
"""
|
||||
Extract repository name from file path.
|
||||
|
||||
Path structure: /home/rho/.tmp/analysis/temp/{author}_{reponame}/{path/to/file.py}
|
||||
Returns: {author}_{reponame} or filename if extraction fails
|
||||
"""
|
||||
try:
|
||||
parts = file_path.parts
|
||||
# Find 'temp' directory in path
|
||||
if 'temp' in parts:
|
||||
temp_idx = parts.index('temp')
|
||||
if temp_idx + 1 < len(parts):
|
||||
# Next part after 'temp' is the repo directory
|
||||
return parts[temp_idx + 1]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Fallback to filename if extraction fails
|
||||
return file_path.name if hasattr(file_path, 'name') else str(file_path)
|
||||
|
||||
|
||||
def _fallback_empty_dict_detector(code_text: str, file_path: Optional[Path], verbose: bool = False) -> None:
|
||||
"""
|
||||
Detect empty NODE_CLASS_MAPPINGS (UI-only extensions).
|
||||
Logs for documentation purposes only (when verbose=True).
|
||||
|
||||
Args:
|
||||
code_text: Python source code to analyze
|
||||
file_path: Path to the file being analyzed
|
||||
verbose: If True, print detection messages
|
||||
"""
|
||||
empty_patterns = [
|
||||
'NODE_CLASS_MAPPINGS = {}',
|
||||
'NODE_CLASS_MAPPINGS={}',
|
||||
]
|
||||
|
||||
code_normalized = code_text.replace(' ', '').replace('\n', '')
|
||||
|
||||
for pattern in empty_patterns:
|
||||
pattern_normalized = pattern.replace(' ', '')
|
||||
if pattern_normalized in code_normalized:
|
||||
if file_path and verbose:
|
||||
repo_name = _extract_repo_name(file_path)
|
||||
print(f"Info: UI-only extension (empty NODE_CLASS_MAPPINGS): {repo_name}")
|
||||
return
|
||||
|
||||
def has_comfy_node_base(class_node):
|
||||
"""Check if class inherits from io.ComfyNode or ComfyNode"""
|
||||
@ -229,6 +785,25 @@ def extract_v3_nodes(code_text):
|
||||
|
||||
|
||||
# scan
|
||||
def extract_metadata_only(filename):
|
||||
"""Extract only metadata (@author, @title, etc) without node scanning"""
|
||||
try:
|
||||
with open(filename, encoding='utf-8', errors='ignore') as file:
|
||||
code = file.read()
|
||||
|
||||
metadata = {}
|
||||
lines = code.strip().split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('@'):
|
||||
if line.startswith("@author:") or line.startswith("@title:") or line.startswith("@nickname:") or line.startswith("@description:"):
|
||||
key, value = line[1:].strip().split(':', 1)
|
||||
metadata[key.strip()] = value.strip()
|
||||
|
||||
return metadata
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
def scan_in_file(filename, is_builtin=False):
|
||||
global builtin_nodes
|
||||
|
||||
@ -242,8 +817,8 @@ def scan_in_file(filename, is_builtin=False):
|
||||
nodes = set()
|
||||
class_dict = {}
|
||||
|
||||
# V1 nodes detection
|
||||
nodes |= extract_nodes(code)
|
||||
# V1 nodes detection (enhanced with fallback patterns)
|
||||
nodes |= extract_nodes_enhanced(code, file_path=Path(filename), visited=set())
|
||||
|
||||
# V3 nodes detection
|
||||
nodes |= extract_v3_nodes(code)
|
||||
@ -620,13 +1195,14 @@ def update_custom_nodes(scan_only_mode=False, url_list_file=None):
|
||||
return node_info
|
||||
|
||||
|
||||
def gen_json(node_info, scan_only_mode=False):
|
||||
def gen_json(node_info, scan_only_mode=False, force_rescan=False):
|
||||
"""
|
||||
Generate extension-node-map.json from scanned node information
|
||||
|
||||
Args:
|
||||
node_info (dict): Repository metadata mapping
|
||||
scan_only_mode (bool): If True, exclude metadata from output
|
||||
force_rescan (bool): If True, ignore cache and force rescan all nodes
|
||||
"""
|
||||
# scan from .py file
|
||||
node_files, node_dirs = get_nodes(temp_dir)
|
||||
@ -642,13 +1218,17 @@ def gen_json(node_info, scan_only_mode=False):
|
||||
py_files = get_py_file_paths(dirname)
|
||||
metadata = {}
|
||||
|
||||
nodes = set()
|
||||
for py in py_files:
|
||||
nodes_in_file, metadata_in_file = scan_in_file(py, dirname == "ComfyUI")
|
||||
nodes.update(nodes_in_file)
|
||||
# Include metadata from .py files in both modes
|
||||
metadata.update(metadata_in_file)
|
||||
|
||||
# Use per-repo cache for node AND metadata extraction
|
||||
try:
|
||||
nodes, metadata = extract_nodes_from_repo(Path(dirname), verbose=False, force_rescan=force_rescan)
|
||||
except:
|
||||
# Fallback to file-by-file scanning if extract_nodes_from_repo fails
|
||||
nodes = set()
|
||||
for py in py_files:
|
||||
nodes_in_file, metadata_in_file = scan_in_file(py, dirname == "ComfyUI")
|
||||
nodes.update(nodes_in_file)
|
||||
metadata.update(metadata_in_file)
|
||||
|
||||
dirname = os.path.basename(dirname)
|
||||
|
||||
if 'Jovimetrix' in dirname:
|
||||
@ -810,11 +1390,14 @@ if __name__ == "__main__":
|
||||
print("\n# Generating 'extension-node-map.json'...\n")
|
||||
|
||||
# Generate extension-node-map.json
|
||||
gen_json(updated_node_info, scan_only_mode)
|
||||
force_rescan = args.force_rescan if hasattr(args, 'force_rescan') else False
|
||||
if force_rescan:
|
||||
print("⚠️ Force rescan enabled - ignoring all cached results\n")
|
||||
gen_json(updated_node_info, scan_only_mode, force_rescan)
|
||||
|
||||
print("\n✅ DONE.\n")
|
||||
|
||||
if scan_only_mode:
|
||||
print("Output: extension-node-map.json (node mappings only)")
|
||||
else:
|
||||
print("Output: extension-node-map.json (full metadata)")
|
||||
print("Output: extension-node-map.json (full metadata)")
|
||||
|
||||
19
tests-api/.gitignore
vendored
Normal file
19
tests-api/.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Pytest cache
|
||||
.pytest_cache/
|
||||
|
||||
# Coverage reports
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Test-specific resources
|
||||
resources/tmp/
|
||||
91
tests-api/README.md
Normal file
91
tests-api/README.md
Normal file
@ -0,0 +1,91 @@
|
||||
# ComfyUI-Manager API Tests
|
||||
|
||||
This directory contains tests for the ComfyUI-Manager API endpoints, validating the OpenAPI specification and ensuring API functionality.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install test dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-test.txt
|
||||
```
|
||||
|
||||
2. Ensure ComfyUI is running with ComfyUI-Manager installed:
|
||||
|
||||
```bash
|
||||
# Start ComfyUI with the default server
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
|
||||
```bash
|
||||
pytest -xvs
|
||||
```
|
||||
|
||||
### Run specific test files
|
||||
|
||||
```bash
|
||||
# Run only the spec validation tests
|
||||
pytest -xvs test_spec_validation.py
|
||||
|
||||
# Run only the custom node API tests
|
||||
pytest -xvs test_customnode_api.py
|
||||
```
|
||||
|
||||
### Run specific test functions
|
||||
|
||||
```bash
|
||||
# Run a specific test
|
||||
pytest -xvs test_customnode_api.py::test_get_custom_node_list
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
The tests use the following default configuration:
|
||||
|
||||
- Server URL: `http://localhost:8188`
|
||||
- Server timeout: 2 seconds
|
||||
- Wait between requests: 0.5 seconds
|
||||
- Maximum retries: 3
|
||||
|
||||
You can override these settings with environment variables:
|
||||
|
||||
```bash
|
||||
# Use a different server URL
|
||||
COMFYUI_SERVER_URL=http://localhost:8189 pytest -xvs
|
||||
```
|
||||
|
||||
## Test Categories
|
||||
|
||||
The tests are organized into the following categories:
|
||||
|
||||
1. **Spec Validation** (`test_spec_validation.py`): Validates that the OpenAPI specification is correct and complete.
|
||||
2. **Custom Node API** (`test_customnode_api.py`): Tests for custom node management endpoints.
|
||||
3. **Snapshot API** (`test_snapshot_api.py`): Tests for snapshot management endpoints.
|
||||
4. **Queue API** (`test_queue_api.py`): Tests for queue management endpoints.
|
||||
5. **Config API** (`test_config_api.py`): Tests for configuration endpoints.
|
||||
6. **Model API** (`test_model_api.py`): Tests for model management endpoints (minimal as these are being deprecated).
|
||||
|
||||
## Test Implementation Details
|
||||
|
||||
### Fixtures
|
||||
|
||||
- `test_config`: Provides the test configuration
|
||||
- `server_url`: Returns the server URL from the configuration
|
||||
- `openapi_spec`: Loads the OpenAPI specification
|
||||
- `api_client`: Creates a requests Session for API calls
|
||||
- `api_request`: Helper function for making consistent API requests
|
||||
|
||||
### Utilities
|
||||
|
||||
- `validation.py`: Functions for validating responses against the OpenAPI schema
|
||||
- `schema_utils.py`: Utilities for extracting and manipulating schemas
|
||||
|
||||
## Notes
|
||||
|
||||
- Some tests are skipped with `@pytest.mark.skip` to avoid modifying state in automated testing
|
||||
- Security-level restricted endpoints have minimal tests to avoid security issues
|
||||
- Tests focus on read operations rather than write operations where possible
|
||||
1
tests-api/__init__.py
Normal file
1
tests-api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Make tests-api directory a proper package
|
||||
237
tests-api/conftest.py
Normal file
237
tests-api/conftest.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
PyTest configuration and fixtures for API tests.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
import requests
|
||||
import tempfile
|
||||
import time
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Generator, Optional, Tuple
|
||||
|
||||
# Import test utilities
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Get the absolute path to the current file (conftest.py)
|
||||
current_file = Path(os.path.abspath(__file__))
|
||||
|
||||
# Get the directory containing the current file (the tests-api directory)
|
||||
tests_api_dir = current_file.parent
|
||||
|
||||
# Add the tests-api directory to the Python path
|
||||
if str(tests_api_dir) not in sys.path:
|
||||
sys.path.insert(0, str(tests_api_dir))
|
||||
|
||||
# Apply mocks for ComfyUI imports
|
||||
from mocks.patch import apply_mocks
|
||||
apply_mocks()
|
||||
|
||||
# Now we can import from utils.validation
|
||||
from utils.validation import load_openapi_spec
|
||||
|
||||
|
||||
# Default test configuration
|
||||
DEFAULT_TEST_CONFIG = {
|
||||
"server_url": "http://localhost:8188",
|
||||
"server_timeout": 2, # seconds
|
||||
"wait_between_requests": 0.5, # seconds
|
||||
"max_retries": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_config() -> Dict:
|
||||
"""
|
||||
Load test configuration from environment variables or use defaults.
|
||||
"""
|
||||
config = DEFAULT_TEST_CONFIG.copy()
|
||||
|
||||
# Override from environment variables if present
|
||||
if "COMFYUI_SERVER_URL" in os.environ:
|
||||
config["server_url"] = os.environ["COMFYUI_SERVER_URL"]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def server_url(test_config: Dict) -> str:
|
||||
"""
|
||||
Get the server URL from the test configuration.
|
||||
"""
|
||||
return test_config["server_url"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def openapi_spec() -> Dict:
|
||||
"""
|
||||
Load the OpenAPI specification.
|
||||
"""
|
||||
return load_openapi_spec()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_client(server_url: str, test_config: Dict) -> requests.Session:
|
||||
"""
|
||||
Create a requests Session for API calls.
|
||||
"""
|
||||
session = requests.Session()
|
||||
|
||||
# Check if the server is running
|
||||
try:
|
||||
response = session.get(f"{server_url}/", timeout=test_config["server_timeout"])
|
||||
response.raise_for_status()
|
||||
except (requests.ConnectionError, requests.Timeout, requests.HTTPError):
|
||||
pytest.skip("ComfyUI server is not running or not accessible")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def temp_dir() -> Generator[Path, None, None]:
|
||||
"""
|
||||
Create a temporary directory for test files.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
yield Path(temp_dir)
|
||||
|
||||
|
||||
class SecurityLevelContext:
|
||||
"""
|
||||
Context manager for setting and restoring security levels.
|
||||
"""
|
||||
def __init__(self, api_client: requests.Session, server_url: str, security_level: str):
|
||||
self.api_client = api_client
|
||||
self.server_url = server_url
|
||||
self.security_level = security_level
|
||||
self.original_level = None
|
||||
|
||||
async def __aenter__(self):
|
||||
# Get the current security level (not directly exposed in API, would require more setup)
|
||||
# For now, we'll just set the new level
|
||||
|
||||
# Set the new security level
|
||||
# Note: In a real implementation, we would need a way to set this
|
||||
# This is a placeholder - the actual implementation would depend on how
|
||||
# security levels are managed in ComfyUI-Manager
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# Restore the original security level if needed
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def security_level_context(api_client: requests.Session, server_url: str):
|
||||
"""
|
||||
Create a context manager for setting security levels.
|
||||
"""
|
||||
return lambda level: SecurityLevelContext(api_client, server_url, level)
|
||||
|
||||
|
||||
def make_api_url(server_url: str, path: str) -> str:
|
||||
"""
|
||||
Construct a full API URL from the server URL and path.
|
||||
"""
|
||||
# Ensure the path starts with a slash
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
|
||||
# Remove trailing slash from server_url if present
|
||||
if server_url.endswith("/"):
|
||||
server_url = server_url[:-1]
|
||||
|
||||
return f"{server_url}{path}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_request(api_client: requests.Session, server_url: str, test_config: Dict):
|
||||
"""
|
||||
Helper function for making API requests with consistent behavior.
|
||||
"""
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[Dict] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
expected_status: int = 200,
|
||||
retry_on_error: bool = True,
|
||||
) -> Tuple[requests.Response, Optional[Dict]]:
|
||||
"""
|
||||
Make an API request with automatic validation.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
path: API path
|
||||
params: Query parameters
|
||||
json_data: JSON request body
|
||||
headers: HTTP headers
|
||||
expected_status: Expected HTTP status code
|
||||
retry_on_error: Whether to retry on connection errors
|
||||
|
||||
Returns:
|
||||
Tuple of (Response object, JSON response data or None)
|
||||
"""
|
||||
method = method.lower()
|
||||
url = make_api_url(server_url, path)
|
||||
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
# Add common headers
|
||||
headers.setdefault("Accept", "application/json")
|
||||
|
||||
# Sleep between requests to avoid overwhelming the server
|
||||
time.sleep(test_config["wait_between_requests"])
|
||||
|
||||
retries = test_config["max_retries"] if retry_on_error else 0
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
if method == "get":
|
||||
response = api_client.get(url, params=params, headers=headers)
|
||||
elif method == "post":
|
||||
response = api_client.post(url, params=params, json=json_data, headers=headers)
|
||||
elif method == "put":
|
||||
response = api_client.put(url, params=params, json=json_data, headers=headers)
|
||||
elif method == "delete":
|
||||
response = api_client.delete(url, params=params, headers=headers)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
# Check status code
|
||||
assert response.status_code == expected_status, (
|
||||
f"Expected status code {expected_status}, got {response.status_code}"
|
||||
)
|
||||
|
||||
# Parse JSON response if possible
|
||||
json_response = None
|
||||
if response.headers.get("Content-Type", "").startswith("application/json"):
|
||||
try:
|
||||
json_response = response.json()
|
||||
except json.JSONDecodeError:
|
||||
if expected_status == 200:
|
||||
raise ValueError("Response was not valid JSON")
|
||||
|
||||
return response, json_response
|
||||
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
last_exception = e
|
||||
if attempt < retries:
|
||||
# Wait before retrying
|
||||
time.sleep(1)
|
||||
continue
|
||||
break
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
|
||||
raise RuntimeError("Failed to make API request")
|
||||
|
||||
return _request
|
||||
1
tests-api/mocks/__init__.py
Normal file
1
tests-api/mocks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Make tests-api/mocks directory a proper package
|
||||
26
tests-api/mocks/custom_node_manager.py
Normal file
26
tests-api/mocks/custom_node_manager.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""
|
||||
Mock CustomNodeManager for testing purposes
|
||||
"""
|
||||
|
||||
class CustomNodeManager:
|
||||
"""
|
||||
Mock implementation of the CustomNodeManager class
|
||||
"""
|
||||
instance = None
|
||||
|
||||
def __init__(self):
|
||||
self.custom_nodes = {}
|
||||
self.node_paths = []
|
||||
self.refresh_timeout = None
|
||||
|
||||
def get_node_path(self, node_class):
|
||||
"""
|
||||
Mock implementation to get the path for a node class
|
||||
"""
|
||||
return self.custom_nodes.get(node_class, None)
|
||||
|
||||
def update_node_paths(self):
|
||||
"""
|
||||
Mock implementation to update node paths
|
||||
"""
|
||||
pass
|
||||
116
tests-api/mocks/patch.py
Normal file
116
tests-api/mocks/patch.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Patch module to mock imports for testing
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Import mock modules
|
||||
from mocks.prompt_server import PromptServer
|
||||
from mocks.custom_node_manager import CustomNodeManager
|
||||
|
||||
# Current directory
|
||||
current_dir = Path(__file__).parent.parent # tests-api directory
|
||||
|
||||
# Define mocks
|
||||
class MockModule:
|
||||
"""Base class for mock modules"""
|
||||
pass
|
||||
|
||||
# Create server mock module with PromptServer
|
||||
server_mock = MockModule()
|
||||
server_mock.PromptServer = PromptServer
|
||||
prompt_server_instance = PromptServer()
|
||||
server_mock.PromptServer.instance = prompt_server_instance
|
||||
server_mock.PromptServer.inst = prompt_server_instance
|
||||
|
||||
# Create app mock module with custom_node_manager submodule
|
||||
app_mock = MockModule()
|
||||
app_custom_node_manager = MockModule()
|
||||
app_custom_node_manager.CustomNodeManager = CustomNodeManager
|
||||
app_custom_node_manager.CustomNodeManager.instance = CustomNodeManager()
|
||||
|
||||
# Create utils mock module with json_util submodule
|
||||
utils_mock = MockModule()
|
||||
utils_json_util = MockModule()
|
||||
|
||||
# Create utils.validation and utils.schema_utils submodules
|
||||
utils_validation = MockModule()
|
||||
utils_schema_utils = MockModule()
|
||||
|
||||
# Import actual modules (make sure path is set up correctly)
|
||||
sys.path.insert(0, str(current_dir))
|
||||
|
||||
try:
|
||||
# Import the validation module
|
||||
from utils.validation import load_openapi_spec
|
||||
utils_validation.load_openapi_spec = load_openapi_spec
|
||||
|
||||
# Import all schema_utils functions
|
||||
from utils.schema_utils import (
|
||||
get_all_paths,
|
||||
get_grouped_paths,
|
||||
get_methods_for_path,
|
||||
find_paths_with_security,
|
||||
get_content_types_for_response,
|
||||
get_required_parameters
|
||||
)
|
||||
|
||||
utils_schema_utils.get_all_paths = get_all_paths
|
||||
utils_schema_utils.get_grouped_paths = get_grouped_paths
|
||||
utils_schema_utils.get_methods_for_path = get_methods_for_path
|
||||
utils_schema_utils.find_paths_with_security = find_paths_with_security
|
||||
utils_schema_utils.get_content_types_for_response = get_content_types_for_response
|
||||
utils_schema_utils.get_required_parameters = get_required_parameters
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error importing test utilities: {e}")
|
||||
# Define dummy functions if imports fail
|
||||
def dummy_load_openapi_spec():
|
||||
"""Dummy function for testing"""
|
||||
return {"paths": {}}
|
||||
utils_validation.load_openapi_spec = dummy_load_openapi_spec
|
||||
|
||||
def dummy_get_all_paths(spec):
|
||||
return list(spec.get("paths", {}).keys())
|
||||
utils_schema_utils.get_all_paths = dummy_get_all_paths
|
||||
|
||||
def dummy_get_grouped_paths(spec):
|
||||
return {}
|
||||
utils_schema_utils.get_grouped_paths = dummy_get_grouped_paths
|
||||
|
||||
def dummy_get_methods_for_path(spec, path):
|
||||
return []
|
||||
utils_schema_utils.get_methods_for_path = dummy_get_methods_for_path
|
||||
|
||||
def dummy_find_paths_with_security(spec, security_scheme=None):
|
||||
return []
|
||||
utils_schema_utils.find_paths_with_security = dummy_find_paths_with_security
|
||||
|
||||
def dummy_get_content_types_for_response(spec, path, method, status_code="200"):
|
||||
return []
|
||||
utils_schema_utils.get_content_types_for_response = dummy_get_content_types_for_response
|
||||
|
||||
def dummy_get_required_parameters(spec, path, method):
|
||||
return []
|
||||
utils_schema_utils.get_required_parameters = dummy_get_required_parameters
|
||||
|
||||
# Add merge_json_recursive from our mock utils
|
||||
from mocks.utils import merge_json_recursive
|
||||
utils_json_util.merge_json_recursive = merge_json_recursive
|
||||
|
||||
# Apply the mocks to sys.modules
|
||||
def apply_mocks():
|
||||
"""Apply all mocks to sys.modules"""
|
||||
sys.modules['server'] = server_mock
|
||||
sys.modules['app'] = app_mock
|
||||
sys.modules['app.custom_node_manager'] = app_custom_node_manager
|
||||
sys.modules['utils'] = utils_mock
|
||||
sys.modules['utils.json_util'] = utils_json_util
|
||||
sys.modules['utils.validation'] = utils_validation
|
||||
sys.modules['utils.schema_utils'] = utils_schema_utils
|
||||
|
||||
# Make sure our actual utils module is importable
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, str(current_dir))
|
||||
71
tests-api/mocks/prompt_server.py
Normal file
71
tests-api/mocks/prompt_server.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""
|
||||
Mock PromptServer for testing purposes
|
||||
"""
|
||||
|
||||
class MockRoutes:
|
||||
"""
|
||||
Mock routing class with method decorators
|
||||
"""
|
||||
def __init__(self):
|
||||
self.routes = {}
|
||||
|
||||
def get(self, path):
|
||||
"""Decorator for GET routes"""
|
||||
def decorator(f):
|
||||
self.routes[('GET', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
def post(self, path):
|
||||
"""Decorator for POST routes"""
|
||||
def decorator(f):
|
||||
self.routes[('POST', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
def put(self, path):
|
||||
"""Decorator for PUT routes"""
|
||||
def decorator(f):
|
||||
self.routes[('PUT', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
def delete(self, path):
|
||||
"""Decorator for DELETE routes"""
|
||||
def decorator(f):
|
||||
self.routes[('DELETE', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
class PromptServer:
|
||||
"""
|
||||
Mock implementation of the PromptServer class
|
||||
"""
|
||||
instance = None
|
||||
inst = None
|
||||
|
||||
def __init__(self):
|
||||
self.routes = MockRoutes()
|
||||
self.registered_paths = set()
|
||||
self.base_url = "http://127.0.0.1:8188" # Assuming server is running on default port
|
||||
self.queue_lock = None
|
||||
|
||||
def add_route(self, method, path, handler, *args, **kwargs):
|
||||
"""
|
||||
Add a mock route to the server
|
||||
"""
|
||||
self.routes.routes[(method.upper(), path)] = handler
|
||||
self.registered_paths.add(path)
|
||||
|
||||
async def send_msg(self, message, data=None):
|
||||
"""
|
||||
Mock send_msg method (does nothing in the mock)
|
||||
"""
|
||||
pass
|
||||
|
||||
def send_sync(self, message, data=None):
|
||||
"""
|
||||
Mock send_sync method (does nothing in the mock)
|
||||
"""
|
||||
pass
|
||||
20
tests-api/mocks/utils.py
Normal file
20
tests-api/mocks/utils.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""
|
||||
Mock utils module for testing purposes
|
||||
"""
|
||||
|
||||
def merge_json_recursive(a, b):
|
||||
"""
|
||||
Mock implementation of merge_json_recursive
|
||||
"""
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
result = a.copy()
|
||||
for key, value in b.items():
|
||||
if key in result and isinstance(result[key], (dict, list)) and isinstance(value, (dict, list)):
|
||||
result[key] = merge_json_recursive(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
elif isinstance(a, list) and isinstance(b, list):
|
||||
return a + b
|
||||
else:
|
||||
return b
|
||||
382
tests-api/openapi.yaml
Normal file
382
tests-api/openapi.yaml
Normal file
@ -0,0 +1,382 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: ComfyUI-Manager API
|
||||
description: API for managing ComfyUI extensions, custom nodes, and models
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: ComfyUI Community
|
||||
url: https://github.com/comfyanonymous/ComfyUI
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8188
|
||||
description: Local ComfyUI server
|
||||
|
||||
paths:
|
||||
/customnode/getlist:
|
||||
get:
|
||||
summary: Get the list of custom nodes
|
||||
description: Returns the list of custom nodes from all configured channels
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode to retrieve (local=installed nodes, remote=available nodes)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, remote]
|
||||
default: remote
|
||||
responses:
|
||||
'200':
|
||||
description: List of custom nodes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomNode'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/customnode/get_node_mappings:
|
||||
get:
|
||||
summary: Get mappings between node class names and their custom nodes
|
||||
description: Returns mappings that help identify which custom node package provides specific node classes
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode for mappings (local=installed nodes, nickname=node nicknames)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, nickname]
|
||||
default: local
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Node mappings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/customnode/get_node_alternatives:
|
||||
get:
|
||||
summary: Get alternative nodes for specific node classes
|
||||
description: Returns alternative implementations of node classes from different custom node packages
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode to retrieve alternatives (local=installed nodes, remote=all available nodes)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, remote]
|
||||
default: remote
|
||||
responses:
|
||||
'200':
|
||||
description: Node alternatives
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/externalmodel/getlist:
|
||||
get:
|
||||
summary: Get the list of external models
|
||||
description: Returns the list of models from all configured channels
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode to retrieve (local=installed models, remote=available models)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, remote]
|
||||
default: remote
|
||||
responses:
|
||||
'200':
|
||||
description: List of external models
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExternalModel'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/manager/get_config:
|
||||
get:
|
||||
summary: Get manager configuration
|
||||
description: Returns the current configuration of ComfyUI-Manager
|
||||
parameters:
|
||||
- name: key
|
||||
in: query
|
||||
description: "The configuration key to retrieve"
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Configuration value
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
'400':
|
||||
description: Invalid key or missing parameter
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/manager/set_config:
|
||||
post:
|
||||
summary: Set manager configuration
|
||||
description: Updates the configuration of ComfyUI-Manager
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: "The configuration key to update"
|
||||
value:
|
||||
type: string
|
||||
description: "The new value for the configuration key"
|
||||
responses:
|
||||
'200':
|
||||
description: Configuration updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
'400':
|
||||
description: Invalid key or value
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/snapshot/getlist:
|
||||
get:
|
||||
summary: Get the list of snapshots
|
||||
description: Returns the list of saved snapshots
|
||||
responses:
|
||||
'200':
|
||||
description: List of snapshots
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
snapshots:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Snapshot'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/comfyui_manager/queue/status:
|
||||
get:
|
||||
summary: Get queue status
|
||||
description: Returns the current status of the operation queue
|
||||
responses:
|
||||
'200':
|
||||
description: Queue status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QueueStatus'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
components:
|
||||
schemas:
|
||||
CustomNode:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- title
|
||||
- reference
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Internal name/ID of the custom node"
|
||||
title:
|
||||
type: string
|
||||
description: "Display title of the custom node"
|
||||
reference:
|
||||
type: string
|
||||
description: "Reference URL (usually GitHub repository URL)"
|
||||
description:
|
||||
type: string
|
||||
description: "Description of what the custom node does"
|
||||
install_type:
|
||||
type: string
|
||||
enum: [git, pip, copy]
|
||||
description: "Installation method for the custom node"
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of files provided by this custom node"
|
||||
node_class_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of node class names provided by this custom node"
|
||||
installed:
|
||||
type: boolean
|
||||
description: "Whether the custom node is installed"
|
||||
version:
|
||||
type: string
|
||||
description: "Version of the custom node"
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "Tags associated with the custom node"
|
||||
|
||||
ExternalModel:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- type
|
||||
- url
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Name of the model"
|
||||
type:
|
||||
type: string
|
||||
description: "Type of the model (checkpoint, lora, embedding, etc.)"
|
||||
url:
|
||||
type: string
|
||||
description: "Download URL for the model"
|
||||
description:
|
||||
type: string
|
||||
description: "Description of the model"
|
||||
size:
|
||||
type: integer
|
||||
description: "Size of the model in bytes"
|
||||
installed:
|
||||
type: boolean
|
||||
description: "Whether the model is installed"
|
||||
version:
|
||||
type: string
|
||||
description: "Version of the model"
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "Tags associated with the model"
|
||||
|
||||
Snapshot:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- date
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Name of the snapshot"
|
||||
date:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Date when the snapshot was created"
|
||||
description:
|
||||
type: string
|
||||
description: "Description of the snapshot"
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of custom nodes in the snapshot"
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of models in the snapshot"
|
||||
|
||||
QueueStatus:
|
||||
type: object
|
||||
properties:
|
||||
pending:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QueueItem'
|
||||
description: "List of pending operations in the queue"
|
||||
completed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QueueItem'
|
||||
description: "List of completed operations in the queue"
|
||||
failed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QueueItem'
|
||||
description: "List of failed operations in the queue"
|
||||
running:
|
||||
type: boolean
|
||||
description: "Whether the queue is currently running"
|
||||
|
||||
QueueItem:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
- target
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Unique ID of the queue item"
|
||||
type:
|
||||
type: string
|
||||
enum: [install, update, uninstall]
|
||||
description: "Type of operation"
|
||||
target:
|
||||
type: string
|
||||
description: "Target of the operation (e.g., custom node name, model name)"
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, processing, completed, failed]
|
||||
description: "Current status of the operation"
|
||||
error:
|
||||
type: string
|
||||
description: "Error message if the operation failed"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Time when the operation was added to the queue"
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Time when the operation was completed"
|
||||
|
||||
securitySchemes:
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
description: "API key for authentication"
|
||||
6
tests-api/requirements-test.txt
Normal file
6
tests-api/requirements-test.txt
Normal file
@ -0,0 +1,6 @@
|
||||
pytest>=7.3.1
|
||||
requests>=2.31.0
|
||||
openapi-spec-validator>=0.6.0
|
||||
jsonschema>=4.17.3
|
||||
pytest-asyncio>=0.21.0
|
||||
pyyaml>=6.0
|
||||
270
tests-api/test_config_api.py
Normal file
270
tests-api/test_config_api.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""
|
||||
Tests for configuration endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from typing import Callable, Dict, List, Tuple
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
def test_get_preview_method(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current preview method.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/preview_method"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is one of the valid preview methods
|
||||
assert response.text in ["auto", "latent2rgb", "taesd", "none"]
|
||||
|
||||
|
||||
def test_get_db_mode(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current database mode.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/db_mode"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is one of the valid database modes
|
||||
assert response.text in ["channel", "local", "remote"]
|
||||
|
||||
|
||||
def test_get_component_policy(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current component policy.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/policy/component"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Component policy could be any string
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
def test_get_update_policy(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current update policy.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/policy/update"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is one of the valid update policies
|
||||
assert response.text in ["stable", "nightly", "nightly-comfyui"]
|
||||
|
||||
|
||||
def test_get_channel_url_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test getting the channel URL list.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/channel_url_list"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "selected" in json_data
|
||||
assert "list" in json_data
|
||||
assert isinstance(json_data["list"], list)
|
||||
|
||||
# Each channel should have a name and URL
|
||||
if json_data["list"]:
|
||||
first_channel = json_data["list"][0]
|
||||
assert "name" in first_channel
|
||||
assert "url" in first_channel
|
||||
|
||||
|
||||
def test_get_manager_version(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the manager version.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/version"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is a version string
|
||||
assert response.text.startswith("V") # Version strings start with V
|
||||
|
||||
|
||||
def test_get_manager_notice(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the manager notice.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/notice"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is HTML content
|
||||
assert response.headers.get("Content-Type", "").startswith("text/html") or "ComfyUI" in response.text
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operations")
|
||||
class TestConfigChanges:
|
||||
"""
|
||||
Tests for changing configuration settings.
|
||||
These are skipped to avoid modifying state in automated tests.
|
||||
"""
|
||||
|
||||
@pytest.fixture(scope="class", autouse=True)
|
||||
def save_original_config(self, api_request: Callable):
|
||||
"""
|
||||
Save the original configuration to restore after tests.
|
||||
"""
|
||||
# Save original values
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
expected_status=200,
|
||||
)
|
||||
self.original_preview_method = response.text
|
||||
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
expected_status=200,
|
||||
)
|
||||
self.original_db_mode = response.text
|
||||
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
expected_status=200,
|
||||
)
|
||||
self.original_update_policy = response.text
|
||||
|
||||
yield
|
||||
|
||||
# Restore original values
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
params={"value": self.original_preview_method},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
params={"value": self.original_db_mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
params={"value": self.original_update_policy},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
def test_set_preview_method(self, api_request: Callable):
|
||||
"""
|
||||
Test setting the preview method.
|
||||
"""
|
||||
# Set to a different value (taesd)
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
params={"value": "taesd"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify it was changed
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
expected_status=200,
|
||||
)
|
||||
assert response.text == "taesd"
|
||||
|
||||
def test_set_db_mode(self, api_request: Callable):
|
||||
"""
|
||||
Test setting the database mode.
|
||||
"""
|
||||
# Set to local mode
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
params={"value": "local"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify it was changed
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
expected_status=200,
|
||||
)
|
||||
assert response.text == "local"
|
||||
|
||||
def test_set_update_policy(self, api_request: Callable):
|
||||
"""
|
||||
Test setting the update policy.
|
||||
"""
|
||||
# Set to stable
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
params={"value": "stable"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify it was changed
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
expected_status=200,
|
||||
)
|
||||
assert response.text == "stable"
|
||||
200
tests-api/test_customnode_api.py
Normal file
200
tests-api/test_customnode_api.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""
|
||||
Tests for custom node management endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Tuple
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "remote"]
|
||||
)
|
||||
def test_get_custom_node_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing custom nodes.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/getlist"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode, "skip_update": "true"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "channel" in json_data
|
||||
assert "node_packs" in json_data
|
||||
assert isinstance(json_data["node_packs"], dict)
|
||||
|
||||
# If there are any node packs, verify they have the expected structure
|
||||
if json_data["node_packs"]:
|
||||
# Take the first node pack to validate
|
||||
first_node_pack = next(iter(json_data["node_packs"].values()))
|
||||
assert "title" in first_node_pack
|
||||
assert "name" in first_node_pack
|
||||
|
||||
|
||||
def test_get_installed_nodes(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing installed nodes.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/installed"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response is a dictionary of node packs
|
||||
assert isinstance(json_data, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "nickname"]
|
||||
)
|
||||
def test_get_node_mappings(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting node-to-package mappings.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/getmappings"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response is a dictionary mapping extension IDs to node info
|
||||
assert isinstance(json_data, dict)
|
||||
|
||||
# If there are any mappings, verify they have the expected structure
|
||||
if json_data:
|
||||
# Take the first mapping to validate
|
||||
first_mapping = next(iter(json_data.values()))
|
||||
assert isinstance(first_mapping, list)
|
||||
assert len(first_mapping) == 2
|
||||
assert isinstance(first_mapping[0], list) # List of node classes
|
||||
assert isinstance(first_mapping[1], dict) # Metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "remote"]
|
||||
)
|
||||
def test_get_node_alternatives(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting alternative node options.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/alternatives"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response is a dictionary
|
||||
assert isinstance(json_data, dict)
|
||||
|
||||
|
||||
def test_fetch_updates(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for fetching updates.
|
||||
This might modify state, so we just check for a valid response.
|
||||
"""
|
||||
# Make the API request with skip_update=true to avoid actual updates
|
||||
path = "/customnode/fetch_updates"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": "local"},
|
||||
# Don't validate JSON since this endpoint doesn't return JSON
|
||||
expected_status=200,
|
||||
retry_on_error=False, # Don't retry as this might have side effects
|
||||
)
|
||||
|
||||
# Just check the status code is as expected (covered by api_request)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Queue endpoints are better tested with queue operations")
|
||||
def test_queue_update_all(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for queuing updates for all nodes.
|
||||
Skipping as this would actually modify the installation.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Security-restricted endpoint")
|
||||
def test_install_node_via_git_url(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for installing a node via Git URL.
|
||||
Skipping as this requires high security level and would modify the installation.
|
||||
"""
|
||||
pass
|
||||
23
tests-api/test_import.py
Normal file
23
tests-api/test_import.py
Normal file
@ -0,0 +1,23 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Print current working directory
|
||||
print(f"Current directory: {os.getcwd()}")
|
||||
|
||||
# Print module search path
|
||||
print(f"System path: {sys.path}")
|
||||
|
||||
# Try to import
|
||||
try:
|
||||
from utils.validation import load_openapi_spec
|
||||
print("Import successful!")
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}")
|
||||
|
||||
# Try direct import
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "custom_nodes/ComfyUI-Manager/tests-api"))
|
||||
from utils.validation import load_openapi_spec
|
||||
print("Direct import successful!")
|
||||
except ImportError as e:
|
||||
print(f"Direct import error: {e}")
|
||||
62
tests-api/test_model_api.py
Normal file
62
tests-api/test_model_api.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
Tests for model management endpoints.
|
||||
These features are scheduled for deprecation, so tests are minimal.
|
||||
"""
|
||||
import pytest
|
||||
from typing import Callable, Dict
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "remote"]
|
||||
)
|
||||
def test_get_external_model_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing external models.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/externalmodel/getlist"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "models" in json_data
|
||||
assert isinstance(json_data["models"], list)
|
||||
|
||||
# If there are any models, verify they have the expected structure
|
||||
if json_data["models"]:
|
||||
first_model = json_data["models"][0]
|
||||
assert "name" in first_model
|
||||
assert "type" in first_model
|
||||
assert "url" in first_model
|
||||
assert "filename" in first_model
|
||||
assert "installed" in first_model
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_install_model():
|
||||
"""
|
||||
Test queuing a model installation.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
This feature is also scheduled for deprecation.
|
||||
"""
|
||||
pass
|
||||
213
tests-api/test_queue_api.py
Normal file
213
tests-api/test_queue_api.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""
|
||||
Tests for queue management endpoints.
|
||||
"""
|
||||
import pytest
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Tuple
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
def test_get_queue_status(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting queue status.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/queue/status"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "total_count" in json_data
|
||||
assert "done_count" in json_data
|
||||
assert "in_progress_count" in json_data
|
||||
assert "is_processing" in json_data
|
||||
|
||||
# Type checks
|
||||
assert isinstance(json_data["total_count"], int)
|
||||
assert isinstance(json_data["done_count"], int)
|
||||
assert isinstance(json_data["in_progress_count"], int)
|
||||
assert isinstance(json_data["is_processing"], bool)
|
||||
|
||||
|
||||
def test_reset_queue(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for resetting the queue.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/queue/reset"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Now check the queue status to verify it was reset
|
||||
response2, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Queue should be empty after reset
|
||||
assert json_data["total_count"] == json_data["done_count"] + json_data["in_progress_count"]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_queue_install_node():
|
||||
"""
|
||||
Test queuing a node installation.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_queue_update_node():
|
||||
"""
|
||||
Test queuing a node update.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_queue_uninstall_node():
|
||||
"""
|
||||
Test queuing a node uninstallation.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation")
|
||||
def test_queue_start():
|
||||
"""
|
||||
Test starting the queue.
|
||||
Skipped to avoid modifying state.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TestQueueOperations:
|
||||
"""
|
||||
Test a complete queue workflow.
|
||||
These tests are grouped to ensure proper sequencing but are still skipped
|
||||
to avoid modifying state in automated tests.
|
||||
"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def node_data(self) -> Dict:
|
||||
"""
|
||||
Create test data for a node operation.
|
||||
"""
|
||||
# This would be replaced with actual data for a known safe node
|
||||
return {
|
||||
"ui_id": "test_node_1",
|
||||
"id": "comfyui-manager", # Manager itself
|
||||
"version": "latest",
|
||||
"channel": "default",
|
||||
"mode": "local",
|
||||
}
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation")
|
||||
def test_queue_operation_sequence(
|
||||
self,
|
||||
api_request: Callable,
|
||||
node_data: Dict
|
||||
):
|
||||
"""
|
||||
Test the queue operation sequence.
|
||||
"""
|
||||
# 1. Reset the queue
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/queue/reset",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# 2. Queue a node operation (we'll use the manager itself)
|
||||
api_request(
|
||||
method="post",
|
||||
path="/manager/queue/update",
|
||||
json_data=node_data,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# 3. Check queue status - should have one operation
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
assert json_data["total_count"] > 0
|
||||
assert not json_data["is_processing"] # Queue hasn't started yet
|
||||
|
||||
# 4. Start the queue
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/queue/start",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# 5. Check queue status again - should be processing
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Queue should be processing or already done
|
||||
assert json_data["is_processing"] or json_data["done_count"] == json_data["total_count"]
|
||||
|
||||
# 6. Wait for queue to complete (with timeout)
|
||||
max_wait_time = 60 # seconds
|
||||
start_time = time.time()
|
||||
completed = False
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
if json_data["done_count"] == json_data["total_count"] and not json_data["is_processing"]:
|
||||
completed = True
|
||||
break
|
||||
|
||||
time.sleep(2) # Wait before checking again
|
||||
|
||||
assert completed, "Queue did not complete within timeout period"
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation")
|
||||
def test_concurrent_queue_operations(
|
||||
self,
|
||||
api_request: Callable,
|
||||
node_data: Dict
|
||||
):
|
||||
"""
|
||||
Test concurrent queue operations.
|
||||
"""
|
||||
# This would test adding multiple operations to the queue
|
||||
# and verifying they all complete correctly
|
||||
pass
|
||||
198
tests-api/test_snapshot_api.py
Normal file
198
tests-api/test_snapshot_api.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""
|
||||
Tests for snapshot management endpoints.
|
||||
"""
|
||||
import pytest
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
def test_get_snapshot_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing snapshots.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/snapshot/getlist"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "items" in json_data
|
||||
assert isinstance(json_data["items"], list)
|
||||
|
||||
|
||||
def test_get_current_snapshot(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting the current snapshot.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/snapshot/get_current"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Check for basic snapshot structure
|
||||
assert "snapshot_date" in json_data
|
||||
assert "custom_nodes" in json_data
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This test creates a snapshot which is a state-modifying operation")
|
||||
def test_save_snapshot(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for saving a new snapshot.
|
||||
Skipped to avoid modifying state in tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This test removes a snapshot which is a destructive operation")
|
||||
def test_remove_snapshot(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for removing a snapshot.
|
||||
Skipped to avoid modifying state in tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This test restores a snapshot which is a state-modifying operation")
|
||||
def test_restore_snapshot(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for restoring a snapshot.
|
||||
Skipped to avoid modifying state in tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TestSnapshotWorkflow:
|
||||
"""
|
||||
Test the complete snapshot workflow (create, list, get, remove).
|
||||
These tests are grouped to ensure proper sequencing but are still skipped
|
||||
to avoid modifying state in automated tests.
|
||||
"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def snapshot_name(self) -> str:
|
||||
"""
|
||||
Generate a unique snapshot name for testing.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"test_snapshot_{timestamp}"
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying test")
|
||||
def test_create_snapshot(
|
||||
self,
|
||||
api_request: Callable,
|
||||
snapshot_name: str
|
||||
):
|
||||
"""
|
||||
Test creating a snapshot.
|
||||
"""
|
||||
# Make the API request to save a snapshot
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/snapshot/save",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify a snapshot was created (would need to check the snapshot list)
|
||||
response2, json_data = api_request(
|
||||
method="get",
|
||||
path="/snapshot/getlist",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# The most recently created snapshot should be first in the list
|
||||
assert json_data["items"]
|
||||
|
||||
# Store the snapshot name for later tests
|
||||
self.actual_snapshot_name = json_data["items"][0]
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying test")
|
||||
def test_get_snapshot_details(
|
||||
self,
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test getting details of the created snapshot.
|
||||
"""
|
||||
# This would check the current snapshot, not a specific one
|
||||
# since there's no direct API to get a specific snapshot
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/snapshot/get_current",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate the snapshot data
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path="/snapshot/get_current",
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying test")
|
||||
def test_remove_test_snapshot(
|
||||
self,
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test removing the test snapshot.
|
||||
"""
|
||||
# Make the API request to remove the snapshot
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/snapshot/remove",
|
||||
params={"target": self.actual_snapshot_name},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the snapshot was removed
|
||||
response2, json_data = api_request(
|
||||
method="get",
|
||||
path="/snapshot/getlist",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# The snapshot should no longer be in the list
|
||||
assert self.actual_snapshot_name not in json_data["items"]
|
||||
150
tests-api/test_spec_validation.py
Normal file
150
tests-api/test_spec_validation.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""
|
||||
Tests for validating the OpenAPI specification.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
import yaml
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from pathlib import Path
|
||||
from openapi_spec_validator import validate_spec
|
||||
from utils.validation import load_openapi_spec
|
||||
from utils.schema_utils import (
|
||||
get_all_paths,
|
||||
get_methods_for_path,
|
||||
find_paths_with_security,
|
||||
get_required_parameters
|
||||
)
|
||||
|
||||
|
||||
def test_spec_is_valid():
|
||||
"""
|
||||
Test that the OpenAPI specification is valid according to the spec validator.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
validate_spec(spec)
|
||||
|
||||
|
||||
def test_spec_has_info():
|
||||
"""
|
||||
Test that the OpenAPI specification has basic info.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
assert "info" in spec
|
||||
assert "title" in spec["info"]
|
||||
assert "version" in spec["info"]
|
||||
assert spec["info"]["title"] == "ComfyUI-Manager API"
|
||||
|
||||
|
||||
def test_spec_has_paths():
|
||||
"""
|
||||
Test that the OpenAPI specification has paths defined.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
assert "paths" in spec
|
||||
assert len(spec["paths"]) > 0
|
||||
|
||||
|
||||
def test_paths_have_responses():
|
||||
"""
|
||||
Test that all paths have responses defined.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
assert "responses" in operation, f"Path {path} method {method} has no responses"
|
||||
assert len(operation["responses"]) > 0, f"Path {path} method {method} has empty responses"
|
||||
|
||||
|
||||
def test_responses_have_schemas():
|
||||
"""
|
||||
Test that responses with application/json content type have schemas.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
for status, response in operation["responses"].items():
|
||||
if "content" not in response:
|
||||
continue
|
||||
|
||||
if "application/json" in response["content"]:
|
||||
assert "schema" in response["content"]["application/json"], (
|
||||
f"Path {path} method {method} status {status} "
|
||||
f"application/json content has no schema"
|
||||
)
|
||||
|
||||
|
||||
def test_required_parameters_have_schemas():
|
||||
"""
|
||||
Test that all required parameters have schemas.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
if "parameters" not in operation:
|
||||
continue
|
||||
|
||||
for param in operation["parameters"]:
|
||||
if param.get("required", False):
|
||||
assert "schema" in param, (
|
||||
f"Path {path} method {method} required parameter {param.get('name')} has no schema"
|
||||
)
|
||||
|
||||
|
||||
def test_security_schemes_defined():
|
||||
"""
|
||||
Test that security schemes are properly defined.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
# Get paths requiring security
|
||||
secure_paths = find_paths_with_security(spec)
|
||||
|
||||
if secure_paths:
|
||||
assert "components" in spec, "Spec has secure paths but no components"
|
||||
assert "securitySchemes" in spec["components"], "Spec has secure paths but no securitySchemes"
|
||||
|
||||
# Check each security reference is defined
|
||||
for path, method in secure_paths:
|
||||
operation = spec["paths"][path][method]
|
||||
for security_req in operation["security"]:
|
||||
for scheme_name in security_req:
|
||||
assert scheme_name in spec["components"]["securitySchemes"], (
|
||||
f"Security scheme {scheme_name} used by {method.upper()} {path} "
|
||||
f"is not defined in components.securitySchemes"
|
||||
)
|
||||
|
||||
|
||||
def test_common_endpoint_groups_present():
|
||||
"""
|
||||
Test that the spec includes the main endpoint groups.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
paths = get_all_paths(spec)
|
||||
|
||||
# Define the expected endpoint prefixes
|
||||
expected_prefixes = [
|
||||
"/customnode/",
|
||||
"/externalmodel/",
|
||||
"/manager/",
|
||||
"/snapshot/",
|
||||
"/comfyui_manager/",
|
||||
]
|
||||
|
||||
# Check that at least one path exists for each expected prefix
|
||||
for prefix in expected_prefixes:
|
||||
matching_paths = [p for p in paths if p.startswith(prefix)]
|
||||
assert matching_paths, f"No endpoints found with prefix {prefix}"
|
||||
1
tests-api/utils/__init__.py
Normal file
1
tests-api/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Make utils directory a proper package
|
||||
174
tests-api/utils/schema_utils.py
Normal file
174
tests-api/utils/schema_utils.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""
|
||||
Schema utilities for extracting and manipulating OpenAPI schemas.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from .validation import load_openapi_spec
|
||||
|
||||
|
||||
def get_all_paths(spec: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Get all paths defined in the OpenAPI specification.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
|
||||
Returns:
|
||||
List of all paths
|
||||
"""
|
||||
return list(spec.get("paths", {}).keys())
|
||||
|
||||
|
||||
def get_grouped_paths(spec: Dict[str, Any]) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Group paths by their top-level segment.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
|
||||
Returns:
|
||||
Dictionary mapping top-level segments to lists of paths
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for path in get_all_paths(spec):
|
||||
segments = path.strip("/").split("/")
|
||||
if not segments:
|
||||
continue
|
||||
|
||||
top_segment = segments[0]
|
||||
if top_segment not in result:
|
||||
result[top_segment] = []
|
||||
|
||||
result[top_segment].append(path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_methods_for_path(spec: Dict[str, Any], path: str) -> List[str]:
|
||||
"""
|
||||
Get all HTTP methods defined for a path.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path
|
||||
|
||||
Returns:
|
||||
List of HTTP methods (lowercase)
|
||||
"""
|
||||
if path not in spec.get("paths", {}):
|
||||
return []
|
||||
|
||||
return [
|
||||
method.lower()
|
||||
for method in spec["paths"][path].keys()
|
||||
if method.lower() in {"get", "post", "put", "delete", "patch", "options", "head"}
|
||||
]
|
||||
|
||||
|
||||
def find_paths_with_security(
|
||||
spec: Dict[str, Any],
|
||||
security_scheme: Optional[str] = None
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Find all paths that require security.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
security_scheme: Optional specific security scheme to filter by
|
||||
|
||||
Returns:
|
||||
List of (path, method) tuples that require security
|
||||
"""
|
||||
result = []
|
||||
|
||||
for path, path_item in spec.get("paths", {}).items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
if "security" in operation:
|
||||
if security_scheme is None:
|
||||
result.append((path, method.lower()))
|
||||
else:
|
||||
# Check if this security scheme is required
|
||||
for security_req in operation["security"]:
|
||||
if security_scheme in security_req:
|
||||
result.append((path, method.lower()))
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_content_types_for_response(
|
||||
spec: Dict[str, Any],
|
||||
path: str,
|
||||
method: str,
|
||||
status_code: str = "200"
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get content types defined for a response.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path
|
||||
method: The HTTP method
|
||||
status_code: The HTTP status code
|
||||
|
||||
Returns:
|
||||
List of content types
|
||||
"""
|
||||
method = method.lower()
|
||||
|
||||
if path not in spec["paths"]:
|
||||
return []
|
||||
|
||||
if method not in spec["paths"][path]:
|
||||
return []
|
||||
|
||||
if "responses" not in spec["paths"][path][method]:
|
||||
return []
|
||||
|
||||
if status_code not in spec["paths"][path][method]["responses"]:
|
||||
return []
|
||||
|
||||
response_def = spec["paths"][path][method]["responses"][status_code]
|
||||
|
||||
if "content" not in response_def:
|
||||
return []
|
||||
|
||||
return list(response_def["content"].keys())
|
||||
|
||||
|
||||
def get_required_parameters(
|
||||
spec: Dict[str, Any],
|
||||
path: str,
|
||||
method: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all required parameters for a path/method.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path
|
||||
method: The HTTP method
|
||||
|
||||
Returns:
|
||||
List of parameter objects that are required
|
||||
"""
|
||||
method = method.lower()
|
||||
|
||||
if path not in spec["paths"]:
|
||||
return []
|
||||
|
||||
if method not in spec["paths"][path]:
|
||||
return []
|
||||
|
||||
if "parameters" not in spec["paths"][path][method]:
|
||||
return []
|
||||
|
||||
return [
|
||||
param for param in spec["paths"][path][method]["parameters"]
|
||||
if param.get("required", False)
|
||||
]
|
||||
155
tests-api/utils/validation.py
Normal file
155
tests-api/utils/validation.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""
|
||||
Validation utilities for API tests.
|
||||
"""
|
||||
import json
|
||||
import jsonschema
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
|
||||
def load_openapi_spec(spec_path: Union[str, Path] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Load the OpenAPI specification document.
|
||||
|
||||
Args:
|
||||
spec_path: Path to the OpenAPI specification file
|
||||
|
||||
Returns:
|
||||
The OpenAPI specification as a dictionary
|
||||
"""
|
||||
if spec_path is None:
|
||||
# Default to the root openapi.yaml file
|
||||
spec_path = Path(__file__).parents[2] / "openapi.yaml"
|
||||
|
||||
with open(spec_path, "r") as f:
|
||||
if str(spec_path).endswith(".yaml") or str(spec_path).endswith(".yml"):
|
||||
return yaml.safe_load(f)
|
||||
else:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_schema_for_path(
|
||||
spec: Dict[str, Any],
|
||||
path: str,
|
||||
method: str,
|
||||
status_code: str = "200",
|
||||
content_type: str = "application/json"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract the response schema for a specific path, method, and status code.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path (e.g., "/customnode/getlist")
|
||||
method: The HTTP method (e.g., "get", "post")
|
||||
status_code: The HTTP status code (default: "200")
|
||||
content_type: The response content type (default: "application/json")
|
||||
|
||||
Returns:
|
||||
The schema for the specified path and method, or None if not found
|
||||
"""
|
||||
method = method.lower()
|
||||
|
||||
if path not in spec["paths"]:
|
||||
return None
|
||||
|
||||
if method not in spec["paths"][path]:
|
||||
return None
|
||||
|
||||
if "responses" not in spec["paths"][path][method]:
|
||||
return None
|
||||
|
||||
if status_code not in spec["paths"][path][method]["responses"]:
|
||||
return None
|
||||
|
||||
response_def = spec["paths"][path][method]["responses"][status_code]
|
||||
|
||||
if "content" not in response_def:
|
||||
return None
|
||||
|
||||
if content_type not in response_def["content"]:
|
||||
return None
|
||||
|
||||
if "schema" not in response_def["content"][content_type]:
|
||||
return None
|
||||
|
||||
return response_def["content"][content_type]["schema"]
|
||||
|
||||
|
||||
def validate_response_schema(
|
||||
response_data: Any,
|
||||
schema: Dict[str, Any],
|
||||
spec: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Validate a response against a schema from the OpenAPI specification.
|
||||
|
||||
Args:
|
||||
response_data: The response data to validate
|
||||
schema: The schema to validate against
|
||||
spec: The complete OpenAPI specification (for resolving references)
|
||||
|
||||
Returns:
|
||||
True if validation succeeds, raises an exception otherwise
|
||||
"""
|
||||
if spec is None:
|
||||
spec = load_openapi_spec()
|
||||
|
||||
# Create a resolver for references within the schema
|
||||
resolver = jsonschema.RefResolver.from_schema(spec)
|
||||
|
||||
# Validate the response against the schema
|
||||
jsonschema.validate(
|
||||
instance=response_data,
|
||||
schema=schema,
|
||||
resolver=resolver
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_response(
|
||||
response_data: Any,
|
||||
path: str,
|
||||
method: str,
|
||||
status_code: str = "200",
|
||||
content_type: str = "application/json",
|
||||
spec: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Validate a response against the schema defined in the OpenAPI specification.
|
||||
|
||||
Args:
|
||||
response_data: The response data to validate
|
||||
path: The API path
|
||||
method: The HTTP method
|
||||
status_code: The HTTP status code (default: "200")
|
||||
content_type: The response content type (default: "application/json")
|
||||
spec: The OpenAPI specification (loaded from default location if None)
|
||||
|
||||
Returns:
|
||||
True if validation succeeds, raises an exception otherwise
|
||||
"""
|
||||
if spec is None:
|
||||
spec = load_openapi_spec()
|
||||
|
||||
schema = get_schema_for_path(
|
||||
spec=spec,
|
||||
path=path,
|
||||
method=method,
|
||||
status_code=status_code,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
if schema is None:
|
||||
raise ValueError(
|
||||
f"No schema found for {method.upper()} {path} "
|
||||
f"with status {status_code} and content type {content_type}"
|
||||
)
|
||||
|
||||
return validate_response_schema(
|
||||
response_data=response_data,
|
||||
schema=schema,
|
||||
spec=spec
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user