From 55390c538793aa0dad6400c32e5e620226109eda Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 20:51:57 +0100 Subject: [PATCH 1/8] Avoid crashing when invalid __install__.json file exists. Fixes #293 --- src/manage/installs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/manage/installs.py b/src/manage/installs.py index 4b1dfa5..f8150cd 100644 --- a/src/manage/installs.py +++ b/src/manage/installs.py @@ -23,6 +23,14 @@ def _get_installs(install_dir): try: with p.open() as f: j = json.load(f) + except ValueError: + LOGGER.warn( + "Failed to read install at %s. You may have a broken " + "install, which can be cleaned up by deleting the directory.", + d + ) + LOGGER.debug("ERROR", exc_info=True) + continue except FileNotFoundError: continue From c7d5b4303ee55c3a7bb5c1b27b1bcbf0e8a899bc Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 20:58:12 +0100 Subject: [PATCH 2/8] Improve purge to clean up unrecognised directories --- src/manage/uninstall_command.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/manage/uninstall_command.py b/src/manage/uninstall_command.py index 8fefa6c..1b57868 100644 --- a/src/manage/uninstall_command.py +++ b/src/manage/uninstall_command.py @@ -108,6 +108,14 @@ def execute(cmd): for _, cleanup in SHORTCUT_HANDLERS.values(): if cleanup: cleanup(cmd, []) + # Clean up other lingering directories + LOGGER.info("Purging remaining files") + for d in cmd.install_dir.listdir(): + if d.is_dir(): + LOGGER.verbose("Removing %s", d) + rmtree(d, after_5s_warning=warn_msg.format("remaining files")) + if any(cmd.install_dir.listdir()): + LOGGER.warn("Unable to fully remove %s.", cmd.install_dir) LOGGER.debug("END uninstall_command.execute") return From a9d2ce4a8fffdc3595216c082ed9bd27060d4245 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 21:04:34 +0100 Subject: [PATCH 3/8] Set install_dir on test config --- tests/conftest.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c4ec097..a927495 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -152,11 +152,11 @@ def localserver(): class FakeConfig: - def __init__(self, global_dir, installs=[]): - self.global_dir = global_dir - self.root = global_dir.parent if global_dir else None - self.download_dir = self.root / "_cache" if self.root else None - self.start_folder = self.root / "_start" if self.root else None + def __init__(self, root, installs=[]): + self.root = self.install_dir = root + self.global_dir = root / "bin" if root else None + self.download_dir = root / "_cache" if root else None + self.start_folder = root / "_start" if root else None self.pep514_root = REG_TEST_ROOT self.confirm = False self.installs = list(installs) @@ -186,7 +186,7 @@ def ask_yn(self, question): @pytest.fixture def fake_config(tmp_path): - return FakeConfig(tmp_path / "bin") + return FakeConfig(tmp_path) class RegistryFixture: From 2b50aaf31af4292e30f645aa1c3e58b3c9cdeaf1 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 21:37:57 +0100 Subject: [PATCH 4/8] Use iterdir not listdir --- src/manage/uninstall_command.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/manage/uninstall_command.py b/src/manage/uninstall_command.py index 1b57868..d55e9a0 100644 --- a/src/manage/uninstall_command.py +++ b/src/manage/uninstall_command.py @@ -110,11 +110,11 @@ def execute(cmd): cleanup(cmd, []) # Clean up other lingering directories LOGGER.info("Purging remaining files") - for d in cmd.install_dir.listdir(): + for d in cmd.install_dir.iterdir(): if d.is_dir(): LOGGER.verbose("Removing %s", d) rmtree(d, after_5s_warning=warn_msg.format("remaining files")) - if any(cmd.install_dir.listdir()): + if any(cmd.install_dir.iterdir()): LOGGER.warn("Unable to fully remove %s.", cmd.install_dir) LOGGER.debug("END uninstall_command.execute") return From c99a712dc74893fef1fbfad319a294055e314574 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 21:50:02 +0100 Subject: [PATCH 5/8] Add purge to test run --- .github/workflows/build.yml | 14 ++++++++++++++ ci/release.yml | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94671db..6309a5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -182,6 +182,20 @@ jobs: PYMANAGER_DEBUG: true shell: powershell + - name: 'Test purge' + run: | + $env:PYTHON_MANAGER_CONFIG = (gi $env:PYTHON_MANAGER_CONFIG).FullName + pymanager uninstall --purge -y + if (Test-Path test_installs) { + dir -r test_installs + } else { + Write-Host "test_installs directory has been deleted" + } + env: + PYTHON_MANAGER_INCLUDE_UNMANAGED: false + PYTHON_MANAGER_CONFIG: .\test-config.json + PYMANAGER_DEBUG: true + - name: 'Offline bundle download and install' run: | pymanager list --online 3 3-32 3-64 3-arm64 diff --git a/ci/release.yml b/ci/release.yml index 01336fe..d2fcbcd 100644 --- a/ci/release.yml +++ b/ci/release.yml @@ -323,6 +323,21 @@ stages: PYTHON_MANAGER_CONFIG: .\test-config.json PYMANAGER_DEBUG: true + - powershell: | + $env:PYTHON_MANAGER_CONFIG = (gi $env:PYTHON_MANAGER_CONFIG).FullName + pymanager uninstall --purge -y + if (Test-Path test_installs) { + dir -r test_installs + } else { + Write-Host "test_installs directory has been deleted" + } + displayName: 'Test purge' + timeoutInMinutes: 5 + env: + PYTHON_MANAGER_INCLUDE_UNMANAGED: false + PYTHON_MANAGER_CONFIG: .\test-config.json + PYMANAGER_DEBUG: true + - powershell: | pymanager list --online 3 3-32 3-64 3-arm64 pymanager install --download .\bundle 3 3-32 3-64 3-arm64 From e7370ce15543b88ef7abac9a49237519aba320a1 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 21:59:37 +0100 Subject: [PATCH 6/8] Simplify and use helpers --- src/manage/uninstall_command.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/manage/uninstall_command.py b/src/manage/uninstall_command.py index d55e9a0..fc48151 100644 --- a/src/manage/uninstall_command.py +++ b/src/manage/uninstall_command.py @@ -66,9 +66,7 @@ def _do_purge_global_dir(global_dir, warn_msg, *, hive=None, subkey="Environment if not global_dir.is_dir(): return LOGGER.info("Purging global commands from %s", global_dir) - for f in _iterdir(global_dir): - LOGGER.debug("Purging %s", f) - rmtree(f, after_5s_warning=warn_msg) + rmtree(global_dir, after_5s_warning=warn_msg) def execute(cmd): @@ -110,11 +108,11 @@ def execute(cmd): cleanup(cmd, []) # Clean up other lingering directories LOGGER.info("Purging remaining files") - for d in cmd.install_dir.iterdir(): + for d in _iterdir(cmd.install_dir): if d.is_dir(): LOGGER.verbose("Removing %s", d) rmtree(d, after_5s_warning=warn_msg.format("remaining files")) - if any(cmd.install_dir.iterdir()): + if any(_iterdir(cmd.install_dir)): LOGGER.warn("Unable to fully remove %s.", cmd.install_dir) LOGGER.debug("END uninstall_command.execute") return From 819e54a451843d23dbd80f164111c2a064ad54c3 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 22:08:02 +0100 Subject: [PATCH 7/8] Allow path to be deleted in test --- tests/test_uninstall_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_uninstall_command.py b/tests/test_uninstall_command.py index a106d61..050a808 100644 --- a/tests/test_uninstall_command.py +++ b/tests/test_uninstall_command.py @@ -16,7 +16,7 @@ def test_purge_global_dir(monkeypatch, registry, tmp_path): UC._do_purge_global_dir(tmp_path, "SLOW WARNING", hive=registry.hive, subkey=registry.root) assert registry.getvalueandkind("", "Path") == ( rf"C:\A;{tmp_path}\X;C:\B;%PTH%;C:\%D%\E", winreg.REG_SZ) - assert not list(tmp_path.iterdir()) + assert not tmp_path.is_dir() or not list(tmp_path.iterdir()) def test_null_purge(fake_config): From d8dcd941e1a14f891356155e21780e697724bd7f Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 31 Mar 2026 22:24:40 +0100 Subject: [PATCH 8/8] Don't purge everything anymore --- src/manage/uninstall_command.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/manage/uninstall_command.py b/src/manage/uninstall_command.py index fc48151..28735fd 100644 --- a/src/manage/uninstall_command.py +++ b/src/manage/uninstall_command.py @@ -106,14 +106,6 @@ def execute(cmd): for _, cleanup in SHORTCUT_HANDLERS.values(): if cleanup: cleanup(cmd, []) - # Clean up other lingering directories - LOGGER.info("Purging remaining files") - for d in _iterdir(cmd.install_dir): - if d.is_dir(): - LOGGER.verbose("Removing %s", d) - rmtree(d, after_5s_warning=warn_msg.format("remaining files")) - if any(_iterdir(cmd.install_dir)): - LOGGER.warn("Unable to fully remove %s.", cmd.install_dir) LOGGER.debug("END uninstall_command.execute") return