Skip to content

Commit 07c4712

Browse files
authored
Improve handling when install fails. (#323)
Fixes #322
1 parent 52242bf commit 07c4712

2 files changed

Lines changed: 205 additions & 80 deletions

File tree

src/manage/install_command.py

Lines changed: 91 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def download_package(cmd, install, dest, cache, *, on_progress=None, urlopen=_ur
108108
LOGGER.verbose("Using bundled file at %s", bundled)
109109
return bundled
110110

111-
unlink(dest, "Removing old download is taking some time. " +
111+
unlink(dest, "Removing old download is taking some time. " +
112112
"Please continue to wait, or press Ctrl+C to abort.")
113113

114114
def _find_creds(url):
@@ -528,36 +528,6 @@ def _restore_site(cmd, state):
528528
LOGGER.verbose("TRACEBACK", exc_info=True)
529529

530530

531-
def _sanitise_install(cmd, install):
532-
"""Prepares install metadata for storing locally.
533-
534-
This includes:
535-
* filtering out disabled shortcuts
536-
* preserving original shortcuts
537-
* sanitising URLs
538-
"""
539-
540-
if "shortcuts" in install:
541-
# This saves our original set of shortcuts, so a later repair operation
542-
# can enable those that were originally disabled.
543-
shortcuts = install.setdefault("__original-shortcuts", install["shortcuts"])
544-
if cmd.enable_shortcut_kinds or cmd.disable_shortcut_kinds:
545-
orig_shortcuts = shortcuts
546-
shortcuts = []
547-
for s in orig_shortcuts:
548-
if cmd.enable_shortcut_kinds and s["kind"] not in cmd.enable_shortcut_kinds:
549-
continue
550-
if cmd.disable_shortcut_kinds and s["kind"] in cmd.disable_shortcut_kinds:
551-
continue
552-
shortcuts.append(s)
553-
install["shortcuts"] = shortcuts
554-
555-
install["url"] = sanitise_url(install["url"])
556-
# If there's a non-empty and non-default source, sanitise it
557-
if install.get("source") and install["source"] != cmd.fallback_source:
558-
install["source"] = sanitise_url(install["source"])
559-
560-
561531
def _install_one(cmd, source, install, *, target=None):
562532
if cmd.repair:
563533
LOGGER.info("Repairing %s.", install['display-name'])
@@ -574,46 +544,87 @@ def _install_one(cmd, source, install, *, target=None):
574544
package = _download_one(cmd, source, install, cmd.download_dir)
575545

576546
dest = target or (cmd.install_dir / install["id"])
547+
metadata_dest = dest / "__install__.json"
577548

578549
preserved_site = _preserve_site(cmd, dest, install)
579550

580551
LOGGER.verbose("Extracting %s to %s", package, dest)
581-
if not cmd.repair:
582-
try:
583-
rmtree(
584-
dest,
585-
"Removing the previous install is taking some time. " +
586-
"Ensure Python is not running, and continue to wait " +
587-
"or press Ctrl+C to abort.",
588-
remove_ext_first=("exe", "dll", "json"),
589-
)
590-
except FileExistsError:
591-
LOGGER.error(
592-
"Unable to remove previous install. " +
593-
"Please check your packages directory at %s for issues.",
594-
dest.parent
595-
)
596-
raise
597-
except FilesInUseError:
598-
LOGGER.error(
599-
"Unable to remove previous install because files are still in use. " +
600-
"Please ensure Python is not currently running."
552+
try:
553+
if not cmd.repair:
554+
_remove_existing(dest)
555+
556+
with ProgressPrinter("Extracting", maxwidth=CONSOLE_MAX_WIDTH) as on_progress:
557+
extract_package(package, dest, on_progress=on_progress, repair=cmd.repair)
558+
559+
if target:
560+
unlink(
561+
metadata_dest,
562+
"Removing metadata from the install is taking some time. Please " +
563+
"continue to wait, or press Ctrl+C to abort."
601564
)
602-
raise
565+
else:
566+
_finalize_metadata(cmd, install, metadata_dest)
603567

604-
with ProgressPrinter("Extracting", maxwidth=CONSOLE_MAX_WIDTH) as on_progress:
605-
extract_package(package, dest, on_progress=on_progress, repair=cmd.repair)
568+
LOGGER.debug("Write __install__.json to %s", dest)
569+
with open(metadata_dest, "w", encoding="utf-8") as f:
570+
json.dump(install, f, default=str)
606571

607-
if target:
608-
unlink(
609-
dest / "__install__.json",
610-
"Removing metadata from the install is taking some time. Please " +
611-
"continue to wait, or press Ctrl+C to abort."
572+
finally:
573+
# May be letting an exception bubble out here, so we'll handle and log
574+
# here rather than letting any new ones leave.
575+
try:
576+
if dest.is_dir():
577+
# Install may be broken at this point, but we'll put site back anyway
578+
_restore_site(cmd, preserved_site)
579+
else:
580+
# Install is certainly broken, but we don't want to delete user files
581+
# Just warn, until we come up with a better idea
582+
LOGGER.warn("This runtime has been lost due to an error, you will "
583+
"need to reinstall.")
584+
except Exception:
585+
LOGGER.warn("Unexpected failure finalizing install. See log file for details")
586+
LOGGER.verbose("TRACEBACK", exc_info=True)
587+
588+
LOGGER.verbose("Install complete")
589+
590+
591+
def _remove_existing(install_dir):
592+
try:
593+
rmtree(
594+
install_dir,
595+
"Removing the previous install is taking some time. " +
596+
"Ensure Python is not running, and continue to wait " +
597+
"or press Ctrl+C to abort.",
598+
remove_ext_first=("exe", "dll", "json"),
612599
)
613-
else:
600+
except FileExistsError:
601+
LOGGER.error(
602+
"Unable to remove previous install. " +
603+
"Please check your packages directory at %s for issues.",
604+
install_dir.parent
605+
)
606+
raise
607+
except FilesInUseError:
608+
LOGGER.error(
609+
"Unable to remove previous install because files are still in use. " +
610+
"Please ensure Python is not currently running."
611+
)
612+
raise
613+
614+
615+
def _finalize_metadata(cmd, install, merge_from=None):
616+
"""Prepares install metadata for storing locally.
617+
618+
This includes:
619+
* filtering out disabled shortcuts
620+
* preserving original shortcuts
621+
* sanitising URLs
622+
"""
623+
624+
if merge_from:
614625
try:
615-
with open(dest / "__install__.json", "r", encoding="utf-8-sig") as f:
616-
LOGGER.debug("Updating from __install__.json in %s", dest)
626+
with open(merge_from, "r", encoding="utf-8-sig") as f:
627+
LOGGER.debug("Updating from __install__.json in %s", merge_from.parent)
617628
for k, v in json.load(f).items():
618629
if not install.setdefault(k, v):
619630
install[k] = v
@@ -626,15 +637,25 @@ def _install_one(cmd, source, install, *, target=None):
626637
)
627638
raise
628639

629-
_sanitise_install(cmd, install)
630-
631-
LOGGER.debug("Write __install__.json to %s", dest)
632-
with open(dest / "__install__.json", "w", encoding="utf-8") as f:
633-
json.dump(install, f, default=str)
634-
635-
_restore_site(cmd, preserved_site)
640+
if "shortcuts" in install:
641+
# This saves our original set of shortcuts, so a later repair operation
642+
# can enable those that were originally disabled.
643+
shortcuts = install.setdefault("__original-shortcuts", install["shortcuts"])
644+
if cmd.enable_shortcut_kinds or cmd.disable_shortcut_kinds:
645+
orig_shortcuts = shortcuts
646+
shortcuts = []
647+
for s in orig_shortcuts:
648+
if cmd.enable_shortcut_kinds and s["kind"] not in cmd.enable_shortcut_kinds:
649+
continue
650+
if cmd.disable_shortcut_kinds and s["kind"] in cmd.disable_shortcut_kinds:
651+
continue
652+
shortcuts.append(s)
653+
install["shortcuts"] = shortcuts
636654

637-
LOGGER.verbose("Install complete")
655+
install["url"] = sanitise_url(install["url"])
656+
# If there's a non-empty and non-default source, sanitise it
657+
if install.get("source") and install["source"] != cmd.fallback_source:
658+
install["source"] = sanitise_url(install["source"])
638659

639660

640661
def _merge_existing_index(versions, index_json):

tests/test_install_command.py

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def __init__(self, tmp_path, *args, **kwargs):
233233
self.download = kwargs.get("download")
234234
if self.download:
235235
self.download = tmp_path / self.download
236+
self.download_dir = tmp_path / kwargs.get("download_dir", "_cache")
236237
self.dry_run = kwargs.get("dry_run", True)
237238
self.fallback_source = kwargs.get("fallback_source")
238239
self.force = kwargs.get("force", True)
@@ -330,7 +331,85 @@ def test_install_from_script(tmp_path, assert_log):
330331
)
331332

332333

333-
def test_sanitise_install_urls():
334+
def test_failed_install_unwind(tmp_path, monkeypatch, assert_log):
335+
cmd = InstallCommandTestCmd(tmp_path, "1.0", force=False)
336+
cmd.dry_run = False
337+
cmd.preserve_site_on_upgrade = True
338+
339+
inst = cmd.installs[0]
340+
inst.setdefault("shortcuts", []).append({
341+
"kind": "site-dirs", "dirs": ["test-site"],
342+
})
343+
344+
target = tmp_path / "target"
345+
test_file = target / "test-site/file.txt"
346+
test_file.parent.mkdir(parents=True, exist_ok=True)
347+
test_file.write_text("Before")
348+
349+
def remove_existing(*args):
350+
pass
351+
352+
def download_one(*args, **kwargs):
353+
return "<package>"
354+
355+
def extract_package(package, dest, *args, **kwargs):
356+
# site dir should be gone
357+
assert not test_file.is_file()
358+
# create the target directory
359+
dest.mkdir(parents=True, exist_ok=True)
360+
# interrupt the install process
361+
raise RuntimeError("Failed to extract for test reasons")
362+
363+
monkeypatch.setattr(IC, "_remove_existing", remove_existing)
364+
monkeypatch.setattr(IC, "_download_one", download_one)
365+
monkeypatch.setattr(IC, "extract_package", extract_package)
366+
367+
with pytest.raises(RuntimeError):
368+
IC._install_one(cmd, "<source>", inst, target=target)
369+
370+
# site dir should be back
371+
assert test_file.is_file()
372+
assert test_file.read_text() == "Before"
373+
374+
375+
def test_failed_install_unwind_dont_clobber(tmp_path, monkeypatch, assert_log):
376+
cmd = InstallCommandTestCmd(tmp_path, "1.0", force=False)
377+
cmd.dry_run = False
378+
cmd.preserve_site_on_upgrade = True
379+
380+
inst = cmd.installs[0]
381+
inst.setdefault("shortcuts", []).append({
382+
"kind": "site-dirs", "dirs": ["test-site"],
383+
})
384+
385+
target = tmp_path / "test-site"
386+
test_file = target / "file.txt"
387+
test_file.parent.mkdir(parents=True, exist_ok=True)
388+
test_file.write_text("Before")
389+
390+
def download_one(*args, **kwargs):
391+
return "<package>"
392+
393+
def extract_package(package, dest, *args, **kwargs):
394+
# site dir should be gone
395+
assert not test_file.is_file()
396+
# create a new one - it should be preserved
397+
test_file.parent.mkdir(parents=True, exist_ok=True)
398+
test_file.write_text("After")
399+
# interrupt the install process
400+
raise RuntimeError("Failed to extract for test reasons")
401+
402+
monkeypatch.setattr(IC, "_download_one", download_one)
403+
monkeypatch.setattr(IC, "extract_package", extract_package)
404+
405+
with pytest.raises(RuntimeError):
406+
IC._install_one(cmd, "<source>", inst, target=target)
407+
408+
# Ensure file we extracted is still there
409+
assert test_file.read_text() == "After"
410+
411+
412+
def test_finalize_metadata_urls():
334413
class Cmd:
335414
enable_shortcut_kinds = []
336415
disable_shortcut_kinds = []
@@ -341,13 +420,13 @@ class Cmd:
341420
"source": "http://user:placeholder@example.com/index.json",
342421
}
343422

344-
IC._sanitise_install(Cmd, i)
423+
IC._finalize_metadata(Cmd, i)
345424

346425
assert i["url"] == "http://example.com/package.zip"
347426
assert i["source"] == "http://example.com/index.json"
348427

349428

350-
def test_sanitise_install_fallback_urls():
429+
def test_finalize_metadata_fallback_urls():
351430
class Cmd:
352431
enable_shortcut_kinds = []
353432
disable_shortcut_kinds = []
@@ -358,13 +437,13 @@ class Cmd:
358437
"source": "http://user:placeholder@example.com/index.json",
359438
}
360439

361-
IC._sanitise_install(Cmd, i)
440+
IC._finalize_metadata(Cmd, i)
362441

363442
assert i["url"] == "http://example.com/package.zip"
364443
assert i["source"] == "http://user:placeholder@example.com/index.json"
365444

366445

367-
def test_sanitise_install_shortcuts():
446+
def test_finalize_metadata_shortcuts():
368447
class Cmd:
369448
enable_shortcut_kinds = []
370449
disable_shortcut_kinds = []
@@ -375,13 +454,13 @@ class Cmd:
375454
"shortcuts": [dict(kind=a) for a in "abc"],
376455
}
377456

378-
IC._sanitise_install(Cmd, i)
457+
IC._finalize_metadata(Cmd, i)
379458

380459
assert [j["kind"] for j in i["shortcuts"]] == ["a", "b", "c"]
381460
assert [j["kind"] for j in i["__original-shortcuts"]] == ["a", "b", "c"]
382461

383462

384-
def test_sanitise_install_shortcuts_disable():
463+
def test_finalize_metadata_shortcuts_disable():
385464
class Cmd:
386465
enable_shortcut_kinds = []
387466
disable_shortcut_kinds = ["b"]
@@ -392,13 +471,13 @@ class Cmd:
392471
"shortcuts": [dict(kind=a) for a in "abc"],
393472
}
394473

395-
IC._sanitise_install(Cmd, i)
474+
IC._finalize_metadata(Cmd, i)
396475

397476
assert [j["kind"] for j in i["shortcuts"]] == ["a", "c"]
398477
assert [j["kind"] for j in i["__original-shortcuts"]] == ["a", "b", "c"]
399478

400479

401-
def test_sanitise_install_shortcuts_enable():
480+
def test_finalize_metadata_shortcuts_enable():
402481
class Cmd:
403482
enable_shortcut_kinds = ["b"]
404483
disable_shortcut_kinds = []
@@ -409,7 +488,32 @@ class Cmd:
409488
"shortcuts": [dict(kind=a) for a in "abc"],
410489
}
411490

412-
IC._sanitise_install(Cmd, i)
491+
IC._finalize_metadata(Cmd, i)
413492

414493
assert [j["kind"] for j in i["shortcuts"]] == ["b"]
415494
assert [j["kind"] for j in i["__original-shortcuts"]] == ["a", "b", "c"]
495+
496+
497+
def test_finalize_metadata_merge_from(tmp_path):
498+
class Cmd:
499+
enable_shortcut_kinds = []
500+
disable_shortcut_kinds = []
501+
fallback_source = None
502+
503+
merge_from = tmp_path / "file.json"
504+
test_url_1 = "https://example.com/"
505+
test_url_2 = "https://example.com/path2"
506+
507+
# merge_from does not exist, but we shouldn't fail
508+
i = {"url": test_url_1}
509+
IC._finalize_metadata(Cmd, i, merge_from)
510+
assert i["url"] == test_url_1
511+
512+
# Update missing fields from merge_from, but don't touch existing ones
513+
with open(merge_from, "w", encoding="utf-8") as f:
514+
json.dump({"url": test_url_1, "data1": "b", "data2": "c"}, f)
515+
i = {"url": test_url_2, "data1": "a"}
516+
IC._finalize_metadata(Cmd, i, merge_from)
517+
assert i["url"] == test_url_2
518+
assert i["data1"] == "a"
519+
assert i["data2"] == "c"

0 commit comments

Comments
 (0)