import requests import os import ast import argparse import sys import subprocess from importlib import import_module import platform from pathlib import Path import sys import stat import threading import signal from contextlib import suppress from queue import Queue, Empty from contextlib import contextmanager import shlex here = Path(__file__).parent executable = Path(sys.executable) # - detect mode mode = None if os.environ.get("COLAB_GPU"): mode = "colab" elif "python_embeded" in str(executable): mode = "embeded" elif ".venv" in str(executable): mode = "venv" if mode is None: mode = "unknown" # - Constants repo_url = "https://github.com/melmass/comfy_mtb.git" repo_owner = "melmass" repo_name = "comfy_mtb" short_platform = { "windows": "win_amd64", "linux": "linux_x86_64", } current_platform = platform.system().lower() # region ansi # ANSI escape sequences for text styling ANSI_FORMATS = { "reset": "\033[0m", "bold": "\033[1m", "dim": "\033[2m", "italic": "\033[3m", "underline": "\033[4m", "blink": "\033[5m", "reverse": "\033[7m", "strike": "\033[9m", } ANSI_COLORS = { "black": "\033[30m", "red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m", "blue": "\033[34m", "magenta": "\033[35m", "cyan": "\033[36m", "white": "\033[37m", "bright_black": "\033[30;1m", "bright_red": "\033[31;1m", "bright_green": "\033[32;1m", "bright_yellow": "\033[33;1m", "bright_blue": "\033[34;1m", "bright_magenta": "\033[35;1m", "bright_cyan": "\033[36;1m", "bright_white": "\033[37;1m", "bg_black": "\033[40m", "bg_red": "\033[41m", "bg_green": "\033[42m", "bg_yellow": "\033[43m", "bg_blue": "\033[44m", "bg_magenta": "\033[45m", "bg_cyan": "\033[46m", "bg_white": "\033[47m", "bg_bright_black": "\033[40;1m", "bg_bright_red": "\033[41;1m", "bg_bright_green": "\033[42;1m", "bg_bright_yellow": "\033[43;1m", "bg_bright_blue": "\033[44;1m", "bg_bright_magenta": "\033[45;1m", "bg_bright_cyan": "\033[46;1m", "bg_bright_white": "\033[47;1m", } def apply_format(text, *formats): """Apply ANSI escape sequences for the specified formats to the given text.""" formatted_text = text for format in formats: formatted_text = f"{ANSI_FORMATS.get(format, '')}{formatted_text}{ANSI_FORMATS.get('reset', '')}" return formatted_text def apply_color(text, color=None, background=None): """Apply ANSI escape sequences for the specified color and background to the given text.""" formatted_text = text if color: formatted_text = f"{ANSI_COLORS.get(color, '')}{formatted_text}{ANSI_FORMATS.get('reset', '')}" if background: formatted_text = f"{ANSI_COLORS.get(background, '')}{formatted_text}{ANSI_FORMATS.get('reset', '')}" return formatted_text def print_formatted(text, *formats, color=None, background=None, **kwargs): """Print the given text with the specified formats, color, and background.""" formatted_text = apply_format(text, *formats) formatted_text = apply_color(formatted_text, color, background) file = kwargs.get("file", sys.stdout) header = "[mtb install] " # Handle console encoding for Unicode characters (utf-8) encoded_header = header.encode(sys.stdout.encoding, errors="replace").decode( sys.stdout.encoding ) encoded_text = formatted_text.encode(sys.stdout.encoding, errors="replace").decode( sys.stdout.encoding ) print( " " * len(encoded_header) if kwargs.get("no_header") else apply_color(apply_format(encoded_header, "bold"), color="yellow"), encoded_text, file=file, ) # endregion # region utils def enqueue_output(out, queue): for char in iter(lambda: out.read(1), b""): queue.put(char) out.close() def run_command(cmd, ignored_lines_start=None): if ignored_lines_start is None: ignored_lines_start = [] if isinstance(cmd, str): shell_cmd = cmd elif isinstance(cmd, list): shell_cmd = "" for arg in cmd: if isinstance(arg, Path): arg = arg.as_posix() shell_cmd += f"{shlex.quote(str(arg))} " else: raise ValueError( "Invalid 'cmd' argument. It must be a string or a list of arguments." ) process = subprocess.Popen( shell_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, shell=True, ) # Create separate threads to read standard output and standard error streams stdout_queue = Queue() stderr_queue = Queue() stdout_thread = threading.Thread( target=enqueue_output, args=(process.stdout, stdout_queue) ) stderr_thread = threading.Thread( target=enqueue_output, args=(process.stderr, stderr_queue) ) stdout_thread.daemon = True stderr_thread.daemon = True stdout_thread.start() stderr_thread.start() interrupted = False def signal_handler(signum, frame): nonlocal interrupted interrupted = True print("Command execution interrupted.") # Register the signal handler for keyboard interrupts (SIGINT) signal.signal(signal.SIGINT, signal_handler) stdout_buffer = "" stderr_buffer = "" # Process output from both streams until the process completes or interrupted while not interrupted and ( process.poll() is None or not stdout_queue.empty() or not stderr_queue.empty() ): with suppress(Empty): stdout_char = stdout_queue.get_nowait() stdout_buffer += stdout_char if stdout_char == "\n": if not any( stdout_buffer.startswith(ign) for ign in ignored_lines_start ): print(stdout_buffer.strip()) stdout_buffer = "" with suppress(Empty): stderr_char = stderr_queue.get_nowait() stderr_buffer += stderr_char if stderr_char == "\n": print(stderr_buffer.strip()) stderr_buffer = "" # Print any remaining content in buffers if stdout_buffer and not any( stdout_buffer.startswith(ign) for ign in ignored_lines_start ): print(stdout_buffer.strip()) if stderr_buffer: print(stderr_buffer.strip()) return_code = process.returncode if return_code == 0 and not interrupted: print("Command executed successfully!") else: if not interrupted: print(f"Command failed with return code: {return_code}") # endregion try: import requirements except ImportError: print_formatted("Installing requirements-parser...", "italic", color="yellow") run_command([executable, "-m", "pip", "install", "requirements-parser"]) import requirements print_formatted("Done.", "italic", color="green") try: from tqdm import tqdm except ImportError: print_formatted("Installing tqdm...", "italic", color="yellow") run_command([executable, "-m", "pip", "install", "--upgrade", "tqdm"]) from tqdm import tqdm pip_map = { "onnxruntime-gpu": "onnxruntime", "opencv-contrib": "cv2", "tb-nightly": "tensorboard", "protobuf": "google.protobuf", # Add more mappings as needed } def is_pipe(): if not sys.stdin.isatty(): return False if sys.platform == "win32": try: import msvcrt return msvcrt.get_osfhandle(0) != -1 except ImportError: return False else: try: mode = os.fstat(0).st_mode return ( stat.S_ISFIFO(mode) or stat.S_ISREG(mode) or stat.S_ISBLK(mode) or stat.S_ISSOCK(mode) ) except OSError: return False @contextmanager def suppress_std(): with open(os.devnull, "w") as devnull: old_stdout = sys.stdout old_stderr = sys.stderr sys.stdout = devnull sys.stderr = devnull try: yield finally: sys.stdout = old_stdout sys.stderr = old_stderr # Get the version from __init__.py def get_local_version(): init_file = os.path.join(os.path.dirname(__file__), "__init__.py") if os.path.isfile(init_file): with open(init_file, "r") as f: tree = ast.parse(f.read()) for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if ( isinstance(target, ast.Name) and target.id == "__version__" and isinstance(node.value, ast.Str) ): return node.value.s return None def download_file(url, file_name): with requests.get(url, stream=True) as response: response.raise_for_status() total_size = int(response.headers.get("content-length", 0)) with open(file_name, "wb") as file, tqdm( desc=file_name.stem, total=total_size, unit="B", unit_scale=True, unit_divisor=1024, ) as progress_bar: for chunk in response.iter_content(chunk_size=8192): file.write(chunk) progress_bar.update(len(chunk)) def get_requirements(path: Path): with open(path.resolve(), "r") as requirements_file: requirements_txt = requirements_file.read() try: parsed_requirements = requirements.parse(requirements_txt) except AttributeError: print_formatted( f"Failed to parse {path}. Please make sure the file is correctly formatted.", "bold", color="red", ) return return parsed_requirements def try_import(requirement): dependency = requirement.name.strip() import_name = pip_map.get(dependency, dependency) installed = False pip_name = dependency pip_spec = "".join(specs[0]) if (specs := requirement.specs) else "" try: with suppress_std(): import_module(import_name) print_formatted( f"\t✅ Package {pip_name} already installed (import name: '{import_name}').", "bold", color="green", no_header=True, ) installed = True except ImportError: print_formatted( f"\t⛔ Package {pip_name} is missing (import name: '{import_name}').", "bold", color="red", no_header=True, ) return (installed, pip_name, pip_spec, import_name) def import_or_install(requirement, dry=False): installed, pip_name, pip_spec, import_name = try_import(requirement) pip_install_name = pip_name + pip_spec if not installed: print_formatted(f"Installing package {pip_name}...", "italic", color="yellow") if dry: print_formatted( f"Dry-run: Package {pip_install_name} would be installed (import name: '{import_name}').", color="yellow", ) else: try: run_command([executable, "-m", "pip", "install", pip_install_name]) print_formatted( f"Package {pip_install_name} installed successfully using pip package name (import name: '{import_name}')", "bold", color="green", ) except subprocess.CalledProcessError as e: print_formatted( f"Failed to install package {pip_install_name} using pip package name (import name: '{import_name}'). Error: {str(e)}", "bold", color="red", ) def get_github_assets(tag=None): if tag: tag_url = ( f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/tags/{tag}" ) else: tag_url = ( f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" ) response = requests.get(tag_url) if response.status_code == 404: # print_formatted( # f"Tag version '{apply_color(version,'cyan')}' not found for {owner}/{repo} repository." # ) print_formatted("Error retrieving the release assets.", color="red") sys.exit() tag_data = response.json() tag_name = tag_data["name"] return tag_data, tag_name # Install dependencies from requirements.txt def install_dependencies(dry=False): parsed_requirements = get_requirements(here / "reqs.txt") if not parsed_requirements: return print_formatted( "Installing dependencies from reqs.txt...", "italic", color="yellow" ) for requirement in parsed_requirements: import_or_install(requirement, dry=dry) if __name__ == "__main__": full = False if len(sys.argv) == 1: print_formatted( "No arguments provided, doing a full install/update...", "italic", color="yellow", ) full = True # Parse command-line arguments parser = argparse.ArgumentParser(description="Comfy_mtb install script") parser.add_argument( "--path", "-p", type=str, help="Path to clone the repository to (i.e the absolute path to ComfyUI/custom_nodes)", ) parser.add_argument( "--wheels", "-w", action="store_true", help="Install wheel dependencies" ) parser.add_argument( "--requirements", "-r", action="store_true", help="Install requirements.txt" ) parser.add_argument( "--dry", action="store_true", help="Print what will happen without doing it (still making requests to the GH Api)", ) # - keep # parser.add_argument( # "--version", # default=get_local_version(), # help="Version to check against the GitHub API", # ) print_formatted("mtb install", "bold", color="yellow") args = parser.parse_args() # wheels_directory = here / "wheels" print_formatted(f"Detected environment: {apply_color(mode,'cyan')}") if args.path: clone_dir = Path(args.path) if not clone_dir.exists(): print_formatted( "The path provided does not exist on disk... It must be pointing to ComfyUI's custom_nodes directory" ) sys.exit() else: repo_dir = clone_dir / repo_name if not repo_dir.exists(): print_formatted(f"Cloning to {repo_dir}...", "italic", color="yellow") run_command(["git", "clone", "--recursive", repo_url, repo_dir]) else: print_formatted( f"Directory {repo_dir} already exists, we will update it..." ) run_command(["git", "pull", "-C", repo_dir]) # os.chdir(clone_dir) here = clone_dir full = True # Install dependencies from requirements.txt # if args.requirements or mode == "venv": # if (not args.wheels and mode not in ["colab", "embeded"]) and not full: # print_formatted( # "Skipping wheel installation. Use --wheels to install wheel dependencies. (only needed for Comfy embed)", # "italic", # color="yellow", # ) # install_dependencies(dry=args.dry) # sys.exit() # if mode in ["colab", "embeded"]: # print_formatted( # f"Downloading and installing release wheels since we are in a Comfy {apply_color(mode,'cyan')} environment", # "italic", # color="yellow", # ) # if full: # print_formatted( # f"Downloading and installing release wheels since no arguments where provided", # "italic", # color="yellow", # ) print_formatted("Checking environment...", "italic", color="yellow") missing_deps = [] if parsed_requirements := get_requirements(here / "reqs.txt"): for requirement in parsed_requirements: installed, pip_name, pip_spec, import_name = try_import(requirement) if not installed: missing_deps.append(pip_name.split("-")[0]) if not missing_deps: print_formatted( "All requirements are already installed. Enjoy 🚀", "italic", color="green", ) sys.exit() # # - Get the tag version from the GitHub API # tag_data, tag_name = get_github_assets(tag=None) # # - keep # version = args.version # # Compare the local and tag versions # if version and tag_name: # if re.match(r"v?(\d+(\.\d+)+)", version) and re.match( # r"v?(\d+(\.\d+)+)", tag_name # ): # version_parts = [int(part) for part in version.lstrip("v").split(".")] # tag_version_parts = [int(part) for part in tag_name.lstrip("v").split(".")] # if version_parts > tag_version_parts: # print_formatted( # f"Local version ({version}) is greater than the release version ({tag_name}).", # "bold", # "yellow", # ) # sys.exit() # matching_assets = [ # asset # for asset in tag_data["assets"] # if asset["name"].endswith(".whl") # and ( # "any" in asset["name"] or short_platform[current_platform] in asset["name"] # ) # ] # if not matching_assets: # print_formatted( # f"Unsupported operating system: {current_platform}", color="yellow" # ) # wheel_order_asset = next( # (asset for asset in tag_data["assets"] if asset["name"] == "wheel_order.txt"), # None, # ) # if wheel_order_asset is not None: # print_formatted( # "⚙️ Sorting the release wheels using wheels order", "italic", color="yellow" # ) # response = requests.get(wheel_order_asset["browser_download_url"]) # if response.status_code == 200: # wheel_order = [line.strip() for line in response.text.splitlines()] # def get_order_index(val): # try: # return wheel_order.index(val) # except ValueError: # return len(wheel_order) # matching_assets = sorted( # matching_assets, # key=lambda x: get_order_index(x["name"].split("-")[0]), # ) # else: # print("Failed to fetch wheel_order.txt. Status code:", response.status_code) # missing_deps_urls = [] # for whl_file in matching_assets: # # check if installed # missing_deps_urls.append(whl_file["browser_download_url"]) install_cmd = [executable, "-m", "pip", "install"] # - Install all deps if not args.dry: if platform.system() == "Windows": wheel_cmd = install_cmd + ["-r", (here / "reqs_windows.txt")] else: wheel_cmd = install_cmd + ["-r", (here / "reqs.txt")] run_command(wheel_cmd) print_formatted( "✅ Successfully installed all dependencies.", "italic", color="green" ) else: print_formatted( f"Would have run the following command:\n\t{apply_color(' '.join(install_cmd),'cyan')}", "italic", color="yellow", )