• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 37608043205

07 Oct 2026 10:32AM UTC coverage: 74.033% (-0.09%) from 74.125%
37608043205

Pull #2623

github

web-flow
Merge 04b654b70 into d71bec813
Pull Request #2623: #2561: Make python a pristine versioned tool with per-project venv is…

5775 of 8695 branches covered (66.42%)

Branch coverage included in aggregate %.

15172 of 19599 relevant lines covered (77.41%)

3.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

43.24
cli/src/main/java/com/devonfw/tools/ide/tool/python/Python.java
1
package com.devonfw.tools.ide.tool.python;
2

3
import java.nio.file.Files;
4
import java.nio.file.Path;
5
import java.nio.file.StandardCopyOption;
6
import java.util.List;
7
import java.util.Set;
8

9
import org.slf4j.Logger;
10
import org.slf4j.LoggerFactory;
11

12
import com.devonfw.tools.ide.cli.CliException;
13
import com.devonfw.tools.ide.common.Tag;
14
import com.devonfw.tools.ide.context.IdeContext;
15
import com.devonfw.tools.ide.io.FileAccess;
16
import com.devonfw.tools.ide.log.IdeLogLevel;
17
import com.devonfw.tools.ide.process.EnvironmentContext;
18
import com.devonfw.tools.ide.process.ProcessContext;
19
import com.devonfw.tools.ide.process.ProcessErrorHandling;
20
import com.devonfw.tools.ide.process.ProcessMode;
21
import com.devonfw.tools.ide.process.ProcessResult;
22
import com.devonfw.tools.ide.tool.AbstractLocalToolCommandlet;
23
import com.devonfw.tools.ide.tool.ToolInstallRequest;
24
import com.devonfw.tools.ide.tool.ToolInstallation;
25
import com.devonfw.tools.ide.tool.repository.ToolRepository;
26
import com.devonfw.tools.ide.tool.uv.Uv;
27
import com.devonfw.tools.ide.version.VersionIdentifier;
28

29
/**
30
 * {@link AbstractToolCommandlet} for <a href="https://www.python.org/">python</a>.
31
 */
32
public class Python extends AbstractLocalToolCommandlet {
33

34
  private static final Logger LOG = LoggerFactory.getLogger(Python.class);
3 ✔
35

36
  private static final VersionIdentifier PYTHON_MIN_VERSION = VersionIdentifier.of("3.8.2");
4 ✔
37

38
  /** The per-project folder (inside {@code IDE_HOME}) that holds the {@code uv} tool store (e.g. {@code ruff}). */
39
  static final String UV_TOOLS_FOLDER = ".uv-tools";
40

41
  /** The per-project folder (inside {@code IDE_HOME}) that holds the virtual environment for the project's python packages. */
42
  static final String VENV_FOLDER = ".venv";
43

44
  /** The tool primed into the per-project {@code uv} tool store on installation so that it is available immediately. */
45
  private static final String RUFF_TOOL = "ruff";
46

47
  private static final String FILE_PYVENV_CFG = "pyvenv.cfg";
48

49
  private static final String PYVENV_CFG_VERSION_INFO = "version_info";
50

51
  /**
52
   * The constructor.
53
   *
54
   * @param context the {@link IdeContext}.
55
   */
56
  public Python(IdeContext context) {
57

58
    super(context, "python", Set.of(Tag.PYTHON));
6 ✔
59
  }
1 ✔
60

61
  @Override
62
  protected void performToolInstallation(ToolInstallRequest request, Path installationPath) {
63

64
    VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion();
4 ✔
65
    if (resolvedVersion.compareVersion(PYTHON_MIN_VERSION).isLess()) {
5 !
66
      throw new CliException("Python version must be at least " + this.PYTHON_MIN_VERSION);
×
67
    }
68

69
    // Python is installed as a pristine, versioned interpreter into the shared software repository (see getInstallationPath). uv lays the interpreter out in a
70
    // nested "cpython-«version»-«platform»" folder plus management files, so we install into a scratch directory and move the interpreter folder into place.
71
    FileAccess fileAccess = this.context.getFileAccess();
4 ✔
72
    if (Files.exists(installationPath)) {
5 !
73
      fileAccess.backup(installationPath);
×
74
    }
75

76
    Path scratchDir = fileAccess.createTempDir("python-install");
4 ✔
77
    try {
78
      Uv uv = this.context.getCommandletManager().getCommandlet(Uv.class);
7 ✔
79
      uv.installInterpreter(scratchDir, resolvedVersion, request.getProcessContext());
6 ✔
80

81
      Path interpreterDir = findInterpreterDir(scratchDir, resolvedVersion);
5 ✔
82
      fileAccess.mkdirs(installationPath.getParent());
4 ✔
83
      fileAccess.move(interpreterDir, installationPath, StandardCopyOption.REPLACE_EXISTING);
10 ✔
84
      this.context.writeVersionFile(resolvedVersion, installationPath);
5 ✔
85
      createWindowsSymlinkBinFolder(fileAccess, installationPath);
4 ✔
86
      LOG.debug("Installed pristine {} in version {} at {}", this.tool, resolvedVersion, installationPath);
18 ✔
87
    } finally {
88
      fileAccess.delete(scratchDir);
3 ✔
89
    }
90
  }
1 ✔
91

92
  /**
93
   * Locates the {@code cpython-«version»} interpreter folder created by {@code uv python install} inside the given scratch directory.
94
   *
95
   * @param scratchDir the directory passed to {@code uv python install --install-dir}
96
   * @param version the {@link VersionIdentifier} that was installed
97
   * @return the {@link Path} of the interpreter folder.
98
   */
99
  private Path findInterpreterDir(Path scratchDir, VersionIdentifier version) {
100

101
    Path result = this.context.getFileAccess().findFirst(scratchDir, p -> {
9 ✔
102
      String name = p.getFileName().toString();
4 ✔
103
      return name.startsWith("cpython-" + version) && !name.equals(version.toString());
14 !
104
    }, false);
105
    if (result == null) {
2 !
106
      throw new CliException("Could not find the python interpreter installed by uv in " + scratchDir);
×
107
    }
108
    return result;
2 ✔
109
  }
110

111
  /**
112
   * Places the pristine interpreter in the shared software repository under the {@link ToolRepository#ID_DEFAULT default} namespace
113
   * ({@code $IDE_ROOT/software/default/python/python/«version»}) - the same layout the default repository produces for other pristine, versioned tools (e.g.
114
   * node). Version resolution itself still uses {@link PythonRepository} (see {@link #getToolRepository()}), which is only consulted to list available
115
   * versions via {@code uv}; it is not a download source, so the pristine interpreter must not be stored under its {@link PythonRepository#ID ID}.
116
   *
117
   * @param edition the {@link #getConfiguredEdition() tool edition}.
118
   * @param resolvedVersion the resolved {@link VersionIdentifier version}.
119
   * @return the {@link Path} where the pristine interpreter is (to be) installed.
120
   */
121
  @Override
122
  protected Path getInstallationPath(String edition, VersionIdentifier resolvedVersion) {
123

124
    Path softwareRepositoryPath = this.context.getSoftwareRepositoryPath();
4 ✔
125
    if (softwareRepositoryPath == null) {
2 !
126
      return null;
×
127
    }
128
    return softwareRepositoryPath.resolve(ToolRepository.ID_DEFAULT).resolve(this.tool).resolve(edition).resolve(resolvedVersion.toString());
12 ✔
129
  }
130

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

134
    super.setEnvironment(environmentContext, toolInstallation, additionalInstallation);
5 ✔
135
    Path ideHome = this.context.getIdeHome();
4 ✔
136
    if (ideHome == null) {
2 !
137
      // running in global mode (no project) - there is no per-project virtual environment to point to
138
      return;
×
139
    }
140
    // The python packages of the project (pip / ruff / ...) live in a per-project virtual environment so that two projects on the same python interpreter
141
    // don't collide (see #352). The pristine interpreter itself stays shared in the software repository.
142
    Path venvPath = ideHome.resolve(VENV_FOLDER);
4 ✔
143
    environmentContext.withEnvVar("VIRTUAL_ENV", venvPath.toString());
6 ✔
144
    environmentContext.withEnvVar("UV_PROJECT_ENVIRONMENT", venvPath.toString());
6 ✔
145
    environmentContext.withPathEntry(venvPath.resolve("bin"));
6 ✔
146
  }
1 ✔
147

148
  @Override
149
  protected void postInstall(ToolInstallRequest request) {
150

151
    super.postInstall(request);
3 ✔
152
    setupProjectEnvironment(request);
3 ✔
153
  }
1 ✔
154

155
  /**
156
   * Creates the per-project virtual environment (backed by the pristine interpreter) and primes {@code ruff} into the per-project {@code uv} tool store. Only
157
   * done inside a project (a pristine interpreter is shared and must not be modified).
158
   *
159
   * @param request the {@link ToolInstallRequest}.
160
   */
161
  private void setupProjectEnvironment(ToolInstallRequest request) {
162

163
    Path ideHome = this.context.getIdeHome();
4 ✔
164
    if (ideHome == null) {
2 !
165
      return;
×
166
    }
167
    VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion();
4 ✔
168
    Uv uv = this.context.getCommandletManager().getCommandlet(Uv.class);
7 ✔
169
    Path uvToolDir = ideHome.resolve(UV_TOOLS_FOLDER);
4 ✔
170

171
    // create the per-project virtual environment backed by the pristine interpreter
172
    Path venvPath = ideHome.resolve(VENV_FOLDER);
4 ✔
173
    Path interpreterDir = getInstallationPath(getConfiguredEdition(), resolvedVersion);
6 ✔
174
    if (interpreterDir != null) {
2 !
175
      uv.createVirtualEnvironment(venvPath, interpreterDir, request.getProcessContext());
6 ✔
176
    }
177

178
    // prime ruff into the per-project uv tool store (best effort - a failure must not fail the python installation)
179
    ProcessContext processContext = request.getProcessContext().withEnvVar("UV_TOOL_DIR", uvToolDir.toString())
9 ✔
180
        .withEnvVar("UV_TOOL_BIN_DIR", uvToolDir.resolve("bin").toString());
4 ✔
181
    uv.installTool(RUFF_TOOL, processContext);
4 ✔
182
  }
1 ✔
183

184
  @Override
185
  protected boolean isIgnoreMissingSoftwareVersionFile() {
186

187
    // https://github.com/devonfw/IDEasy/issues/2190
188
    return true;
×
189
  }
190

191
  @Override
192
  protected VersionIdentifier computeInstalledVersionFromLocalSoftwareFolder() {
193

194
    Path toolPath = getToolPath();
×
195
    VersionIdentifier version = readVersionFromPyvenvCfg(toolPath);
×
196
    if (version == null) {
×
197
      version = readVersionFromInterpreter(toolPath);
×
198
    }
199
    if (version != null) {
×
200
      LOG.debug("Determined version {} of python from the installation at {}.", version, toolPath);
×
201
    }
202
    return version;
×
203
  }
204

205
  /**
206
   * @param installationPath the {@link Path} to the virtual environment.
207
   * @return the {@link VersionIdentifier} from the {@code version_info} entry of {@code pyvenv.cfg} or {@code null} if not available or not precise enough.
208
   */
209
  private VersionIdentifier readVersionFromPyvenvCfg(Path installationPath) {
210

211
    Path pyvenvCfg = installationPath.resolve(FILE_PYVENV_CFG);
×
212
    if (!Files.exists(pyvenvCfg)) {
×
213
      return null;
×
214
    }
215
    String content = this.context.getFileAccess().readFileContent(pyvenvCfg);
×
216
    for (String line : content.split("\\R")) {
×
217
      String[] keyAndValue = line.split("=", 2);
×
218
      if ((keyAndValue.length == 2) && keyAndValue[0].trim().equals(PYVENV_CFG_VERSION_INFO)) {
×
219
        String value = keyAndValue[1].trim();
×
220
        // uv only writes the minor version (e.g. "3.13") for its own interpreters what is too imprecise for us
221
        if (value.chars().filter(c -> c == '.').count() >= 2) {
×
222
          return VersionIdentifier.of(value);
×
223
        }
224
        LOG.debug("Ignoring imprecise version {} from {}.", value, pyvenvCfg);
×
225
      }
226
    }
227
    return null;
×
228
  }
229

230
  /**
231
   * @param installationPath the {@link Path} to the virtual environment.
232
   * @return the {@link VersionIdentifier} reported by the installed python interpreter or {@code null} if it could not be determined.
233
   */
234
  private VersionIdentifier readVersionFromInterpreter(Path installationPath) {
235

236
    Path binPath = this.context.getFileAccess().getBinPath(installationPath);
×
237
    Path binaryPath = this.context.getPath().findBinary(binPath.resolve(getBinaryName()));
×
238
    if (!Files.exists(binaryPath)) {
×
239
      LOG.debug("Python binary does not exist in {}.", binPath);
×
240
      return null;
×
241
    }
242
    ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.NONE).withPathEntry(binPath);
×
243
    ProcessResult result = runTool(pc, ProcessMode.DEFAULT_CAPTURE, List.of("--version"));
×
244
    String output = result.getSingleOutput(IdeLogLevel.DEBUG);
×
245
    if (output == null) {
×
246
      return null;
×
247
    }
248
    String version = output.trim();
×
249
    int lastSpace = version.lastIndexOf(' ');
×
250
    if (lastSpace >= 0) {
×
251
      version = version.substring(lastSpace + 1);
×
252
    }
253
    if (!version.isEmpty() && Character.isDigit(version.charAt(0))) {
×
254
      return VersionIdentifier.of(version);
×
255
    }
256
    LOG.debug("Could not parse version from output '{}' of {}.", output, binPath);
×
257
    return null;
×
258
  }
259

260
  @Override
261
  public ToolRepository getToolRepository() {
262

263
    return this.context.getPythonRepository();
4 ✔
264
  }
265

266
  /**
267
   * 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"
268
   * directory.
269
   *
270
   * @param fileAccess the {@link FileAccess} utility for file operations.
271
   * @param installationPath the path where Python is installed.
272
   */
273
  private void createWindowsSymlinkBinFolder(FileAccess fileAccess, Path installationPath) {
274

275
    if (!this.context.getSystemInfo().isWindows()) {
5 !
276
      return;
1 ✔
277
    }
278
    Path scriptsPath = installationPath.resolve("Scripts");
×
279
    Path binPath = installationPath.resolve("bin");
×
280
    fileAccess.symlink(scriptsPath, binPath);
×
281
  }
×
282

283
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc