Skip to content

Commit b3416b0

Browse files
authored
Merge pull request #3128 from gst/fix-easy-install-pth-file-not-reloaded-before-save
Fix: reload and merge easy-install pth file before save
2 parents 89e68d7 + b60b007 commit b3416b0

File tree

3 files changed

+105
-33
lines changed

3 files changed

+105
-33
lines changed

changelog.d/3128.change.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
In deprecated easy_install, reload and merge the pth file before saving.

setuptools/command/easy_install.py

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,50 +1567,79 @@ def get_exe_prefixes(exe_filename):
15671567
class PthDistributions(Environment):
15681568
"""A .pth file with Distribution paths in it"""
15691569

1570-
dirty = False
1571-
15721570
def __init__(self, filename, sitedirs=()):
15731571
self.filename = filename
15741572
self.sitedirs = list(map(normalize_path, sitedirs))
15751573
self.basedir = normalize_path(os.path.dirname(self.filename))
1576-
self._load()
1574+
self.paths, self.dirty = self._load()
1575+
# keep a copy if someone manually updates the paths attribute on the instance
1576+
self._init_paths = self.paths[:]
15771577
super().__init__([], None, None)
15781578
for path in yield_lines(self.paths):
15791579
list(map(self.add, find_distributions(path, True)))
15801580

1581-
def _load(self):
1582-
self.paths = []
1583-
saw_import = False
1581+
def _load_raw(self):
1582+
paths = []
1583+
dirty = saw_import = False
15841584
seen = dict.fromkeys(self.sitedirs)
1585-
if os.path.isfile(self.filename):
1586-
f = open(self.filename, 'rt')
1587-
for line in f:
1588-
if line.startswith('import'):
1589-
saw_import = True
1590-
continue
1591-
path = line.rstrip()
1592-
self.paths.append(path)
1593-
if not path.strip() or path.strip().startswith('#'):
1594-
continue
1595-
# skip non-existent paths, in case somebody deleted a package
1596-
# manually, and duplicate paths as well
1597-
path = self.paths[-1] = normalize_path(
1598-
os.path.join(self.basedir, path)
1599-
)
1600-
if not os.path.exists(path) or path in seen:
1601-
self.paths.pop() # skip it
1602-
self.dirty = True # we cleaned up, so we're dirty now :)
1603-
continue
1604-
seen[path] = 1
1605-
f.close()
1585+
f = open(self.filename, 'rt')
1586+
for line in f:
1587+
path = line.rstrip()
1588+
# still keep imports and empty/commented lines for formatting
1589+
paths.append(path)
1590+
if line.startswith(('import ', 'from ')):
1591+
saw_import = True
1592+
continue
1593+
stripped_path = path.strip()
1594+
if not stripped_path or stripped_path.startswith('#'):
1595+
continue
1596+
# skip non-existent paths, in case somebody deleted a package
1597+
# manually, and duplicate paths as well
1598+
normalized_path = normalize_path(os.path.join(self.basedir, path))
1599+
if normalized_path in seen or not os.path.exists(normalized_path):
1600+
log.debug("cleaned up dirty or duplicated %r", path)
1601+
dirty = True
1602+
paths.pop()
1603+
continue
1604+
seen[normalized_path] = 1
1605+
f.close()
1606+
# remove any trailing empty/blank line
1607+
while paths and not paths[-1].strip():
1608+
paths.pop()
1609+
dirty = True
1610+
return paths, dirty or (paths and saw_import)
16061611

1607-
if self.paths and not saw_import:
1608-
self.dirty = True # ensure anything we touch has import wrappers
1609-
while self.paths and not self.paths[-1].strip():
1610-
self.paths.pop()
1612+
def _load(self):
1613+
if os.path.isfile(self.filename):
1614+
return self._load_raw()
1615+
return [], False
16111616

16121617
def save(self):
16131618
"""Write changed .pth file back to disk"""
1619+
# first reload the file
1620+
last_paths, last_dirty = self._load()
1621+
# and check that there are no difference with what we have.
1622+
# there can be difference if someone else has written to the file
1623+
# since we first loaded it.
1624+
# we don't want to lose the eventual new paths added since then.
1625+
for path in last_paths[:]:
1626+
if path not in self.paths:
1627+
self.paths.append(path)
1628+
log.info("detected new path %r", path)
1629+
last_dirty = True
1630+
else:
1631+
last_paths.remove(path)
1632+
# also, re-check that all paths are still valid before saving them
1633+
for path in self.paths[:]:
1634+
if path not in last_paths \
1635+
and not path.startswith(('import ', 'from ', '#')):
1636+
absolute_path = os.path.join(self.basedir, path)
1637+
if not os.path.exists(absolute_path):
1638+
self.paths.remove(path)
1639+
log.info("removing now non-existent path %r", path)
1640+
last_dirty = True
1641+
1642+
self.dirty |= last_dirty or self.paths != self._init_paths
16141643
if not self.dirty:
16151644
return
16161645

@@ -1619,17 +1648,16 @@ def save(self):
16191648
log.debug("Saving %s", self.filename)
16201649
lines = self._wrap_lines(rel_paths)
16211650
data = '\n'.join(lines) + '\n'
1622-
16231651
if os.path.islink(self.filename):
16241652
os.unlink(self.filename)
16251653
with open(self.filename, 'wt') as f:
16261654
f.write(data)
1627-
16281655
elif os.path.exists(self.filename):
16291656
log.debug("Deleting empty %s", self.filename)
16301657
os.unlink(self.filename)
16311658

16321659
self.dirty = False
1660+
self._init_paths[:] = self.paths[:]
16331661

16341662
@staticmethod
16351663
def _wrap_lines(lines):

setuptools/tests/test_easy_install.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,49 @@ def test_add_from_site_is_ignored(self):
337337
pth.add(PRDistribution(location))
338338
assert not pth.dirty
339339

340+
def test_many_pth_distributions_merge_together(self, tmpdir):
341+
"""
342+
If the pth file is modified under the hood, then PthDistribution
343+
will refresh its content before saving, merging contents when
344+
necessary.
345+
"""
346+
# putting the pth file in a dedicated sub-folder,
347+
pth_subdir = tmpdir.join("pth_subdir")
348+
pth_subdir.mkdir()
349+
pth_path = str(pth_subdir.join("file1.pth"))
350+
pth1 = PthDistributions(pth_path)
351+
pth2 = PthDistributions(pth_path)
352+
assert (
353+
pth1.paths == pth2.paths == []
354+
), "unless there would be some default added at some point"
355+
# and so putting the src_subdir in folder distinct than the pth one,
356+
# so to keep it absolute by PthDistributions
357+
new_src_path = tmpdir.join("src_subdir")
358+
new_src_path.mkdir() # must exist to be accounted
359+
new_src_path_str = str(new_src_path)
360+
pth1.paths.append(new_src_path_str)
361+
pth1.save()
362+
assert (
363+
pth1.paths
364+
), "the new_src_path added must still be present/valid in pth1 after save"
365+
# now,
366+
assert (
367+
new_src_path_str not in pth2.paths
368+
), "right before we save the entry should still not be present"
369+
pth2.save()
370+
assert (
371+
new_src_path_str in pth2.paths
372+
), "the new_src_path entry should have been added by pth2 with its save() call"
373+
assert pth2.paths[-1] == new_src_path, (
374+
"and it should match exactly on the last entry actually "
375+
"given we append to it in save()"
376+
)
377+
# finally,
378+
assert PthDistributions(pth_path).paths == pth2.paths, (
379+
"and we should have the exact same list at the end "
380+
"with a fresh PthDistributions instance"
381+
)
382+
340383

341384
@pytest.fixture
342385
def setup_context(tmpdir):

0 commit comments

Comments
 (0)