PY-88442 detect Python environments on targets

Run environment discovery from the mapped SDK-add working directory as part
of the existing batched target probe. Cache the results for the filesystem
session to avoid additional target process launches.

GitOrigin-RevId: b5e52c76d2145c500a4f1e714541bba44baa3f4b
This commit is contained in:
Alexey Katsman
2026-08-05 18:37:12 +00:00
committed by intellij-monorepo-bot
parent 876a90d54f
commit 25329e2c4a
17 changed files with 283 additions and 58 deletions
+59 -3
View File
@@ -148,19 +148,60 @@ print("true" if callable(is_gil_enabled) and not is_gil_enabled() else "false")
printf '}'
}
emit_environment_probe() {
possible_venv_home=$1
[ -d "$possible_venv_home" ] || return 0
environment_python="${possible_venv_home%/}/bin/python"
if environment_probe=$(run_python_probe "$environment_python"); then
if [ "$first_environment" -eq 0 ]; then
printf ','
fi
first_environment=0
printf '{"path":'
json_string "$environment_python"
printf ',"python":%s}' "$environment_probe"
fi
}
canonical_directory() {
(cd "$1" 2>/dev/null && pwd -P 2>/dev/null) || true
}
emit_environment_probes_in_directory() {
directory_to_probe=$1
[ -d "$directory_to_probe" ] || return 0
# Include regular and hidden direct children. Globbing is enabled only for these controlled suffixes.
set +f
for possible_venv_home in "$directory_to_probe"/*; do
emit_environment_probe "$possible_venv_home"
done
for possible_venv_home in "$directory_to_probe"/.*; do
case "${possible_venv_home##*/}" in
.|..) continue ;;
esac
emit_environment_probe "$possible_venv_home"
done
set -f
}
# argv protocol:
# --python <path-or-empty>
# --detect-environments <directory-or-empty>
# repeated <tool-name> <search-path-count> <search-path>...
# A search path is one of:
# absolute <directory>
# env <variable> <component-count> <component>...
# home <component-count> <component>...
# stdout is one JSON object containing shell, home, python, and found tools. Exit 2 means malformed argv.
# stdout is one JSON object containing shell, home, python, environments, and found tools. Exit 2 means malformed argv.
# python is null when not requested, minimal isExecutable=false on failure, and a full object on success.
[ "$#" -ge 2 ] || exit 2
[ "$#" -ge 4 ] || exit 2
[ "$1" = "--python" ] || exit 2
PYTHON_PATH=$2
shift 2
[ "$3" = "--detect-environments" ] || exit 2
DETECT_ENVIRONMENTS_DIRECTORY=$4
shift 4
USER_NAME=$(whoami 2>/dev/null || true)
PASSWD_HOME=
@@ -188,6 +229,21 @@ elif python_probe=$(run_python_probe "$PYTHON_PATH"); then
else
printf '{"isExecutable":false}'
fi
printf ',"environments":['
first_environment=1
if [ -n "$DETECT_ENVIRONMENTS_DIRECTORY" ]; then
REQUESTED_WORKING_DIRECTORY=$DETECT_ENVIRONMENTS_DIRECTORY
REQUESTED_WORKING_DIRECTORY_CANONICAL=$(canonical_directory "$REQUESTED_WORKING_DIRECTORY")
TARGET_WORKING_DIRECTORY=$(pwd -P 2>/dev/null || true)
if [ -n "$REQUESTED_WORKING_DIRECTORY_CANONICAL" ]; then
emit_environment_probes_in_directory "$REQUESTED_WORKING_DIRECTORY"
fi
if [ -n "$TARGET_WORKING_DIRECTORY" ] && [ "$TARGET_WORKING_DIRECTORY" != "$REQUESTED_WORKING_DIRECTORY_CANONICAL" ]; then
emit_environment_probes_in_directory "$TARGET_WORKING_DIRECTORY"
fi
fi
printf ']'
printf ',"tools":{'
first_tool=1
@@ -41,11 +41,7 @@ data class ToolCommandExecutor(
suspend fun <P : PathHolder> detectToolExecutable(
fileSystem: FileSystem<P>,
filter: (P) -> Boolean,
): P? {
val toolSpec = toCommandSpec()
val resolvedSearchPaths = fileSystem.resolveToolSearchPaths(toolSpec)
return fileSystem.detectTool(toolSpec.toolName, resolvedSearchPaths, filter)
}
): P? = fileSystem.detectTool(toCommandSpec(), filter)
fun toCommandSpec(): ToolCommandSpec = ToolCommandSpec(toolName, KNOWN_SEARCH_PATHS + additionalSearchPaths)
@@ -4,7 +4,6 @@ package com.jetbrains.python.sdk
import com.intellij.execution.Platform
import com.intellij.execution.target.FullPathOnTarget
import com.intellij.openapi.util.NlsSafe
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import org.jetbrains.annotations.ApiStatus
@@ -24,17 +23,6 @@ data class ToolProbeResult<P : PathHolder>(
val versionOutput: String?,
)
@ApiStatus.Internal
suspend fun <P : PathHolder> FileSystem<P>.resolveToolSearchPaths(toolSpec: ToolCommandSpec): List<P> {
return toolSpec.searchPathsFor(platformAndRoot.platform).mapNotNull { searchPath ->
when (searchPath) {
is ToolSearchPath.AbsolutePath -> parsePath(searchPath.path).successOrNull
is ToolSearchPath.RelativePath -> getFullPath(searchPath.prefixEnvVar, searchPath.pathComponents)
is ToolSearchPath.RelativePathFromHome -> getFullPathFromHome(searchPath.pathComponents)
}
}
}
/**
* Represents a location to search for a tool executable.
*
@@ -84,11 +84,7 @@ interface FileSystem<P : PathHolder> {
fun normalizePathToRemote(path: P): P
suspend fun detectEnvironments(workingDir: Path, uiInfoGetter: (P) -> PyToolUIInfo?): List<DetectedSelectableInterpreter<P>>
suspend fun detectTool(
toolName: String,
additionalSearchPaths: List<P> = listOf(),
filter: (P) -> Boolean = { true },
): P?
suspend fun detectTool(toolSpec: ToolCommandSpec, filter: (P) -> Boolean = { true }): P?
suspend fun probeTools(toolSpecs: List<ToolCommandSpec>): PyResult<Map<String, ToolProbeResult<P>>>
@@ -0,0 +1,8 @@
package com.jetbrains.python.sdk.add.v2
import com.jetbrains.python.sdk.ToolCommandSpec
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
suspend fun <P : PathHolder> FileSystem<P>.detectTool(toolName: String, filter: (P) -> Boolean = { true }): P? =
detectTool(ToolCommandSpec(toolName, emptyList()), filter)
@@ -13,6 +13,7 @@ import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.detectTool
import com.jetbrains.python.sdk.add.v2.toFileSystem
import com.jetbrains.python.sdk.impl.PySdkBundle
import org.jetbrains.annotations.ApiStatus
@@ -11,7 +11,7 @@ import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
/**
* TODO PY-87712 Should drop as a whole
* TODO PY-91452 Should drop as a whole
* uvWorkingDirectory - workingDirectory in PythonSdkAdditionalData
* usePip - can be deduced based on requirementsFile in PythonSdkAdditionalData
* venvPath - sdkHome
@@ -82,7 +82,6 @@ fun PythonExecution.buildTargetedCommandLine(
if (runTool != null) {
applyRunToolAsync(commandLineBuilder, runTool)
// TODO PY-87712 maybe need proper handling of envs (duplicates?)
runTool.envs.forEach { (k, v) ->
commandLineBuilder.addEnvironmentVariable(k, v)
}
@@ -68,6 +68,7 @@ import com.jetbrains.python.sdk.PythonSdkType
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.ToolCommandSpec
import com.jetbrains.python.sdk.ToolProbeResult
import com.jetbrains.python.sdk.ToolSearchPath
import com.jetbrains.python.sdk.asBinToExecute
import com.jetbrains.python.sdk.associatedModulePath
import com.jetbrains.python.sdk.createSdk
@@ -76,7 +77,6 @@ import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.impl.resolvePythonBinary
import com.jetbrains.python.sdk.impl.resolvePythonHome
import com.jetbrains.python.sdk.isSystemWide
import com.jetbrains.python.sdk.resolveToolSearchPaths
import com.jetbrains.python.target.PyTargetAwareAdditionalData
import com.jetbrains.python.target.PythonLanguageRuntimeConfiguration
import com.jetbrains.python.target.ui.TargetPanelExtension
@@ -315,10 +315,10 @@ data class EelFileSystem(
}
override suspend fun detectTool(
toolName: String,
additionalSearchPaths: List<PathHolder.Eel>,
toolSpec: ToolCommandSpec,
filter: (PathHolder.Eel) -> Boolean,
): PathHolder.Eel? = withContext(Dispatchers.IO) {
val toolName = toolSpec.toolName
val fromPath = eelApi.exec.where(toolName)
?.asNioPath()
?.let { PathHolder.Eel(it) }
@@ -328,6 +328,7 @@ data class EelFileSystem(
val binaryNames =
if (eelApi.platform.isWindows) listOf("$toolName.exe", "$toolName.bat")
else listOf(toolName)
val additionalSearchPaths = resolveToolSearchPaths(toolSpec)
for (path in additionalSearchPaths) {
assert(path.path.getEelDescriptor() == eelApi.descriptor) {
"Additional search paths should be on the same descriptor as EelFileSystem API, but $path isn't on $eelApi"
@@ -349,8 +350,7 @@ data class EelFileSystem(
): PyResult<Map<String, ToolProbeResult<PathHolder.Eel>>> = withContext(Dispatchers.IO) {
val result = linkedMapOf<String, ToolProbeResult<PathHolder.Eel>>()
for (toolSpec in toolSpecs) {
val resolvedSearchPaths = resolveToolSearchPaths(toolSpec)
val path = detectTool(toolSpec.toolName, resolvedSearchPaths) ?: continue
val path = detectTool(toolSpec) ?: continue
val versionOutput = ExecService().execGetStdout(getBinaryToExec(path), Args("--version")).successOrNull
result[toolSpec.toolName] = ToolProbeResult(path, versionOutput)
}
@@ -383,6 +383,7 @@ data class EelFileSystem(
data class TargetFileSystem(
val targetEnvironmentConfiguration: TargetEnvironmentConfiguration,
private val pythonLanguageRuntimeConfiguration: PythonLanguageRuntimeConfiguration,
private val targetProbeWorkingDirectory: Path? = null,
) : FileSystem<PathHolder.Target> {
override val isReadOnly: Boolean
get() = !PythonInterpreterTargetEnvironmentFactory.isMutable(targetEnvironmentConfiguration)
@@ -398,6 +399,7 @@ data class TargetFileSystem(
private lateinit var home: PathHolder.Target
private val toolProbeCache = ToolProbeCache<ToolCommandSpec, ToolProbeResult<PathHolder.Target>>()
private val targetUserInfoLock = Mutex()
private var detectedEnvironments: List<TargetEnvironmentProbe>? = null
override fun parsePath(raw: String): PyResult<PathHolder.Target> {
return PyResult.success(PathHolder.Target(raw))
@@ -415,7 +417,7 @@ data class TargetFileSystem(
) {
val targetType = targetEnvironmentConfiguration.getTargetType()
if (targetType is BrowsableTargetEnvironmentType) {
val descriptor = FileChooserDescriptorFactory.singleFile().withTitle(browseTitle)
val descriptor = FileChooserDescriptorFactory.singleFileOrDir().withTitle(browseTitle)
val hints = TargetBrowserHints(showLocalFsInBrowser = true, descriptor)
val actionListener = targetType.createBrowser(
@@ -612,12 +614,21 @@ data class TargetFileSystem(
return PathHolder.Target(targetPath)
}
// TODO PY-87712 Support detection for remotes
override suspend fun detectEnvironments(
workingDir: Path,
uiInfoGetter: (PathHolder.Target) -> PyToolUIInfo?,
): List<DetectedSelectableInterpreter<PathHolder.Target>> {
return emptyList()
probeTools(ADD_INTERPRETER_TOOL_COMMAND_SPECS).orLogException(LOG) ?: return emptyList()
val environments = targetUserInfoLock.withLock { detectedEnvironments.orEmpty() }
return environments.mapNotNull { environment ->
val pythonInfo = environment.python.toPythonInfo() ?: return@mapNotNull null
DetectedSelectableInterpreter(
homePath = environment.path,
pythonInfo = pythonInfo,
isBase = false,
ui = uiInfoGetter(environment.path),
)
}
}
private suspend fun which(cmd: String): PathHolder.Target? {
@@ -627,13 +638,18 @@ data class TargetFileSystem(
}
override suspend fun detectTool(
toolName: String,
additionalSearchPaths: List<PathHolder.Target>,
toolSpec: ToolCommandSpec,
filter: (PathHolder.Target) -> Boolean,
): PathHolder.Target? = withContext(Dispatchers.IO) {
val toolName = toolSpec.toolName
if (toolSpec in ADD_INTERPRETER_TOOL_COMMAND_SPECS) {
val probeResult = probeTools(ADD_INTERPRETER_TOOL_COMMAND_SPECS).orLogException(LOG)
if (probeResult != null) return@withContext probeResult[toolName]?.path
}
val fromWhich = which(toolName)?.takeIf(filter)
if (fromWhich != null) return@withContext fromWhich
val additionalSearchPaths = resolveToolSearchPaths(toolSpec)
for (path in additionalSearchPaths) {
val candidate = parsePath("${path.pathString}/$toolName").successOrNull
?.takeIf { filter(it) && fileExists(it) }
@@ -650,7 +666,7 @@ data class TargetFileSystem(
val probes = toolProbeCache.getOrLoad(toolSpecs) loader@{ missingSpecs ->
val pythonPath = getPythonPathToProbe()
val snapshot = probeTargetTools(missingSpecs, pythonPath).getOr { return@loader it }
val snapshot = probeTargetTools(missingSpecs, pythonPath, targetProbeWorkingDirectory).getOr { return@loader it }
updateFromProbe(snapshot, pythonPath)
PyResult.success(snapshot.tools)
}.getOr { return@withContext it }
@@ -693,19 +709,24 @@ data class TargetFileSystem(
if (pythonPath != null && cachedPython != null) {
systemPythonCache.putIfAbsent(pythonPath, cachedPython)
}
if (detectedEnvironments == null) {
detectedEnvironments = snapshot.environments
}
}
}
private fun TargetPythonProbe.toCachedSystemPython(path: PathHolder.Target): CachedSystemPython? {
return when (this) {
TargetPythonProbe.NotExecutable -> CachedSystemPython.NotExecutable
is TargetPythonProbe.Executable -> {
val languageLevel = getLanguageLevelFromVersionStringSafe(versionOutput.trim()) ?: return null
CachedSystemPython.Executable(createSystemPython(path, PythonInfo(languageLevel, freeThreaded)))
}
is TargetPythonProbe.Executable -> toPythonInfo()?.let { CachedSystemPython.Executable(createSystemPython(path, it)) }
}
}
private fun TargetPythonProbe.Executable.toPythonInfo(): PythonInfo? {
val languageLevel = getLanguageLevelFromVersionStringSafe(versionOutput.trim()) ?: return null
return PythonInfo(languageLevel, freeThreaded)
}
override suspend fun getFullPath(prefixEnvVar: String, pathComponents: List<String>): PathHolder.Target? {
val prefix = getEnvVar(prefixEnvVar).successOrNull ?: return null
return getFullPathWithPrefix(prefix, pathComponents)
@@ -823,3 +844,13 @@ internal suspend fun <P : PathHolder> FileSystem<P>.getExistingSelectableInterpr
}
allValidSdks
}
private suspend fun <P : PathHolder> FileSystem<P>.resolveToolSearchPaths(toolSpec: ToolCommandSpec): List<P> {
return toolSpec.searchPathsFor(platformAndRoot.platform).mapNotNull { searchPath ->
when (searchPath) {
is ToolSearchPath.AbsolutePath -> parsePath(searchPath.path).successOrNull
is ToolSearchPath.RelativePath -> getFullPath(searchPath.prefixEnvVar, searchPath.pathComponents)
is ToolSearchPath.RelativePathFromHome -> getFullPathFromHome(searchPath.pathComponents)
}
}
}
@@ -16,10 +16,12 @@ import com.jetbrains.python.sdk.ToolSearchPath
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
private const val TOOL_VERSION_PROBE_HELPER = "tool_version_probe.sh"
private const val PYTHON_PATH_OPTION = "--python"
private const val DETECT_ENVIRONMENTS_OPTION = "--detect-environments"
private const val SEARCH_PATH_KIND_ABSOLUTE = "absolute"
private const val SEARCH_PATH_KIND_ENV = "env"
private const val SEARCH_PATH_KIND_HOME = "home"
@@ -29,6 +31,7 @@ private val TOOL_PROBE_JSON = Json { ignoreUnknownKeys = true }
internal suspend fun TargetFileSystem.probeTargetTools(
toolSpecs: List<ToolCommandSpec>,
pythonPath: PathHolder.Target?,
workingDir: Path?,
): PyResult<TargetProbeSnapshot> {
if (platformAndRoot.platform == Platform.WINDOWS) {
return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.windows.unsupported"))
@@ -36,10 +39,9 @@ internal suspend fun TargetFileSystem.probeTargetTools(
val helper = PythonHelpersLocator.findPathInHelpersPossibleNull(TOOL_VERSION_PROBE_HELPER)
?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.helper.missing", TOOL_VERSION_PROBE_HELPER))
val args = Args().addLocalFile(helper).addArgs(encodeToolProbeArgs(toolSpecs, pythonPath))
val output = ExecService().execGetStdout(
getBinaryToExec(PathHolder.Target("/bin/sh")),
args,
prepareArgs(helper, toolSpecs, pythonPath, workingDir),
ExecOptions(timeout = 2.minutes),
).getOr { return it }
val serializedSnapshot = try {
@@ -53,20 +55,42 @@ internal suspend fun TargetFileSystem.probeTargetTools(
serializedProbe.toTargetPythonProbe()
?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.output.invalid"))
}
val environments = serializedSnapshot.environments.map { environment ->
val path = environment.path.takeIf { it.isNotBlank() }
?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.output.invalid"))
val python = environment.python.toTargetPythonProbe() as? TargetPythonProbe.Executable
?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.output.invalid"))
TargetEnvironmentProbe(PathHolder.Target(path), python)
}
val tools = toolSpecs.mapNotNull { toolSpec ->
val tool = serializedSnapshot.tools[toolSpec.toolName] ?: return@mapNotNull null
val path = tool.path.takeIf { it.isNotBlank() } ?: return@mapNotNull null
toolSpec to ToolProbeResult(PathHolder.Target(path), tool.versionOutput)
}.toMap()
return PyResult.success(TargetProbeSnapshot(serializedSnapshot.home, serializedSnapshot.shell, pythonProbe, tools))
return PyResult.success(TargetProbeSnapshot(serializedSnapshot.home, serializedSnapshot.shell, pythonProbe, environments, tools))
}
private fun TargetFileSystem.prepareArgs(
helper: Path,
toolSpecs: List<ToolCommandSpec>,
pythonPath: PathHolder.Target?,
workingDir: Path?,
): Args {
val args = Args()
.addLocalFile(helper)
.addArgs(PYTHON_PATH_OPTION, pythonPath?.pathString.orEmpty())
if (workingDir != null) {
// Target file arguments are mapped through their parent. The "." keeps workingDir itself as the mapping root.
args.addArgs(DETECT_ENVIRONMENTS_OPTION).addLocalFile(workingDir.resolve("."))
}
return args.addArgs(encodeToolProbeArgs(toolSpecs))
}
private fun TargetFileSystem.encodeToolProbeArgs(
toolSpecs: List<ToolCommandSpec>,
pythonPath: PathHolder.Target?,
): List<String> = buildList {
add(PYTHON_PATH_OPTION)
add(pythonPath?.pathString.orEmpty())
for (toolSpec in toolSpecs) {
val searchPaths = toolSpec.searchPathsFor(platformAndRoot.platform)
add(toolSpec.toolName)
@@ -98,9 +122,16 @@ private data class SerializedTargetProbeSnapshot(
val shell: String,
val home: String,
val python: SerializedTargetPythonProbe? = null,
val environments: List<SerializedTargetEnvironmentProbe> = emptyList(),
val tools: Map<String, TargetToolProbe> = emptyMap(),
)
@Serializable
private data class SerializedTargetEnvironmentProbe(
val path: FullPathOnTarget,
val python: SerializedTargetPythonProbe,
)
@Serializable
private data class SerializedTargetPythonProbe(
val isExecutable: Boolean,
@@ -133,5 +164,11 @@ internal data class TargetProbeSnapshot(
val home: String,
val shell: String,
val python: TargetPythonProbe?,
val environments: List<TargetEnvironmentProbe>,
val tools: Map<ToolCommandSpec, ToolProbeResult<PathHolder.Target>>,
)
internal data class TargetEnvironmentProbe(
val path: PathHolder.Target,
val python: TargetPythonProbe.Executable,
)
@@ -289,10 +289,10 @@ internal class ValidatedPathField<T, P : PathHolder, VP : ValidatedPath<T, P>>(
}
}
private fun <T, P : PathHolder, V : ValidatedPath<T, P>> Panel.installToolRow(
private fun <T, P : PathHolder, V : ValidatedPath<T, P>> Panel.missingToolRow(
fileSystem: FileSystem<*>,
missingExecutableText: @Nls String,
installAction: ActionLink,
installAction: ActionLink?,
validatedPathField: ValidatedPathField<T, P, V>,
): Row {
val selectExecutableLink = if (fileSystem.isBrowsable && fileSystem.toolPathCanBePersisted) ActionLink(message("sdk.create.custom.select.executable.link")) {
@@ -302,7 +302,7 @@ private fun <T, P : PathHolder, V : ValidatedPath<T, P>> Panel.installToolRow(
return row("") {
validationTooltip(missingExecutableText,
if (fileSystem.toolPathCanBePersisted) installAction else null,
installAction,
selectExecutableLink,
validationType = ValidationType.WARNING,
inline = true)
@@ -331,11 +331,11 @@ internal fun <T, P : PathHolder, VP : ValidatedPath<T, P>> Panel.validatablePath
canBeEdited = canBeEdited,
)
if (missingExecutableText != null && installAction != null && !fileSystem.isReadOnly) {
installToolRow(
if (missingExecutableText != null) {
missingToolRow(
fileSystem = fileSystem,
missingExecutableText = missingExecutableText,
installAction = installAction,
installAction = if (canBeEdited) installAction else null,
validatedPathField = validatedPathField
).visibleIf(pathValidator.backProperty.transform { it?.pathHolder == null }.and(pathValidator.isDirtyValue.not()))
}
@@ -134,7 +134,6 @@ internal class EnvironmentCreatorUv<P : PathHolder>(
installAction = createInstallFix(errorSink)
)
// TODO PY-87712 Add banner if the venv does exist at the specified location
venvPathField = validatablePathField(
fileSystem = model.fileSystem,
pathValidator = model.uvViewModel.uvVenvValidator,
@@ -18,6 +18,7 @@ import com.jetbrains.python.packaging.PyPackageVersionNormalizer
import com.jetbrains.python.packaging.repository.PyPiPackageRepository
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.detectTool
import com.jetbrains.python.sdk.add.v2.toFileSystem
import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.installExecutableViaPythonScript
@@ -288,7 +288,6 @@ internal class UvPackageManager internal constructor(
return PyProjectToml.parseCached(module.project, pyProjectFile.virtualFile)?.project?.name ?: module.name
}
// TODO PY-87712 Double check for remotes
override suspend fun resolveDependencyFilesTree(): List<PyDependenciesFile> {
val rootFile = getRootDependenciesFile() ?: return emptyList()
val rootPyProjectToml = (rootFile as? PyProjectTomlFile) ?: return listOf(rootFile)
@@ -30,7 +30,6 @@ internal class UvRunToolProvider : PySdkRunToolProvider<UvSdkFlavorData, UvSdkFl
): PyRunToolParameters {
val env = mutableMapOf<String, String>()
val uvExecutable = getUvExecutable(fileSystem, flavorData.uvPath)?.toString()
// TODO PY-87712 Duplicated code for setting up uv envs
val pythonPath = fileSystem.parsePath(sdkHome).getOrThrow()
val venvPath = fileSystem.resolvePythonHome(pythonPath).toString()
env += "VIRTUAL_ENV" to venvPath
@@ -56,11 +56,13 @@ internal class PythonLanguageRuntimeUI(
override fun createPanel(): DialogPanel {
val targetEnvironmentConfiguration = targetSupplier.get()
val projectPath = getProjectPath()
val model = PythonLocalAddInterpreterModel(
ProjectPathFlows.create(getProjectPath()),
ProjectPathFlows.create(projectPath),
TargetFileSystem(
targetEnvironmentConfiguration = targetEnvironmentConfiguration,
pythonLanguageRuntimeConfiguration = config,
targetProbeWorkingDirectory = projectPath,
)
)
model.navigator.selectionMode = AtomicProperty(PythonInterpreterSelectionMode.CUSTOM)
@@ -0,0 +1,113 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.add.v2
import com.google.gson.JsonParser
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.execGetStdout
import com.intellij.python.community.helpersLocator.PythonHelpersLocator
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.TestApplication
import com.jetbrains.python.allure.Layers
import com.jetbrains.python.allure.Subsystems
import com.jetbrains.python.getOrThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermissions
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
@TestApplication
@Subsystems.Interpreters
@Layers.Functional
class ToolVersionProbeTest {
@Test
@Timeout(30)
@DisabledOnOs(OS.WINDOWS)
fun `environment detection probes visible and hidden direct children`(@TempDir workingDirectory: Path): Unit = timeoutRunBlocking {
createFakePython(workingDirectory.resolve("venv"), "Python 3.12.7", freeThreaded = false)
createFakePython(workingDirectory.resolve(".venv"), "Python 3.13.1", freeThreaded = true)
createFakePython(workingDirectory.resolve("space env"), "Python 3.11.9", freeThreaded = false)
createBrokenPython(workingDirectory.resolve("broken"))
workingDirectory.resolve("not-an-environment").createDirectories()
val helper = requireNotNull(PythonHelpersLocator.findPathInHelpersPossibleNull("tool_version_probe.sh"))
val output = ExecService().execGetStdout(
BinOnEel(Path.of("/bin/sh"), workingDirectory),
Args(helper.toString(), "--python", "", "--detect-environments", workingDirectory.toString()),
).getOrThrow()
val environmentRecords = requireNotNull(JsonParser.parseString(output).asJsonObject.getAsJsonArray("environments"))
val environments = environmentRecords.associateBy { it.asJsonObject.get("path").asString }
val expectedPaths = setOf(
workingDirectory.resolve("venv/bin/python").toString(),
workingDirectory.resolve(".venv/bin/python").toString(),
workingDirectory.resolve("space env/bin/python").toString(),
)
assertEquals(expectedPaths.size, environmentRecords.size())
assertEquals(expectedPaths, environments.keys)
assertEquals("Python 3.12.7", environments.getValue(workingDirectory.resolve("venv/bin/python").toString())
.asJsonObject.getAsJsonObject("python").get("versionOutput").asString)
assertEquals(true, environments.getValue(workingDirectory.resolve(".venv/bin/python").toString())
.asJsonObject.getAsJsonObject("python").get("freeThreaded").asBoolean)
}
@Test
@Timeout(30)
@DisabledOnOs(OS.WINDOWS)
fun `environment detection also probes a different process working directory`(@TempDir tempDirectory: Path): Unit = timeoutRunBlocking {
val requestedWorkingDirectory = tempDirectory.resolve("requested").createDirectories()
val processWorkingDirectory = tempDirectory.resolve("actual").createDirectories()
createFakePython(requestedWorkingDirectory.resolve("requested-venv"), "Python 3.12.7", freeThreaded = false)
createFakePython(processWorkingDirectory.resolve("actual-venv"), "Python 3.13.1", freeThreaded = true)
val helper = requireNotNull(PythonHelpersLocator.findPathInHelpersPossibleNull("tool_version_probe.sh"))
val output = ExecService().execGetStdout(
BinOnEel(Path.of("/bin/sh"), processWorkingDirectory),
Args(helper.toString(), "--python", "", "--detect-environments", requestedWorkingDirectory.toString()),
).getOrThrow()
val environmentRecords = requireNotNull(JsonParser.parseString(output).asJsonObject.getAsJsonArray("environments"))
val actualPaths = environmentRecords.mapTo(mutableSetOf()) { it.asJsonObject.get("path").asString }
val expectedPaths = setOf(
requestedWorkingDirectory.resolve("requested-venv/bin/python").toString(),
processWorkingDirectory.toRealPath().resolve("actual-venv/bin/python").toString(),
)
assertEquals(expectedPaths, actualPaths)
}
private fun createFakePython(environmentRoot: Path, version: String, freeThreaded: Boolean) {
val python = environmentRoot.resolve("bin/python")
python.parent.createDirectories()
python.writeText(
$$"""
|#!/bin/sh
|if [ "$1" = "-c" ]; then
| printf '%s\n' '$$freeThreaded'
|elif [ "$1" = "--version" ]; then
| printf '%s\n' '$$version'
|else
| exit 1
|fi
""".trimMargin()
)
Files.setPosixFilePermissions(python, PosixFilePermissions.fromString("rwx------"))
}
private fun createBrokenPython(environmentRoot: Path) {
val python = environmentRoot.resolve("bin/python")
python.parent.createDirectories()
python.writeText("#!/bin/sh\nexit 1\n")
Files.setPosixFilePermissions(python, PosixFilePermissions.fromString("rwx------"))
}
}