Skip to content

Commit d26b802

Browse files
authored
Add preservation of site-packages directories on upgrade. (#159)
1 parent 47c90a9 commit d26b802

File tree

3 files changed

+146
-3
lines changed

3 files changed

+146
-3
lines changed

src/manage/commands.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@
3939

4040

4141
WELCOME = f"""!B!Python install manager was successfully updated to {__version__}.!W!
42-
!Y!Start menu shortcuts have been changed in this update.!W!
43-
Run !G!py install --refresh!W! to update any existing shortcuts.
4442
"""
4543

4644
# The 'py help' or 'pymanager help' output is constructed by these default docs,
@@ -254,6 +252,7 @@ def execute(self):
254252
"enable_shortcut_kinds": (str, config_split_append),
255253
"disable_shortcut_kinds": (str, config_split_append),
256254
"default_install_tag": (str, None),
255+
"preserve_site_on_upgrade": (config_bool, None),
257256
},
258257

259258
"first_run": {
@@ -794,6 +793,7 @@ class InstallCommand(BaseCommand):
794793
enable_shortcut_kinds = None
795794
disable_shortcut_kinds = None
796795
default_install_tag = None
796+
preserve_site_on_upgrade = True
797797

798798
def __init__(self, args, root=None):
799799
super().__init__(args, root)

src/manage/install_command.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,87 @@ 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+
continue
530+
d = dest / i.name
531+
if d.exists():
532+
LOGGER.verbose("Not restoring %s because %s exists", i, d)
533+
continue
534+
LOGGER.verbose("Restoring %s to %s", i, d)
535+
d.parent.mkdir(parents=True, exist_ok=True)
536+
i.rename(d)
537+
except OSError:
538+
LOGGER.warn("Failed to restore %s during update.", dest)
539+
LOGGER.verbose("TRACEBACK", exc_info=True)
540+
541+
461542
def _install_one(cmd, source, install, *, target=None):
462543
if cmd.repair:
463544
LOGGER.info("Repairing %s.", install['display-name'])
@@ -475,6 +556,8 @@ def _install_one(cmd, source, install, *, target=None):
475556

476557
dest = target or (cmd.install_dir / install["id"])
477558

559+
preserved_site = _preserve_site(cmd, dest)
560+
478561
LOGGER.verbose("Extracting %s to %s", package, dest)
479562
if not cmd.repair:
480563
try:
@@ -544,6 +627,8 @@ def _install_one(cmd, source, install, *, target=None):
544627
with open(dest / "__install__.json", "w", encoding="utf-8") as f:
545628
json.dump(install, f, default=str)
546629

630+
_restore_site(cmd, preserved_site)
631+
547632
LOGGER.verbose("Install complete")
548633

549634

@@ -560,7 +645,6 @@ def _merge_existing_index(versions, index_json):
560645
else:
561646
LOGGER.debug("Merging into existing %s", index_json)
562647
current = {i["url"].casefold() for i in versions}
563-
added = []
564648
for install in existing_index["versions"]:
565649
if install.get("url", "").casefold() not in current:
566650
LOGGER.debug("Merging %s", install.get("url", "<unspecified>"))

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)