diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index c7377d8efd..147b8c8c00 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2190[#2190]: IDEasy destroys my python installation The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/50?closed=1[milestone 2026.09.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java index 2a81659b71..f1cbbece9d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java @@ -78,7 +78,6 @@ protected boolean isIgnoreMissingSoftwareVersionFile() { return false; } - @Override protected ToolInstallation doInstall(ToolInstallRequest request) { @@ -208,10 +207,17 @@ public ToolInstallation installTool(ToolInstallRequest request) { LOG.warn("Deleting corrupted installation at {}", installationPath); fileAccess.delete(installationPath); } else { - // Version file missing but tool allows this - restore it and preserve installation - LOG.warn("Version file missing at {} - restoring it for tool {}", toolVersionFile, this.tool); - // Restore the missing file - return createToolInstallation(installationPath, resolvedVersion, false, processContext, additionalInstallation); + // version file is missing but tool allows this - restore the file and preserve the installation + VersionIdentifier installedVersion = getInstalledVersion(installationPath); + if (installedVersion == null) { + installedVersion = resolvedVersion; + } + restoreMissingVersionFile(installationPath, installedVersion); + if (installedVersion.equals(resolvedVersion)) { + return createToolInstallation(installationPath, installedVersion, false, processContext, additionalInstallation); + } + // the installation on disk is a different version than requested so we continue with the regular installation + // that will backup the existing installation before installing the requested version. } } } @@ -365,6 +371,41 @@ protected void postExtract(Path extractedDir) { } + @Override + protected ToolInstallation toolAlreadyInstalled(ToolInstallRequest request) { + + if (isIgnoreMissingSoftwareVersionFile()) { + // the installed version was determined from the installation itself so we can heal the missing version file + ToolEditionAndVersion installed = request.getInstalled(); + if (installed != null) { + restoreMissingVersionFile(getToolPath(), installed.getResolvedVersion()); + } + } + return super.toolAlreadyInstalled(request); + } + + /** + * Restores the {@link IdeContext#FILE_SOFTWARE_VERSION version file} of an existing installation in case it got lost (e.g. because {@code uv} recreated the + * python virtual environment). Does nothing if the file is present or the version is unknown. + * + * @param installationPath the {@link Path} to the installation of this tool. + * @param version the {@link VersionIdentifier} that is actually installed at the given {@code installationPath}. + */ + protected void restoreMissingVersionFile(Path installationPath, VersionIdentifier version) { + + if ((version == null) || !Files.isDirectory(installationPath)) { + return; + } + Path toolVersionFile = installationPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + if (Files.exists(toolVersionFile)) { + return; + } + LOG.warn("Version file is missing at {} - restoring it with version {} for tool {}", toolVersionFile, version, this.tool); + this.context.writeVersionFile(version, installationPath); + // the installation on disk changed so a previously cached result must not survive + invalidateInstalledEditionAndVersion(); + } + @Override protected EditionAndVersion computeInstalledEditionAndVersion() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java index 930b82c8b8..3bbcd1c298 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java @@ -12,6 +12,7 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.process.EnvironmentContext; import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolCommandlet; @@ -33,6 +34,10 @@ public class Python extends LocalToolCommandlet { /** The folder created by {@code uv venv} inside the software folder before it is renamed to the python installation. */ static final String VENV_FOLDER = ".venv"; + private static final String FILE_PYVENV_CFG = "pyvenv.cfg"; + + private static final String PYVENV_CFG_VERSION_INFO = "version_info"; + /** * The constructor. * @@ -90,6 +95,81 @@ protected boolean isIgnoreMissingSoftwareVersionFile() { return true; } + @Override + protected VersionIdentifier getInstalledVersion(Path toolPath) { + + VersionIdentifier version = super.getInstalledVersion(toolPath); + if (version == null) { + // the virtual environment can be recreated by uv or python and then the version file is lost, so we ask the + // installation itself instead of reporting that python is not installed - see + // https://github.com/devonfw/IDEasy/issues/2190 + version = readVersionFromPyvenvCfg(toolPath); + if (version == null) { + version = readVersionFromInterpreter(toolPath); + } + if (version != null) { + LOG.debug("Determined version {} of python from the installation at {}.", version, toolPath); + } + } + return version; + } + + /** + * @param installationPath the {@link Path} to the virtual environment. + * @return the {@link VersionIdentifier} from the {@code version_info} entry of {@code pyvenv.cfg} or {@code null} if not available or not precise enough. + */ + private VersionIdentifier readVersionFromPyvenvCfg(Path installationPath) { + + Path pyvenvCfg = installationPath.resolve(FILE_PYVENV_CFG); + if (!Files.exists(pyvenvCfg)) { + return null; + } + String content = this.context.getFileAccess().readFileContent(pyvenvCfg); + for (String line : content.split("\\R")) { + String[] keyAndValue = line.split("=", 2); + if ((keyAndValue.length == 2) && keyAndValue[0].trim().equals(PYVENV_CFG_VERSION_INFO)) { + String value = keyAndValue[1].trim(); + // uv only writes the minor version (e.g. "3.13") for its own interpreters what is too imprecise for us + if (value.chars().filter(c -> c == '.').count() >= 2) { + return VersionIdentifier.of(value); + } + LOG.debug("Ignoring imprecise version {} from {}.", value, pyvenvCfg); + } + } + return null; + } + + /** + * @param installationPath the {@link Path} to the virtual environment. + * @return the {@link VersionIdentifier} reported by the installed python interpreter or {@code null} if it could not be determined. + */ + private VersionIdentifier readVersionFromInterpreter(Path installationPath) { + + Path binPath = this.context.getFileAccess().getBinPath(installationPath); + Path binaryPath = binPath.resolve(getBinaryName()); + if (!Files.exists(binaryPath)) { + binaryPath = binPath.resolve(getBinaryName() + ".exe"); + } + if (!Files.exists(binaryPath)) { + LOG.debug("Python binary does not exist in {}.", binPath); + return null; + } + String output = this.context.newProcess().runAndGetSingleOutput(IdeLogLevel.DEBUG, binaryPath.toString(), "--version"); + if (output == null) { + return null; + } + String version = output.trim(); + int lastSpace = version.lastIndexOf(' '); + if (lastSpace >= 0) { + version = version.substring(lastSpace + 1); + } + if (!version.isEmpty() && Character.isDigit(version.charAt(0))) { + return VersionIdentifier.of(version); + } + LOG.debug("Could not parse version from output '{}' of {}.", output, binaryPath); + return null; + } + @Override public ToolRepository getToolRepository() { diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java index c1915d154e..a34683bf84 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/python/PythonTest.java @@ -1,5 +1,7 @@ package com.devonfw.tools.ide.tool.python; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; @@ -7,6 +9,7 @@ import org.junit.jupiter.api.Test; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.environment.VariableLine; @@ -67,6 +70,44 @@ public void testInstallOnIntelMacResolvesVersionFromUvNotIdeUrls(WireMockRuntime assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed python"); } + + /** + * Test that a missing {@code .ide.software.version} file is restored while the existing installation with all its packages is preserved. + * + * @param wireMockRuntimeInfo the {@link WireMockRuntimeInfo}. + * @throws IOException on error. + * @see issue 2190 + */ + @Test + public void testInstallRestoresMissingVersionFileAndPreservesPackages(WireMockRuntimeInfo wireMockRuntimeInfo) throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_UV, wireMockRuntimeInfo); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Python python = context.getCommandletManager().getCommandlet(Python.class); + python.install(); + Path pythonPath = context.getSoftwarePath().resolve("python"); + Path versionFile = pythonPath.resolve(IdeContext.FILE_SOFTWARE_VERSION); + Path userPackage = pythonPath.resolve("lib").resolve("site-packages").resolve("mylib").resolve("__init__.py"); + Files.createDirectories(userPackage.getParent()); + Files.writeString(userPackage, "# installed via pip"); + // simulate that uv or python has removed our version file from the virtual environment + Files.delete(versionFile); + // the version is still determined from the installation itself (e.g. for "ide get-version python") + assertThat(python.getInstalledVersion(pythonPath)).isEqualTo(VersionIdentifier.of("3.14.6")); + + // act + python.install(); + + // assert + assertThat(versionFile).exists().hasContent("3.14.6"); + assertThat(userPackage).exists(); + assertThat(python.getInstalledVersion()).isEqualTo(VersionIdentifier.of("3.14.6")); + assertThat(context).logAtWarning().hasMessageContaining("Version file is missing"); + assertThat(context).logAtWarning().hasNoMessageContaining("Deleting corrupted installation"); + } + + @Test public void testSetEnvironment() { diff --git a/cli/src/test/resources/ide-projects/uv/repository/python/python/default/bin/python b/cli/src/test/resources/ide-projects/uv/repository/python/python/default/bin/python old mode 100644 new mode 100755 index 92202cc6f4..7a30adf90e --- a/cli/src/test/resources/ide-projects/uv/repository/python/python/default/bin/python +++ b/cli/src/test/resources/ide-projects/uv/repository/python/python/default/bin/python @@ -1,2 +1,6 @@ #!/bin/bash +if [ "$1" = "--version" ]; then + echo "Python 3.14.6" + exit 0 +fi echo "python $*"