v4: Read node pack and pip versions that carry a suffix (#3516)

StrictVersion read each of the first three components of a version
with int(), so it raised on a suffix after the third number. On v4 it
reads node pack versions and the pip versions of pip_auto_fix.list.

- Read a node pack version with a pre-release after the third number,
  following '.' or '-', and write it with '-' as before: a registry
  version such as 1.8.0-beta.1 is recognized, and 0.1.3.post1 no
  longer stops the scan of installed node packs, in the glob and the
  legacy core
- Ask a packaging Specifier for the entries of pip_auto_fix.list, as
  pip does: an installed 2.5.1+cu124 or 4.41.0rc1 no longer skips the
  entry with "Failed to restore", and "~=" becomes a compatible-release
  clause
- Add tests that run the real scan of both cores and the real PIPFixer
- Add a CHANGELOG entry

The same change for main is #3431, which also moves the pip checks at
install time to packaging; v4 did that in #3489.
This commit is contained in:
Dr.Lt.Data
2026-11-14 15:32:05 +09:00
committed by GitHub
parent 8dd1967fde
commit a4cb6f5b3f
3 changed files with 116 additions and 24 deletions
+12
View File
@@ -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
+14 -24
View File
@@ -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')
+90
View File
@@ -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}"]