From 158488782353a9dca0fdebbd609020e20e11eaab Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Mon, 4 May 2026 15:19:08 +0200 Subject: [PATCH 1/2] feat: opt-out switch for prestartup pip install/upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `dependency_management` config key (and matching `COMFYUI_MANAGER_DEPENDENCY_MANAGEMENT` env override) so installs on externally-managed Python environments — Nix, Guix, system packages, locked-down corporate images — can disable every prestartup path that mutates the interpreter. When set to `off`: - `PIPFixer.fix_broken()` returns immediately (no torch/opencv/comfy rollback, no `comfyui-frontend-package` reinstall). - The unified dependency resolver is skipped before it tries to `uv pip install` against a read-only store. - Per-node `pip install -r requirements.txt` in `execute_lazy_install_script` is skipped. Read-only operations (`pip list`, `pip freeze`, `pip show`) still run, so the UI, inventory, and security checks keep functioning. Default is `on`, preserving today's behavior for every existing user. --- comfyui_manager/common/manager_util.py | 15 ++++++++++++ comfyui_manager/prestartup_script.py | 32 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/comfyui_manager/common/manager_util.py b/comfyui_manager/common/manager_util.py index 84b88810..1b38b890 100644 --- a/comfyui_manager/common/manager_util.py +++ b/comfyui_manager/common/manager_util.py @@ -28,6 +28,17 @@ use_uv = False use_unified_resolver = False bypass_ssl = False +# When False, ComfyUI-Manager will not attempt to install, upgrade, or +# uninstall Python packages on its own (prestartup auto-healing, the unified +# dependency resolver, and per-node `pip install -r requirements.txt`). This +# is intended for distro-packaged or otherwise externally-managed Python +# environments (e.g. Nix, Guix, system package managers, locked-down +# corporate images) where the interpreter is read-only. +# +# Read-only pip operations (`list`, `freeze`, `show`) still run so the UI, +# inventory, and security checks keep working. +dependency_management_enabled = True + def is_manager_pip_package(): return not os.path.exists(os.path.join(comfyui_manager_path, '..', 'custom_nodes')) @@ -412,6 +423,10 @@ class PIPFixer: self.manager_files_path = manager_files_path def fix_broken(self): + if not dependency_management_enabled: + logging.info("[ComfyUI-Manager] dependency management disabled; skipping PIPFixer.fix_broken()") + return + new_pip_versions = get_installed_packages(True) # remove `comfy` python package diff --git a/comfyui_manager/prestartup_script.py b/comfyui_manager/prestartup_script.py index 121a5194..1a2ff6b6 100644 --- a/comfyui_manager/prestartup_script.py +++ b/comfyui_manager/prestartup_script.py @@ -93,6 +93,27 @@ def read_unified_resolver_mode(): if 'use_unified_resolver' in default_conf: manager_util.use_unified_resolver = default_conf['use_unified_resolver'].lower() == 'true' + +def read_dependency_management_mode(): + # Config: [default] dependency_management = on|off (default on). + # Env override: COMFYUI_MANAGER_DEPENDENCY_MANAGEMENT=on|off wins over config. + # `off` opts out of every Python-package mutation Manager performs at + # startup (PIPFixer auto-heal, unified resolver, per-node pip install). + # Intended for distro-packaged / externally-managed Python environments + # where the interpreter is read-only (Nix, Guix, system packages, etc.). + def _is_off(value): + return value.strip().lower() in ('off', 'false', '0', 'no', 'disabled') + + env_value = os.environ.get('COMFYUI_MANAGER_DEPENDENCY_MANAGEMENT') + if env_value is not None: + manager_util.dependency_management_enabled = not _is_off(env_value) + elif 'dependency_management' in default_conf: + manager_util.dependency_management_enabled = not _is_off(default_conf['dependency_management']) + + if not manager_util.dependency_management_enabled: + logging.info("[ComfyUI-Manager] dependency management disabled; auto-install/upgrade/uninstall paths will be skipped.") + + def check_file_logging(): global enable_file_logging if 'file_logging' in default_conf and default_conf['file_logging'].lower() == 'false': @@ -102,6 +123,7 @@ def check_file_logging(): read_config() read_uv_mode() read_unified_resolver_mode() +read_dependency_management_mode() security_check.security_check() check_file_logging() @@ -591,7 +613,11 @@ def execute_lazy_install_script(repo_path, executable): install_script_path = os.path.join(repo_path, "install.py") requirements_path = os.path.join(repo_path, "requirements.txt") - if os.path.exists(requirements_path) and not _unified_resolver_succeeded: + if ( + os.path.exists(requirements_path) + and not _unified_resolver_succeeded + and manager_util.dependency_management_enabled + ): # Per-node pip install: only runs if unified resolver is disabled or failed print(f"Install: pip packages for '{repo_path}'") @@ -764,6 +790,10 @@ def execute_startup_script(): # --- Unified dependency resolver: batch resolution at startup --- # Runs unconditionally when enabled, independent of install-scripts.txt existence. +if manager_util.use_unified_resolver and not manager_util.dependency_management_enabled: + logging.info("[ComfyUI-Manager] dependency management disabled; skipping unified dependency resolver") + manager_util.use_unified_resolver = False + if manager_util.use_unified_resolver: try: from .common.unified_dep_resolver import ( From f7df03f4e0dcf71ebfb4e0c3d09dd99b791c735f Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Mon, 4 May 2026 16:09:17 +0200 Subject: [PATCH 2/2] test: add unit tests for dependency_management flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the on/off value parser to `manager_util.is_off_value` so the prestartup hook and the new tests share one definition, and cover the flag's contract: - default is enabled (today's behavior is preserved bit-for-bit) - PIPFixer.fix_broken short-circuits without spawning subprocess when the flag is off - PIPFixer.fix_broken still reaches subprocess when the flag is on - make_pip_cmd is not gated — read paths (`pip list`, `freeze`, `show`) the UI and security check rely on continue to work - value parser accepts off/false/0/no/disabled (case- and whitespace-insensitive) and treats unknown values as "on" so a typo doesn't silently disable installs --- comfyui_manager/common/manager_util.py | 13 +++ comfyui_manager/prestartup_script.py | 7 +- .../common/test_dependency_management_flag.py | 109 ++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/common/test_dependency_management_flag.py diff --git a/comfyui_manager/common/manager_util.py b/comfyui_manager/common/manager_util.py index 1b38b890..f56a3400 100644 --- a/comfyui_manager/common/manager_util.py +++ b/comfyui_manager/common/manager_util.py @@ -39,6 +39,19 @@ bypass_ssl = False # inventory, and security checks keep working. dependency_management_enabled = True + +_OFF_VALUES = frozenset({'off', 'false', '0', 'no', 'disabled'}) + + +def is_off_value(value): + """Return True if a config or env-var string spells "off". + + Anything else — including the empty string and unknown words — is treated + as "on", so a typo doesn't silently disable dependency management. + """ + return value.strip().lower() in _OFF_VALUES + + def is_manager_pip_package(): return not os.path.exists(os.path.join(comfyui_manager_path, '..', 'custom_nodes')) diff --git a/comfyui_manager/prestartup_script.py b/comfyui_manager/prestartup_script.py index 1a2ff6b6..56cfc334 100644 --- a/comfyui_manager/prestartup_script.py +++ b/comfyui_manager/prestartup_script.py @@ -101,14 +101,11 @@ def read_dependency_management_mode(): # startup (PIPFixer auto-heal, unified resolver, per-node pip install). # Intended for distro-packaged / externally-managed Python environments # where the interpreter is read-only (Nix, Guix, system packages, etc.). - def _is_off(value): - return value.strip().lower() in ('off', 'false', '0', 'no', 'disabled') - env_value = os.environ.get('COMFYUI_MANAGER_DEPENDENCY_MANAGEMENT') if env_value is not None: - manager_util.dependency_management_enabled = not _is_off(env_value) + manager_util.dependency_management_enabled = not manager_util.is_off_value(env_value) elif 'dependency_management' in default_conf: - manager_util.dependency_management_enabled = not _is_off(default_conf['dependency_management']) + manager_util.dependency_management_enabled = not manager_util.is_off_value(default_conf['dependency_management']) if not manager_util.dependency_management_enabled: logging.info("[ComfyUI-Manager] dependency management disabled; auto-install/upgrade/uninstall paths will be skipped.") diff --git a/tests/common/test_dependency_management_flag.py b/tests/common/test_dependency_management_flag.py new file mode 100644 index 00000000..f0c97902 --- /dev/null +++ b/tests/common/test_dependency_management_flag.py @@ -0,0 +1,109 @@ +""" +Tests for the `dependency_management` opt-out switch. + +Covers the module-level flag, the value parser shared with prestartup, and +the early-return in `PIPFixer.fix_broken()`. The prestartup gates (unified +resolver, lazy install) are exercised end-to-end; the unit tests here lock +in the read paths and the short-circuit semantics. +""" + +import subprocess +import sys +import types +import unittest +from unittest import mock + + +def _import_manager_util(): + # `comfyui_manager/__init__.py` imports `comfy.cli_args` at module load + # time; stub the host-side modules so the unit under test can be imported + # without a full ComfyUI runtime. + for name in ("comfy", "comfy.cli_args"): + sys.modules.setdefault(name, types.ModuleType(name)) + sys.modules["comfy.cli_args"].args = types.SimpleNamespace(base_directory=None) + + from comfyui_manager.common import manager_util # noqa: WPS433 + return manager_util + + +class DependencyManagementFlagTest(unittest.TestCase): + def setUp(self): + self.manager_util = _import_manager_util() + self._prev = self.manager_util.dependency_management_enabled + self.addCleanup(setattr, self.manager_util, "dependency_management_enabled", self._prev) + + def test_default_is_enabled(self): + # Default must preserve today's behavior. + self.assertTrue(self._prev) + + def test_pip_fixer_short_circuits_when_disabled(self): + self.manager_util.dependency_management_enabled = False + + class _Stub(self.manager_util.PIPFixer): + def __init__(self): # bypass parent init + pass + + with mock.patch.object(subprocess, "check_output") as patched: + _Stub().fix_broken() + + patched.assert_not_called() + + def test_pip_fixer_runs_when_enabled(self): + # Enabled path should at least call subprocess (via get_installed_packages). + self.manager_util.dependency_management_enabled = True + + class _Stub(self.manager_util.PIPFixer): + def __init__(self): + self.prev_pip_versions = {} + self.comfyui_path = "/nonexistent" + self.manager_files_path = "/nonexistent" + + with mock.patch.object(subprocess, "check_output", return_value="Package Version\n------- -------\n") as patched: + try: + _Stub().fix_broken() + except Exception: + # Other internal paths (opencv, frontend) may raise on a stub + # tree — we only care that subprocess was reached at least once. + pass + + self.assertGreaterEqual(patched.call_count, 1) + + def test_make_pip_cmd_not_gated(self): + # make_pip_cmd is consulted for both read and write ops (pip list, + # pip install, …). Gating it would break read paths the UI relies on. + # Assert the helper still produces a runnable command when the flag + # is off — gating happens at call sites that perform writes. + self.manager_util.dependency_management_enabled = False + + cmd = self.manager_util.make_pip_cmd(["list"]) + self.assertIsInstance(cmd, list) + self.assertGreaterEqual(len(cmd), 3) + self.assertIn("list", cmd) + + cmd = self.manager_util.make_pip_cmd(["install", "some-pkg"]) + self.assertIn("install", cmd) + self.assertIn("some-pkg", cmd) + + +class IsOffValueTest(unittest.TestCase): + """`is_off_value` is the single source of truth for the config/env vocabulary.""" + + def setUp(self): + self.manager_util = _import_manager_util() + + def test_off_synonyms(self): + for value in ("off", "OFF", " false ", "0", "no", "Disabled"): + with self.subTest(value=value): + self.assertTrue(self.manager_util.is_off_value(value)) + + def test_on_values(self): + # Anything not in the off vocabulary — including the empty string and + # typos — keeps dependency management enabled. Better to be noisy than + # to silently swallow installs. + for value in ("on", "true", "1", "yes", "enabled", "auto", "", " ", "off-ish"): + with self.subTest(value=value): + self.assertFalse(self.manager_util.is_off_value(value)) + + +if __name__ == "__main__": + unittest.main()