mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
876a90d54f
commit
25329e2c4a
@@ -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------"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user