Skip to content
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/2100[#2100]: Fix Python not available for Mac x64

The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/48?closed=1[milestone 2026.08.001].

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
import com.devonfw.tools.ide.tool.pip.PipRepository;
import com.devonfw.tools.ide.tool.repository.DefaultToolRepository;
import com.devonfw.tools.ide.tool.repository.ToolRepository;
import com.devonfw.tools.ide.tool.python.PythonRepository;
import com.devonfw.tools.ide.tool.uv.UvRepository;
import com.devonfw.tools.ide.url.model.UrlMetadata;
import com.devonfw.tools.ide.util.DateTimeUtil;
Expand Down Expand Up @@ -159,6 +160,8 @@ public abstract class AbstractIdeContext implements IdeContext, IdeLogArgFormatt

private UvRepository uvRepository;

private PythonRepository pythonRepository;

private DirectoryMerger workspaceMerger;

protected UrlMetadata urlMetadata;
Expand Down Expand Up @@ -306,6 +309,13 @@ protected UvRepository createUvRepository() {
return new UvRepository(this);
}

/**
* @return a new {@link PythonRepository}
*/
protected PythonRepository createPythonRepository() {
return new PythonRepository(this);
}

private Path findIdeRoot(Path ideHomePath) {

Path ideRootPath = null;
Expand Down Expand Up @@ -533,6 +543,14 @@ public UvRepository getUvRepository() {
return this.uvRepository;
}

@Override
public PythonRepository getPythonRepository() {
if (this.pythonRepository == null) {
this.pythonRepository = createPythonRepository();
}
return this.pythonRepository;
}

@Override
public CustomToolRepository getCustomToolRepository() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.devonfw.tools.ide.tool.npm.NpmRepository;
import com.devonfw.tools.ide.tool.pip.PipRepository;
import com.devonfw.tools.ide.tool.repository.ToolRepository;
import com.devonfw.tools.ide.tool.python.PythonRepository;
import com.devonfw.tools.ide.tool.uv.UvRepository;
import com.devonfw.tools.ide.url.model.UrlMetadata;
import com.devonfw.tools.ide.variable.IdeVariables;
Expand Down Expand Up @@ -378,6 +379,11 @@ default void requireOnline(String purpose, boolean explicitOnlineCheck) {
*/
UvRepository getUvRepository();

/**
* @return the {@link PythonRepository}.
*/
PythonRepository getPythonRepository();

/**
* @return the {@link Path} to the IDE instance directory. You can have as many IDE instances on the same computer as independent tenants for different
* isolated projects.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.devonfw.tools.ide.tool.ToolCommandlet;
import com.devonfw.tools.ide.tool.ToolInstallRequest;
import com.devonfw.tools.ide.tool.ToolInstallation;
import com.devonfw.tools.ide.tool.repository.ToolRepository;
import com.devonfw.tools.ide.tool.uv.Uv;
import com.devonfw.tools.ide.version.VersionIdentifier;

Expand Down Expand Up @@ -74,6 +75,12 @@ protected boolean isIgnoreSoftwareRepo() {
return true;
}

@Override
public ToolRepository getToolRepository() {

return this.context.getPythonRepository();
}

/**
* Creates a symlink from the "Scripts" folder to the "bin" folder on Windows systems. This is necessary for compatibility with tools that expect a "bin"
* directory.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.devonfw.tools.ide.tool.python;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.process.ProcessErrorHandling;
import com.devonfw.tools.ide.process.ProcessMode;
import com.devonfw.tools.ide.process.ProcessResult;
import com.devonfw.tools.ide.tool.ToolCommandlet;
import com.devonfw.tools.ide.tool.repository.AbstractToolRepository;
import com.devonfw.tools.ide.tool.uv.Uv;
import com.devonfw.tools.ide.url.model.file.UrlDownloadFileMetadata;
import com.devonfw.tools.ide.version.VersionIdentifier;

/**
* {@link com.devonfw.tools.ide.tool.repository.ToolRepository ToolRepository} for Python.
*/
public class PythonRepository extends AbstractToolRepository {

/** {@link #getId() ID} of this repository. */
public static final String ID = "python";

private static final Logger LOG = LoggerFactory.getLogger(PythonRepository.class);

private List<VersionIdentifier> cachedVersions;

/**
* The constructor.
*
* @param context the owning {@link IdeContext}.
*/
public PythonRepository(IdeContext context) {

super(context);
}

@Override
public String getId() {

return ID;
}

@Override
public List<String> getSortedEditions(String tool) {

return List.of(tool);
}

@Override
public List<VersionIdentifier> getSortedVersions(String tool, String edition, ToolCommandlet toolCommandlet) {

if (this.cachedVersions == null) {
this.cachedVersions = computeSortedVersions();
}
return this.cachedVersions;
}

private List<VersionIdentifier> computeSortedVersions() {

List<PythonUvListEntry> entries = fetchUvPythonList();
List<VersionIdentifier> versions = new ArrayList<>();
for (PythonUvListEntry entry : entries) {
if (entry.isCpython()) {
VersionIdentifier version = VersionIdentifier.of(entry.version());
if ((version != null) && !versions.contains(version)) {
versions.add(version);
}
}
}
versions.sort(Comparator.reverseOrder());
LOG.debug("Found {} Python version(s) available via uv for the current platform.", versions.size());
return versions;
}

/**
* Runs {@code uv python list} and parses the result. Extracted as a protected method so tests can stub the {@code uv} interaction.
*
* @return the parsed {@link PythonUvListEntry entries}.
*/
protected List<PythonUvListEntry> fetchUvPythonList() {

Uv uv = this.context.getCommandletManager().getCommandlet(Uv.class);
// We use the runTool variant that ensures uv is installed before running the command.
ProcessResult result = uv.runTool(ProcessMode.DEFAULT_CAPTURE, null, ProcessErrorHandling.THROW_CLI,
List.of("python", "list", "--all-versions", "--only-downloads", "--output-format", "json", "--no-config"));
return uv.parsePythonListJson(result.getOut());
}
Comment thread
hohwille marked this conversation as resolved.

@Override
protected UrlDownloadFileMetadata getMetadata(String tool, String edition, VersionIdentifier version, ToolCommandlet toolCommandlet) {

throw new UnsupportedOperationException(
"Python is installed via uv and is never downloaded from a URL. This repository only resolves versions.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devonfw.tools.ide.tool.python;

/**
* Represents a single entry of the JSON output of {@code uv python list --output-format json}. Only the fields required for version resolution are mapped.
*
* @param version the full Python version (e.g. {@code 3.11.8}) or {@code null} if absent.
* @param implementation the Python implementation (e.g. {@code cpython}, {@code pypy}) or {@code null} if absent.
*/
public record PythonUvListEntry(String version, String implementation) {

/**
* @return {@code true} if this entry is a CPython distribution, {@code false} otherwise.
*/
public boolean isCpython() {

return "cpython".equalsIgnoreCase(this.implementation);
}
}
38 changes: 38 additions & 0 deletions cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package com.devonfw.tools.ide.tool.uv;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

import com.devonfw.tools.ide.common.Tag;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.json.JsonMapping;
import com.devonfw.tools.ide.process.EnvironmentContext;
import com.devonfw.tools.ide.process.ProcessContext;
import com.devonfw.tools.ide.process.ProcessMode;
import com.devonfw.tools.ide.process.ProcessResult;
import com.devonfw.tools.ide.tool.LocalToolCommandlet;
import com.devonfw.tools.ide.tool.ToolCommandlet;
import com.devonfw.tools.ide.tool.ToolInstallation;
import com.devonfw.tools.ide.tool.python.PythonUvListEntry;
import com.devonfw.tools.ide.version.VersionIdentifier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* {@link ToolCommandlet} for <a href="https://docs.astral.sh/uv/">uv</a>.
Expand Down Expand Up @@ -45,6 +50,39 @@ public void installPython(Path installationPath, VersionIdentifier resolvedVersi
assert result.isSuccessful();
}

private static final ObjectMapper MAPPER = JsonMapping.create();

/**
* Parses the JSON output of {@code uv python list --output-format json} into a list of {@link PythonUvListEntry entries}.
*
* @param jsonLines the captured standard output lines of the {@code uv} command.
* @return the {@link List} of parsed {@link PythonUvListEntry entries}.
*/
public List<PythonUvListEntry> parsePythonListJson(List<String> jsonLines) {

String json = String.join("\n", jsonLines).trim();
List<PythonUvListEntry> entries = new ArrayList<>();
if (json.isEmpty()) {
return entries;
}
try {
JsonNode root = MAPPER.readTree(json);
if (root.isArray()) {
for (JsonNode node : root) {
JsonNode versionNode = node.get("version");
if ((versionNode != null) && !versionNode.isNull()) {
JsonNode implNode = node.get("implementation");
String implementation = (implNode != null && !implNode.isNull()) ? implNode.asText() : null;
entries.add(new PythonUvListEntry(versionNode.asText(), implementation));
}
}
}
} catch (Exception e) {
throw new IllegalStateException("Failed to parse JSON output of 'uv python list'.", e);
}
return entries;
}

@Override
public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
*/
public class UvRepository extends ArtifactToolRepository<UvArtifact, UvArtifactMetadata> {

/** Base URL of PyPI registry */
public static final String REGISTRY_URL = "https://pypi.org/pypi/";

private static final ObjectMapper MAPPER = JsonMapping.create();

/** {@link #getId() ID} of this repository. */
public static final String ID = "uv";

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.devonfw.tools.ide.tool.python;

import java.util.List;

import org.junit.jupiter.api.Test;

import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeTestContext;
import com.devonfw.tools.ide.tool.uv.Uv;
import com.devonfw.tools.ide.version.VersionIdentifier;

/**
* Test of {@link PythonRepository}.
*/
public class PythonRepositoryTest extends AbstractIdeContextTest {

/** JSON same structure as produced by {@code uv python list --all-versions --output-format json}. */
private static final String UV_PYTHON_LIST_JSON = """
[
{"version": "3.14.6", "implementation": "cpython", "os": "macos", "arch": "x86_64"},
{"version": "3.13.14", "implementation": "cpython", "os": "macos", "arch": "x86_64"},
{"version": "3.11.4", "implementation": "cpython", "os": "macos", "arch": "x86_64"},
{"version": "7.3.17", "implementation": "pypy", "os": "macos", "arch": "x86_64"}
]
""";

private PythonRepository newRepository(IdeTestContext context) {

return new PythonRepository(context) {
@Override
protected List<PythonUvListEntry> fetchUvPythonList() {
return context.getCommandletManager().getCommandlet(Uv.class).parsePythonListJson(List.of(UV_PYTHON_LIST_JSON));
}
};
}

@Test
public void testGetSortedVersionsComesFromUvAndDropsNonCpython() {

// arrange
IdeTestContext context = newContext(PROJECT_BASIC);
PythonRepository repository = newRepository(context);

// act
List<VersionIdentifier> versions = repository.getSortedVersions("python", "python", null);

// assert
assertThat(versions).containsExactly(
VersionIdentifier.of("3.14.6"),
VersionIdentifier.of("3.13.14"),
VersionIdentifier.of("3.11.4"));
// PyPy must not leak in as a python version
assertThat(versions).doesNotContain(VersionIdentifier.of("7.3.17"));
}

@Test
public void testResolveVersionPatternFindsPlatformSpecificBuild() {

// arrange
IdeTestContext context = newContext(PROJECT_BASIC);
PythonRepository repository = newRepository(context);

// act
VersionIdentifier resolved = repository.resolveVersion("python", "python", VersionIdentifier.of("3.14*"), null);

// assert
assertThat(resolved).isEqualTo(VersionIdentifier.of("3.14.6"));
}

@Test
public void testGetSortedEditionsIsToolNameOnly() {

// arrange
IdeTestContext context = newContext(PROJECT_BASIC);
PythonRepository repository = newRepository(context);

// act & assert
assertThat(repository.getSortedEditions("python")).containsExactly("python");
}
}
Loading
Loading