diff --git a/CHANGELOG.md b/CHANGELOG.md index 1620dfca..a7bdb081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,18 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ subgraph. It read the node's links from the root graph, so it stopped with an error and left the same two copies of the node. Links to the inputs and outputs of the subgraph are kept as well. +- Read the version of a node pack installed from the registry when it has a + pre-release, such as `1.8.0-beta.1` or `0.7.2-b2`; such a pack was not + recognized as a registry pack. A version with a fourth part, such as + `0.1.3.post1`, stopped the scan of installed node packs with + `invalid literal for int()`, in the server and in `cm-cli` (#3431). +- Compare the versions in `pip_auto_fix.list` as pip does. An entry for a + package installed with a local label or as a pre-release, such as + `torch==2.5.1` beside an installed `2.5.1+cu124`, was skipped with + `Failed to restore`. Such an entry is now applied: that installed version + meets it, and one that does not, such as `2.6.0+cu124`, is replaced with + `pip install`. `~=` is a compatible-release clause: it was taken as met by + any other version, and as not met by the same version. ## [4.3] - 2026-09-19 diff --git a/comfyui_manager/common/manager_util.py b/comfyui_manager/common/manager_util.py index 38c5e421..822b55ee 100644 --- a/comfyui_manager/common/manager_util.py +++ b/comfyui_manager/common/manager_util.py @@ -118,17 +118,16 @@ class StrictVersion: self.parse_version_string() def parse_version_string(self): - parts = self.version_string.split('.') - if not parts: - raise ValueError("Version string must not be empty") + # A pre-release follows the third number after '.' or '-': '0.1.3.post1', '1.8.0-beta.1'. + # __str__ writes it with '-', so a version that was already normalized is read as well. + m = re.fullmatch(r'(\d+)(?:\.(\d+)(?:\.(\d+)(?:[.-](.+))?)?)?', self.version_string) + if m is None: + raise ValueError(f"Invalid version: {self.version_string!r}") - self.major = int(parts[0]) - self.minor = int(parts[1]) if len(parts) > 1 else 0 - self.patch = int(parts[2]) if len(parts) > 2 else 0 - - # Handling pre-release versions if present - if len(parts) > 3: - self.pre_release = parts[3] + self.major = int(m.group(1)) + self.minor = int(m.group(2) or 0) + self.patch = int(m.group(3) or 0) + self.pre_release = m.group(4) def __str__(self): version = f"{self.major}.{self.minor}.{self.patch}" @@ -515,7 +514,7 @@ def parse_requirement_line(line): res['operator'] = operator if version is not None: - res['version'] = StrictVersion(version) + res['version'] = version if index_url is not None: res['index_url'] = index_url @@ -685,23 +684,14 @@ class PIPFixer: normalized_name = normalize_pip_name(parsed['package']) if normalized_name in new_pip_versions: if 'version' in parsed and 'operator' in parsed: - cur = StrictVersion(new_pip_versions[normalized_name]) - dest = parsed['version'] - op = parsed['operator'] - if cur == dest: - if op in ['==', '>=', '<=']: - need_to_reinstall = False - elif cur < dest: - if op in ['<=', '<', '~=', '!=']: - need_to_reinstall = False - elif cur > dest: - if op in ['>=', '>', '~=', '!=']: - need_to_reinstall = False + specifier = Specifier(parsed['operator'] + parsed['version']) + if specifier.contains(new_pip_versions[normalized_name], prereleases=True): + need_to_reinstall = False if need_to_reinstall: cmd_args = ['install'] if 'version' in parsed and 'operator' in parsed: - cmd_args.append(parsed['package']+parsed['operator']+parsed['version'].version_string) + cmd_args.append(parsed['package']+parsed['operator']+parsed['version']) if 'index_url' in parsed: cmd_args.append('--index-url') diff --git a/tests/test_version_suffix.py b/tests/test_version_suffix.py new file mode 100644 index 00000000..4c73c997 --- /dev/null +++ b/tests/test_version_suffix.py @@ -0,0 +1,90 @@ +"""Versions with a suffix are read: node pack versions, and pip versions in pip_auto_fix.list (#3431). + +A node pack installed from the registry has its version in pyproject.toml. The registry holds +versions such as `1.8.0-beta.1`, and a pack may write `0.1.3.post1`. pip reports versions such +as `2.5.1+cu124` and `4.41.0rc1`. +""" +import logging + +import pytest + +from _install_flags_testutil import import_context, import_reader +from test_pip_fixer_opencv import fix_broken # noqa: F401 -- fixture + + +@pytest.fixture(params=["glob", "legacy"]) +def resolve(request, tmp_path): + """Returns what the installed-pack scan of each core reads for a registry pack of a given version.""" + import_context() + manager = import_reader(request.param).UnifiedManager() + manager.cnr_map["some-pack"] = {"id": "some-pack"} + + def run(version): + path = tmp_path / "some-pack" + path.mkdir(exist_ok=True) + (path / ".tracking").write_text("", encoding="utf-8") + (path / "pyproject.toml").write_text( + f'[project]\nname = "some-pack"\nversion = "{version}"\n', encoding="utf-8") + return manager.resolve_from_path(str(path)) + + return run + + +@pytest.mark.parametrize("written, read", [ + ("1.2.3", "1.2.3"), + ("2.5", "2.5.0"), + ("3", "3.0.0"), + ("0.7.2-b2", "0.7.2-b2"), + ("1.8.0-beta.1", "1.8.0-beta.1"), + ("2.0.0-inprogress", "2.0.0-inprogress"), + ("0.1.3.post1", "0.1.3-post1"), + ("0.1.3-post1", "0.1.3-post1"), # what the scan itself writes for 0.1.3.post1 +]) +def test_a_registry_pack_is_read_with_its_version(resolve, written, read): + info = resolve(written) + + assert info["id"] == "some-pack" + assert info["ver"] == read + + +@pytest.mark.parametrize("written", ["", "abc", "1.x", "1.2.x", "1.2.3-", "2.5-beta", "1.0.0rc1", "1.0.3+local"]) +def test_a_pack_without_a_readable_version_is_not_a_registry_pack(resolve, written): + assert resolve(written) is None + + +@pytest.mark.parametrize("installed, listed", [ + ("2.5.1+cu124", "torch==2.5.1"), + ("2.5.1+cu124", "torch>=2.5.0"), + ("2.5.1+cu124", "torch<=2.5.1"), + ("2.5.1+cu124", "torch~=2.5.0"), + ("2.5.1", "torch~=2.5.1"), + ("2.6.0.dev20241112", "torch>2.5.1"), + ("2.5.1rc1", "torch!=2.5.1"), +]) +def test_a_listed_requirement_that_the_installed_version_meets_is_left_alone(fix_broken, caplog, installed, listed): # noqa: F811 + with caplog.at_level(logging.ERROR): + commands = fix_broken({"torch": installed, "torchvision": "0.20.1", "torchaudio": "2.5.1"}, pip_auto_fix=listed + "\n") + + assert commands == [] + assert "Failed to restore" not in caplog.text + + +@pytest.mark.parametrize("installed, listed", [ + ("2.5.0+cu124", "torch==2.5.1"), + ("2.5.1rc1", "torch>=2.5.1"), + ("2.5.1rc1", "torch<2.5.1"), # as pip reads it: `<` leaves out the pre-releases of that version + ("2.5.1+cu124", "torch<2.5.1"), + ("2.5.1+cu124", "torch!=2.5.1"), + ("2.6.0", "torch~=2.5.1"), + ("2.5.0", "torch~=2.5.1"), +]) +def test_a_listed_requirement_that_the_installed_version_misses_is_installed(fix_broken, installed, listed): # noqa: F811 + assert fix_broken({"torch": installed, "torchvision": "0.20.1", "torchaudio": "2.5.1"}, + pip_auto_fix=listed + "\n") == ["install " + listed] + + +def test_the_index_url_of_a_listed_requirement_is_passed_to_pip(fix_broken): # noqa: F811 + index = "https://download.pytorch.org/whl/cu124" + + assert fix_broken({"torch": "2.5.0+cu124", "torchvision": "0.20.1", "torchaudio": "2.5.1"}, + pip_auto_fix=f"torch==2.5.1 --index-url {index}\n") == [f"install torch==2.5.1 --index-url {index}"]