From 6a8b86141e1a45e36fe37a75b231190426e2198d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Wed, 21 Oct 2026 15:57:13 -0700 Subject: [PATCH] Prefer the greater metadata directory when a path supplies several. An unclean upgrade can leave a stale `.dist-info` beside the installed one, and because the order of `os.listdir` is arbitrary, which of them answered a query was a matter of chance. Consider the children in reverse lexicographic order so the greater one is found first. The ordering is lexicographic on the directory name and not on the parsed version, so it only approximates a preference for the newer version, but it needs no version parser and so is available to the stdlib. Ref python/cpython#100666. Co-Authored-By: Claude Opus 5 --- importlib_metadata/__init__.py | 10 ++- .../+prefer-greater-version.feature.rst | 5 ++ tests/test_main.py | 70 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 newsfragments/+prefer-greater-version.feature.rst diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index b3ff2c31..cab13afc 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -890,6 +890,14 @@ def __init__(self, path: FastPath): From the children in the path, calculate early all of the children that appear to represent metadata (infos) or legacy metadata (eggs). + + Children are considered in reverse lexicographic order, so when + a path supplies more than one metadata directory for a + distribution, as an interrupted or unclean upgrade may leave + behind, the greater one is found first + (python/cpython#100666). Because the order is lexicographic on + the directory name and not on the parsed version, it only + approximates a preference for the newer version. """ base = os.path.basename(path.root).lower() @@ -897,7 +905,7 @@ def __init__(self, path: FastPath): self.infos = FreezableDefaultDict(list) self.eggs = FreezableDefaultDict(list) - for child in path.children(): + for child in sorted(path.children(), key=str.lower, reverse=True): low = child.lower() if low.endswith((".dist-info", ".egg-info")): # rpartition is faster than splitext and suitable for this purpose. diff --git a/newsfragments/+prefer-greater-version.feature.rst b/newsfragments/+prefer-greater-version.feature.rst new file mode 100644 index 00000000..8d656f7f --- /dev/null +++ b/newsfragments/+prefer-greater-version.feature.rst @@ -0,0 +1,5 @@ +Where a path supplies more than one metadata directory for a distribution, +as an interrupted or unclean upgrade may leave behind, discovery now prefers +the greater one by reverse lexicographic ordering of the directory names, so +the stale metadata no longer shadows the version in use +(python/cpython#100666). diff --git a/tests/test_main.py b/tests/test_main.py index bf3b49a6..7a3f832d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -189,6 +189,76 @@ def test_diagnose_reports_invalid(self): assert 'nameless-1.0.dist-info (metadata without a name)' in report +class MultipleVersionsTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): + """ + An unclean upgrade can leave more than one metadata directory for a + distribution in one path. Prefer the greater one. + + Ref python/cpython#100666. + """ + + # Deliberately not in version order; the preference follows the + # lexicographic ordering of the directory names, which only + # approximates the ordering of the versions they name. + versions = '1.12.1', '1.13.1', '1.9.0', '2.0.0' + expected = '2.0.0', '1.9.0', '1.13.1', '1.12.1' + + @staticmethod + def make_pkg(name, version, files=None): + """ + Create metadata for a dist-info package at the indicated version. + """ + return { + f'{name}-{version}.dist-info': ( + {'METADATA': f'Name: {name}\nVersion: {version}\n'} + if files is None + else files + ), + } + + def build_versions(self, *versions): + for ver in versions: + fixtures.build_files(self.make_pkg('foo', ver), self.site_dir) + + def test_greater_version_preferred(self): + """ + Retrieving by name resolves to the greater version. + """ + self.build_versions(*self.versions) + assert version('foo') == self.expected[0] + + def test_discovery_order(self): + """ + Preferring one version doesn't hide the others from discovery, + which reports them greatest first. + """ + self.build_versions(*self.versions) + found = tuple(dist.version for dist in Distribution.discover(name='foo')) + assert found == self.expected + + def test_greater_version_preferred_in_eggs(self): + """ + Legacy egg-info metadata gets the same preference. + """ + fixtures.build_files( + { + f'foo-{ver}.egg-info': {'PKG-INFO': f'Name: foo\nVersion: {ver}\n'} + for ver in self.versions + }, + self.site_dir, + ) + assert version('foo') == self.expected[0] + + def test_validity_preferred_to_version(self): + """ + A valid dist is still preferred to an invalid one of a greater + version, the condition that prompted the report. + """ + self.build_versions('1.12.1') + fixtures.build_files(self.make_pkg('foo', '2.0.0', files={}), self.site_dir) + assert version('foo') == '1.12.1' + + class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod def pkg_with_non_ascii_description(site_dir):