Skip to content

Commit 6a8b861

Browse files
jaracoclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 0e2b999 commit 6a8b861

3 files changed

Lines changed: 84 additions & 1 deletion

File tree

‎importlib_metadata/__init__.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,14 +890,22 @@ def __init__(self, path: FastPath):
890890
From the children in the path, calculate early all of the
891891
children that appear to represent metadata (infos) or legacy
892892
metadata (eggs).
893+
894+
Children are considered in reverse lexicographic order, so when
895+
a path supplies more than one metadata directory for a
896+
distribution, as an interrupted or unclean upgrade may leave
897+
behind, the greater one is found first
898+
(python/cpython#100666). Because the order is lexicographic on
899+
the directory name and not on the parsed version, it only
900+
approximates a preference for the newer version.
893901
"""
894902

895903
base = os.path.basename(path.root).lower()
896904
base_is_egg = base.endswith(".egg")
897905
self.infos = FreezableDefaultDict(list)
898906
self.eggs = FreezableDefaultDict(list)
899907

900-
for child in path.children():
908+
for child in sorted(path.children(), key=str.lower, reverse=True):
901909
low = child.lower()
902910
if low.endswith((".dist-info", ".egg-info")):
903911
# rpartition is faster than splitext and suitable for this purpose.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Where a path supplies more than one metadata directory for a distribution,
2+
as an interrupted or unclean upgrade may leave behind, discovery now prefers
3+
the greater one by reverse lexicographic ordering of the directory names, so
4+
the stale metadata no longer shadows the version in use
5+
(python/cpython#100666).

‎tests/test_main.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,76 @@ def test_diagnose_reports_invalid(self):
189189
assert 'nameless-1.0.dist-info (metadata without a name)' in report
190190

191191

192+
class MultipleVersionsTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
193+
"""
194+
An unclean upgrade can leave more than one metadata directory for a
195+
distribution in one path. Prefer the greater one.
196+
197+
Ref python/cpython#100666.
198+
"""
199+
200+
# Deliberately not in version order; the preference follows the
201+
# lexicographic ordering of the directory names, which only
202+
# approximates the ordering of the versions they name.
203+
versions = '1.12.1', '1.13.1', '1.9.0', '2.0.0'
204+
expected = '2.0.0', '1.9.0', '1.13.1', '1.12.1'
205+
206+
@staticmethod
207+
def make_pkg(name, version, files=None):
208+
"""
209+
Create metadata for a dist-info package at the indicated version.
210+
"""
211+
return {
212+
f'{name}-{version}.dist-info': (
213+
{'METADATA': f'Name: {name}\nVersion: {version}\n'}
214+
if files is None
215+
else files
216+
),
217+
}
218+
219+
def build_versions(self, *versions):
220+
for ver in versions:
221+
fixtures.build_files(self.make_pkg('foo', ver), self.site_dir)
222+
223+
def test_greater_version_preferred(self):
224+
"""
225+
Retrieving by name resolves to the greater version.
226+
"""
227+
self.build_versions(*self.versions)
228+
assert version('foo') == self.expected[0]
229+
230+
def test_discovery_order(self):
231+
"""
232+
Preferring one version doesn't hide the others from discovery,
233+
which reports them greatest first.
234+
"""
235+
self.build_versions(*self.versions)
236+
found = tuple(dist.version for dist in Distribution.discover(name='foo'))
237+
assert found == self.expected
238+
239+
def test_greater_version_preferred_in_eggs(self):
240+
"""
241+
Legacy egg-info metadata gets the same preference.
242+
"""
243+
fixtures.build_files(
244+
{
245+
f'foo-{ver}.egg-info': {'PKG-INFO': f'Name: foo\nVersion: {ver}\n'}
246+
for ver in self.versions
247+
},
248+
self.site_dir,
249+
)
250+
assert version('foo') == self.expected[0]
251+
252+
def test_validity_preferred_to_version(self):
253+
"""
254+
A valid dist is still preferred to an invalid one of a greater
255+
version, the condition that prompted the report.
256+
"""
257+
self.build_versions('1.12.1')
258+
fixtures.build_files(self.make_pkg('foo', '2.0.0', files={}), self.site_dir)
259+
assert version('foo') == '1.12.1'
260+
261+
192262
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
193263
@staticmethod
194264
def pkg_with_non_ascii_description(site_dir):

0 commit comments

Comments
 (0)