Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ protected boolean isIgnoreMissingSoftwareVersionFile() {
return false;
}


@Override
protected ToolInstallation doInstall(ToolInstallRequest request) {

Expand Down Expand Up @@ -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.
}
}
}
Expand Down Expand Up @@ -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() {

Expand Down
80 changes: 80 additions & 0 deletions cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -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() {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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;

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;
Expand Down Expand Up @@ -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 <a href="https://github.com/devonfw/IDEasy/issues/2190">issue 2190</a>
*/
@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() {

Expand Down
4 changes: 4 additions & 0 deletions cli/src/test/resources/ide-projects/uv/repository/python/python/default/bin/python
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
#!/bin/bash
if [ "$1" = "--version" ]; then
echo "Python 3.14.6"
exit 0
fi
echo "python $*"