Skip to content

Commit 2540a32

Browse files
committed
Add preservation of site-packages directories on upgrade.
1 parent 47c90a9 commit 2540a32

File tree

3 files changed

+145
-0
lines changed

3 files changed

+145
-0
lines changed

src/manage/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ def execute(self):
254254
"enable_shortcut_kinds": (str, config_split_append),
255255
"disable_shortcut_kinds": (str, config_split_append),
256256
"default_install_tag": (str, None),
257+
"preserve_site_on_upgrade": (config_bool, None),
257258
},
258259

259260
"first_run": {
@@ -794,6 +795,7 @@ class InstallCommand(BaseCommand):
794795
enable_shortcut_kinds = None
795796
disable_shortcut_kinds = None
796797
default_install_tag = None
798+
preserve_site_on_upgrade = True
797799

798800
def __init__(self, args, root=None):
799801
super().__init__(args, root)

src/manage/install_command.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,86 @@ def _download_one(cmd, source, install, download_dir, *, must_copy=False):
458458
return package
459459

460460

461+
def _preserve_site(cmd, root):
462+
if not root.is_dir():
463+
return None
464+
if not cmd.preserve_site_on_upgrade:
465+
LOGGER.verbose("Not preserving site directory because of config")
466+
return None
467+
if cmd.force:
468+
LOGGER.verbose("Not preserving site directory because of --force")
469+
return None
470+
if cmd.repair:
471+
LOGGER.verbose("Not preserving site directory because of --repair")
472+
return None
473+
state = []
474+
i = 0
475+
dirs = [root]
476+
root = root.with_name(f"_{root.name}")
477+
root.mkdir(parents=True, exist_ok=True)
478+
while dirs:
479+
if dirs[0].match("site-packages"):
480+
while True:
481+
target = root / str(i)
482+
i += 1
483+
try:
484+
unlink(target)
485+
break
486+
except FileNotFoundError:
487+
break
488+
except OSError:
489+
LOGGER.verbose("Failed to remove %s.", target)
490+
LOGGER.info("Preserving %s during update as %s.", dirs[0], target)
491+
try:
492+
dirs[0].rename(target)
493+
except OSError:
494+
LOGGER.warn("Failed to preserve %s during update.", dirs[0])
495+
LOGGER.verbose("TRACEBACK", exc_info=True)
496+
else:
497+
state.append((dirs[0], target))
498+
else:
499+
dirs.extend(d for d in dirs[0].iterdir() if d.is_dir())
500+
dirs.pop(0)
501+
# Append None, root last so that root gets cleaned up after restore is done
502+
state.append((None, root))
503+
return state
504+
505+
506+
def _restore_site(cmd, state):
507+
if not state:
508+
return
509+
for dest, src in state:
510+
if not dest:
511+
LOGGER.verbose("Removing preserved directory at %s", src)
512+
try:
513+
rmtree(
514+
src,
515+
"Removing temporary files is taking some time. " +
516+
"You can continue to wait or press Ctrl+C to abort. " +
517+
"Python has been installed, but some harmless temporary " +
518+
"files may remain on disk."
519+
)
520+
except KeyboardInterrupt:
521+
break
522+
continue
523+
LOGGER.info("Restoring %s from %s after update.", dest, src)
524+
try:
525+
for i in src.iterdir():
526+
if not i.is_dir() and not i.is_file():
527+
LOGGER.verbose("Not restoring %s because it is not a " +
528+
"normal file or directory.", i)
529+
d = dest / i.name
530+
if d.exists():
531+
LOGGER.verbose("Not restoring %s because %s exists", i, d)
532+
continue
533+
LOGGER.verbose("Restoring %s to %s", i, d)
534+
d.parent.mkdir(parents=True, exist_ok=True)
535+
i.rename(d)
536+
except OSError:
537+
LOGGER.warn("Failed to restore %s during update.", dest)
538+
LOGGER.verbose("TRACEBACK", exc_info=True)
539+
540+
461541
def _install_one(cmd, source, install, *, target=None):
462542
if cmd.repair:
463543
LOGGER.info("Repairing %s.", install['display-name'])
@@ -475,6 +555,8 @@ def _install_one(cmd, source, install, *, target=None):
475555

476556
dest = target or (cmd.install_dir / install["id"])
477557

558+
preserved_site = _preserve_site(cmd, dest)
559+
478560
LOGGER.verbose("Extracting %s to %s", package, dest)
479561
if not cmd.repair:
480562
try:
@@ -544,6 +626,8 @@ def _install_one(cmd, source, install, *, target=None):
544626
with open(dest / "__install__.json", "w", encoding="utf-8") as f:
545627
json.dump(install, f, default=str)
546628

629+
_restore_site(cmd, preserved_site)
630+
547631
LOGGER.verbose("Install complete")
548632

549633

tests/test_install_command.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,62 @@ def test_merge_existing_index_not_valid(tmp_path):
230230
new = [1, 2, 3]
231231
IC._merge_existing_index(new, existing)
232232
assert new == [1, 2, 3]
233+
234+
235+
def test_preserve_site(tmp_path):
236+
root = tmp_path / "root"
237+
preserved = tmp_path / "_root"
238+
site = root / "site-packages"
239+
not_site = root / "site-not-packages"
240+
A = site / "A"
241+
B = site / "B.txt"
242+
C = site / "C.txt"
243+
A.mkdir(parents=True, exist_ok=True)
244+
B.write_bytes(b"")
245+
C.write_bytes(b"original")
246+
247+
class Cmd:
248+
preserve_site_on_upgrade = False
249+
force = False
250+
repair = False
251+
252+
state = IC._preserve_site(Cmd, root)
253+
assert not state
254+
assert not preserved.exists()
255+
Cmd.preserve_site_on_upgrade = True
256+
Cmd.force = True
257+
state = IC._preserve_site(Cmd, root)
258+
assert not state
259+
assert not preserved.exists()
260+
Cmd.force = False
261+
Cmd.repair = True
262+
state = IC._preserve_site(Cmd, root)
263+
assert not state
264+
assert not preserved.exists()
265+
266+
Cmd.repair = False
267+
state = IC._preserve_site(Cmd, root)
268+
assert state == [(site, preserved / "0"), (None, preserved)]
269+
assert preserved.is_dir()
270+
271+
root.rename(root.parent / "ex_root_1")
272+
IC._restore_site(Cmd, state)
273+
assert root.is_dir()
274+
assert A.is_dir()
275+
assert B.is_file()
276+
assert C.is_file()
277+
assert b"original" == C.read_bytes()
278+
assert not preserved.exists()
279+
280+
state = IC._preserve_site(Cmd, root)
281+
assert state == [(site, preserved / "0"), (None, preserved)]
282+
283+
assert not C.exists()
284+
C.parent.mkdir(parents=True, exist_ok=True)
285+
C.write_bytes(b"updated")
286+
IC._restore_site(Cmd, state)
287+
assert A.is_dir()
288+
assert B.is_file()
289+
assert C.is_file()
290+
assert b"updated" == C.read_bytes()
291+
assert not preserved.exists()

0 commit comments

Comments
 (0)