PY-87191 Always provide system Python when setting up Poetry environment

- Extract PyVersionSpecifiers from Poetry-specific PoetryPythonVersion
  into python-community-openapi for reuse across subsystems
- Add PyVersionSpecifiers parameter to PythonInstallerService.installLatestPython()
  so installation respects pyproject.toml version constraints
- Pass version specifiers from getSystemPython() through to the installer
- Move pyproject.toml python-version parsing to pyproject PSI utilities
- Replace PoetryPyProjectTomlPythonVersionsService internals with PyVersionSpecifiers


(cherry picked from commit 5ef8f7cb3430826ee38cd80ab460ea4a52da919a)

IJ-MR-193218

GitOrigin-RevId: a989e2dffd5b3081db526b703f9cc6085eac96d5
This commit is contained in:
Vitaly Legchilkin
2026-02-26 12:50:57 +00:00
committed by intellij-monorepo-bot
parent e71389d8b8
commit eab2b8b719
14 changed files with 530 additions and 148 deletions
+1
View File
@@ -58,6 +58,7 @@ jvm_library(
"//platform/core-api:core",
"//platform/projectModel-api:projectModel",
"@lib//:junit5",
"@lib//:junit5Params",
"//platform/eel",
"//platform/eel-provider",
"//platform/execution",
@@ -21,6 +21,7 @@
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.projectModel" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5Params" level="project" />
<orderEntry type="module" module-name="intellij.platform.eel" />
<orderEntry type="module" module-name="intellij.platform.eel.provider" />
<orderEntry type="module" module-name="intellij.platform.execution" />
@@ -2,3 +2,4 @@ python.execution.error={0}\nThe following command finished with error: {1}\nOutp
python.execution.cant.start.error={0}\nThe following command could not be started: {1}. Error {2} code: {3}
python.execution.timeout={0}\nThe following command stopped due to timeout: {1}.
tracecontext.non.interactive=Non Interactive
python.version.invalid=Invalid version: {0}
@@ -0,0 +1,206 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Originally PoetryPythonVersion, PoetryVersionValue, and VersionType from com.jetbrains.python.poetry.PoetryFilesUtils
package com.jetbrains.python.packaging
import com.jetbrains.python.PyCommunityBundle
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.psi.LanguageLevel
import org.jetbrains.annotations.ApiStatus.Internal
/**
* A parsed Python version specifier string (e.g., `>=3.8,<4.0`).
*
* Supports PEP 440 version specifiers (`==`, `!=`, `~=`, `<`, `<=`, `>=`, `>`),
* wildcard matching (`==3.8.*`, `!=3.8.*`), and Poetry-style operators (`^`, `~`).
* Use [isValid] to check whether a given Python version satisfies this specifier.
*
* Versions outside [LanguageLevel.SUPPORTED_LEVELS] are always rejected by [isValid],
* regardless of the constraint. Use [ANY_SUPPORTED] when no constraint is needed.
*
* Only numeric release segments are supported (e.g., `3.10.2`).
* PEP 440 pre-release (`3.8a1`), post-release (`.post1`), dev (`.dev1`),
* epoch (`1!`), and local (`+local`) suffixes are not handled.
*
* @param constraintSpec comma-separated version specifier string (e.g., `>=3.8,<4.0`)
*/
@Internal
data class PyVersionSpecifiers(val constraintSpec: String) {
private val conditions: List<Pair<VersionConstraintOperator, PythonVersionValue>> =
constraintSpec.split(",").flatMap { parseSingleSpecifier(it.trim()) }
fun isValid(versionString: String?): Boolean {
if (versionString.isNullOrBlank()) return false
val languageLevel = LanguageLevel.fromPythonVersionSafe(versionString) ?: return false
if (languageLevel !in LanguageLevel.SUPPORTED_LEVELS) return false
val version = PythonVersionValue.parse(versionString).successOrNull ?: return false
return conditions.all { (operator, constraint) ->
operator.isSatisfiedBy(version.compareTo(constraint, operator))
}
}
fun isValid(languageLevel: LanguageLevel): Boolean {
if (languageLevel !in LanguageLevel.SUPPORTED_LEVELS) return false
return isValid(languageLevel.toString())
}
companion object {
/** Matches any Python version from [LanguageLevel.SUPPORTED_LEVELS]. */
val ANY_SUPPORTED: PyVersionSpecifiers = PyVersionSpecifiers("")
fun parseSingleSpecifier(spec: String): List<Pair<VersionConstraintOperator, PythonVersionValue>> {
if (spec.isEmpty()) return emptyList()
val firstDigit = spec.indexOfFirst { it.isDigit() }
if (firstDigit == -1) return emptyList()
val operatorStr = spec.substring(0, firstDigit).trim()
val version = PythonVersionValue.parse(spec.substring(firstDigit).trim().removeSuffix(".*")).successOrNull ?: return emptyList()
return when (operatorStr) {
"~=" -> expandCompatibleRelease(version)
"~" -> expandTilde(version)
"^" -> expandCaret(version)
else -> {
val operator = VersionConstraintOperator.parse(operatorStr) ?: return emptyList()
listOf(operator to version)
}
}
}
/** PEP 440 compatible release: `~=3.8` → `>=3.8, <4.0`; `~=3.8.5` → `>=3.8.5, <3.9.0` */
fun expandCompatibleRelease(version: PythonVersionValue): List<Pair<VersionConstraintOperator, PythonVersionValue>> {
val upper = if (version.patch != null) {
PythonVersionValue(version.major, (version.minor ?: 0) + 1, 0)
}
else {
PythonVersionValue(version.major + 1, 0, null)
}
return listOf(
VersionConstraintOperator.MORE_OR_EQUAL to version,
VersionConstraintOperator.LESS to upper,
)
}
/** Poetry tilde: `~3.8` → `>=3.8, <3.9`; `~3.8.5` → `>=3.8.5, <3.9.0` */
fun expandTilde(version: PythonVersionValue): List<Pair<VersionConstraintOperator, PythonVersionValue>> {
val minor = version.minor
val upper = if (minor != null) {
PythonVersionValue(version.major, minor + 1, 0)
}
else {
PythonVersionValue(version.major + 1, 0, null)
}
return listOf(
VersionConstraintOperator.MORE_OR_EQUAL to version,
VersionConstraintOperator.LESS to upper,
)
}
/** Poetry caret: `^3.8` → `>=3.8, <4.0`; `^0.8` → `>=0.8, <0.9` */
fun expandCaret(version: PythonVersionValue): List<Pair<VersionConstraintOperator, PythonVersionValue>> {
val minor = version.minor
val patch = version.patch
val upper = when {
version.major != 0 -> PythonVersionValue(version.major + 1, 0, 0)
minor != null && minor != 0 -> PythonVersionValue(0, minor + 1, 0)
patch != null -> PythonVersionValue(0, 0, patch + 1)
minor != null -> PythonVersionValue(0, minor + 1, 0)
else -> PythonVersionValue(version.major + 1, 0, null)
}
return listOf(
VersionConstraintOperator.MORE_OR_EQUAL to version,
VersionConstraintOperator.LESS to upper,
)
}
}
}
/**
* A parsed Python version with [major], optional [minor], and optional [patch] components (e.g., `3.10.2`).
*/
@JvmInline
@Internal
value class PythonVersionValue private constructor(private val version: Triple<Int, Int?, Int?>) : Comparable<PythonVersionValue> {
val major: Int get() = version.first
val minor: Int? get() = version.second
val patch: Int? get() = version.third
internal constructor(major: Int, minor: Int?, patch: Int?) : this(Triple(major, minor, patch))
/**
* Compares this version to [other] in the context of the given [operator].
* Missing constraint components are filled based on operator semantics:
* - `<` and `>=`: missing defaults to 0 (e.g., `>=3.8` means `>=3.8.0`)
* - `<=` and `>`: missing defaults to a high value (treating as "any subversion")
* - `==` and `!=`: missing matches any value
*/
fun compareTo(other: PythonVersionValue, operator: VersionConstraintOperator): Int {
val default = when (operator) {
VersionConstraintOperator.LESS, VersionConstraintOperator.MORE_OR_EQUAL -> 0
VersionConstraintOperator.LESS_OR_EQUAL, VersionConstraintOperator.MORE -> 20
VersionConstraintOperator.EQUAL, VersionConstraintOperator.NOT_EQUAL -> null
}
return major.compareTo(other.major).takeIf { it != 0 }
?: minor?.compareTo(other.minor ?: (default ?: minor ?: 0))?.takeIf { it != 0 }
?: patch?.compareTo(other.patch ?: (default ?: patch ?: 0))?.takeIf { it != 0 }
?: 0
}
override fun compareTo(other: PythonVersionValue): Int =
major.compareTo(other.major).takeIf { it != 0 }
?: (minor ?: 0).compareTo(other.minor ?: 0).takeIf { it != 0 }
?: (patch ?: 0).compareTo(other.patch ?: 0).takeIf { it != 0 }
?: 0
companion object {
/**
* Parses a dotted version string (e.g., `3`, `3.10`, `3.10.2`).
*/
fun parse(versionString: String): PyResult<PythonVersionValue> {
val parts = try {
versionString.split(".").map { it.toInt() }
}
catch (_: NumberFormatException) {
return PyResult.localizedError(PyCommunityBundle.message("python.version.invalid", versionString))
}
return when (parts.size) {
1 -> Result.success(PythonVersionValue(parts[0], null, null))
2 -> Result.success(PythonVersionValue(parts[0], parts[1], null))
3 -> Result.success(PythonVersionValue(parts[0], parts[1], parts[2]))
else -> PyResult.localizedError(PyCommunityBundle.message("python.version.invalid", versionString))
}
}
}
}
/**
* Comparison operators used in Python version specifiers (e.g., `>=3.8,<4.0`).
*/
@Internal
enum class VersionConstraintOperator {
LESS,
LESS_OR_EQUAL,
EQUAL,
NOT_EQUAL,
MORE_OR_EQUAL,
MORE;
fun isSatisfiedBy(comparisonResult: Int): Boolean = when (this) {
LESS -> comparisonResult < 0
LESS_OR_EQUAL -> comparisonResult <= 0
EQUAL -> comparisonResult == 0
NOT_EQUAL -> comparisonResult != 0
MORE_OR_EQUAL -> comparisonResult >= 0
MORE -> comparisonResult > 0
}
companion object {
fun parse(symbol: String): VersionConstraintOperator? = when (symbol) {
"<" -> LESS
"<=" -> LESS_OR_EQUAL
"=", "==", "" -> EQUAL
"!=" -> NOT_EQUAL
">=" -> MORE_OR_EQUAL
">" -> MORE
else -> null
}
}
}
@@ -1,6 +1,7 @@
<idea-plugin>
<dependencies>
<module name="intellij.libraries.junit5"/>
<module name="intellij.libraries.junit5.params"/>
<module name="intellij.python.community"/>
</dependencies>
</idea-plugin>
@@ -0,0 +1,231 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.unit
import com.jetbrains.python.packaging.PyVersionSpecifiers
import com.jetbrains.python.psi.LanguageLevel
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class PyVersionSpecifiersTest {
@ParameterizedTest(name = "\"{0}\".isValid(\"{1}\") == {2}")
@CsvSource(
// Unsupported versions (not in LanguageLevel.SUPPORTED_LEVELS) are rejected
"==3.5, 3.5.0, false",
">=3.5, 3.5.5, false",
">3.0, 3.5.0, false",
// Supported 2.7 still obeys constraints
"<3.8, 2.7.0, true",
"<=3.8, 2.7.5, true",
">=3.8, 2.7.0, false",
// Less: version < constraint
"<3.8, 3.6.0, true",
"<3.8, 3.7.5, true",
"<3.8, 3.8.0, false",
"<3.8, 3.9.0, false",
"<3.8.5, 3.7.0, true",
"<3.8.5, 3.8.0, true",
"<3.8.5, 3.8.5, false",
"<3.8.5, 3.9.0, false",
// Less or equal: version <= constraint
"<=3.8, 3.7.0, true",
"<=3.8, 3.8.0, true",
"<=3.8, 3.8.5, true",
"<=3.8, 3.9.0, false",
"<=3.8.5, 3.8.5, true",
"<=3.8.5, 3.9.0, false",
// PEP 440 exact match (==): missing components match any subversion
"==3.8, 3.7.0, false",
"==3.8, 3.8.0, true",
"==3.8, 3.8.5, true",
"==3.8, 3.9.0, false",
"==3.8.5, 3.8.0, false",
"==3.8.5, 3.8.5, true",
"==3.8.5, 3.9.0, false",
// PEP 440 wildcard (==X.Y.*): same as ==X.Y with missing components
"==3.8.*, 3.7.0, false",
"==3.8.*, 3.8.0, true",
"==3.8.*, 3.8.5, true",
"==3.8.*, 3.9.0, false",
"!=3.8.*, 3.8.0, false",
"!=3.8.*, 3.9.0, true",
// Single-equals shorthand (=): same as ==
"=3.8, 3.8.0, true",
"=3.8, 3.8.5, true",
"=3.8, 3.9.0, false",
// PEP 440 exclusion (!=): inverse of ==
"!=3.8, 3.7.0, true",
"!=3.8, 3.8.0, false",
"!=3.8, 3.8.5, false",
"!=3.8, 3.9.0, true",
"!=3.8.5, 3.8.0, true",
"!=3.8.5, 3.8.5, false",
"!=3.8.5, 3.9.0, true",
// Greater or equal: version >= constraint
">=3.8, 3.7.0, false",
">=3.8, 3.8.0, true",
">=3.8, 3.8.5, true",
">=3.8, 3.9.0, true",
">=3.8.5, 3.8.0, false",
">=3.8.5, 3.8.5, true",
">=3.8.5, 3.10.0, true",
// Greater: version > constraint
">3.8, 3.7.0, false",
">3.8, 3.8.0, false",
">3.8, 3.8.5, false",
">3.8, 3.9.0, true",
">3.8.5, 3.8.5, false",
">3.8.5, 3.9.0, true",
">3.8.5, 3.11.0, true",
)
fun testSimpleOperators(constraint: String, version: String, expected: Boolean) {
assertEquals(expected, PyVersionSpecifiers(constraint).isValid(version))
}
@ParameterizedTest(name = "\"{0}\".isValid(\"{1}\") == {2}")
@CsvSource(
// PEP 440 compatible release (~=): ~=X.Y → >=X.Y & <(X+1).0; ~=X.Y.Z → >=X.Y.Z & <X.(Y+1).0
"~=3.8, 3.7.0, false",
"~=3.8, 3.8.0, true",
"~=3.8, 3.9.0, true",
"~=3.8, 3.99.0, true",
"~=3.8, 4.0.0, false",
"~=3.8.5, 3.8.4, false",
"~=3.8.5, 3.8.5, true",
"~=3.8.5, 3.8.9, true",
"~=3.8.5, 3.9.0, false",
// Poetry tilde (~): ~X.Y → >=X.Y & <X.(Y+1); ~X.Y.Z → >=X.Y.Z & <X.(Y+1).0
"~3.8, 3.7.0, false",
"~3.8, 3.8.0, true",
"~3.8, 3.8.9, true",
"~3.8, 3.9.0, false",
"~3.8.5, 3.8.4, false",
"~3.8.5, 3.8.5, true",
"~3.8.5, 3.8.9, true",
"~3.8.5, 3.9.0, false",
// Poetry caret (^): ^X.Y → >=X.Y & <(X+1).0 for X>0
"^3.8, 3.7.0, false",
"^3.8, 3.8.0, true",
"^3.8, 3.8.5, true",
"^3.8, 3.9.0, true",
"^3.8, 3.99.0, true",
"^3.8, 4.0.0, false",
"^3.8.5, 3.8.0, false",
"^3.8.5, 3.8.5, true",
"^3.8.5, 3.9.0, true",
"^3.8.5, 4.0.0, false",
)
fun testCompoundOperators(constraint: String, version: String, expected: Boolean) {
assertEquals(expected, PyVersionSpecifiers(constraint).isValid(version))
}
@ParameterizedTest(name = "\"{0}\".isValid(\"{1}\") == {2}")
@CsvSource(
// Strict range: >3.8,<3.10
"'>3.8,<3.10', 3.7.0, false",
"'>3.8,<3.10', 3.8.0, false",
"'>3.8,<3.10', 3.9.0, true",
"'>3.8,<3.10', 3.9.5, true",
"'>3.8,<3.10', 3.10.0, false",
"'>3.8,<3.10', 3.11.0, false",
// Inclusive range: >=3.8,<=3.10
"'>=3.8,<=3.10', 3.7.0, false",
"'>=3.8,<=3.10', 3.8.0, true",
"'>=3.8,<=3.10', 3.8.5, true",
"'>=3.8,<=3.10', 3.9.0, true",
"'>=3.8,<=3.10', 3.10.0, true",
"'>=3.8,<=3.10', 3.10.5, true",
"'>=3.8,<=3.10', 3.11.0, false",
// Exclusion in range: >=3.8,!=3.9
"'>=3.8,!=3.9', 3.8.0, true",
"'>=3.8,!=3.9', 3.9.0, false",
"'>=3.8,!=3.9', 3.9.5, false",
"'>=3.8,!=3.9', 3.10.0, true",
// Three constraints: >=3.8,!=3.9,<3.12
"'>=3.8,!=3.9,<3.12', 3.7.0, false",
"'>=3.8,!=3.9,<3.12', 3.8.0, true",
"'>=3.8,!=3.9,<3.12', 3.9.0, false",
"'>=3.8,!=3.9,<3.12', 3.10.0, true",
"'>=3.8,!=3.9,<3.12', 3.12.0, false",
// Spaces between operator and version
">= 3.8, 3.8.0, true",
"> 3.8, 3.9.0, true",
"<= 3.10, 3.10.0, true",
// Spaces around commas in composite
"'>= 3.8 , < 3.10', 3.9.0, true",
"'>= 3.8 , < 3.10', 3.10.0, false",
)
fun testCompositeSpecifiers(constraint: String, version: String, expected: Boolean) {
assertEquals(expected, PyVersionSpecifiers(constraint).isValid(version))
}
@ParameterizedTest(name = "ANY_SUPPORTED.isValid(\"{0}\") == {1}")
@CsvSource(
"2.7.0, true",
"3.6.0, true",
"3.8.0, true",
"3.12.0, true",
"3.15.0, true",
"2.6.0, false",
"3.0.0, false",
"3.5.0, false",
)
fun testAnySupportedVersion(version: String, expected: Boolean) {
assertEquals(expected, PyVersionSpecifiers.ANY_SUPPORTED.isValid(version))
}
@Test
fun testSupportedLanguageLevels() {
val specifiers = PyVersionSpecifiers(">=3.8")
for (level in LanguageLevel.SUPPORTED_LEVELS) {
assertEquals(level.isAtLeast(LanguageLevel.PYTHON38), specifiers.isValid(level),
">=3.8 should ${if (level.isAtLeast(LanguageLevel.PYTHON38)) "accept" else "reject"} $level")
}
}
@Test
fun testUnsupportedLanguageLevels() {
val unsupported = LanguageLevel.entries.filter { it !in LanguageLevel.SUPPORTED_LEVELS }
for (level in unsupported) {
assertFalse(PyVersionSpecifiers.ANY_SUPPORTED.isValid(level),
"ANY_SUPPORTED should reject unsupported $level")
}
}
@Test
fun testAnySupportedAcceptsAllSupportedLevels() {
for (level in LanguageLevel.SUPPORTED_LEVELS) {
assertTrue(PyVersionSpecifiers.ANY_SUPPORTED.isValid(level),
"ANY_SUPPORTED should accept supported $level")
}
}
@ParameterizedTest(name = "\"{0}\".isValid(\"{1}\") == false")
@CsvSource(
">=3.8, ''",
">=3.8, ' '",
">=3.8, abc",
)
fun testInvalidVersionString(constraint: String, version: String) {
assertEquals(false, PyVersionSpecifiers(constraint).isValid(version))
}
}
@@ -2,8 +2,31 @@ package com.intellij.python.pyproject.psi
import com.intellij.psi.PsiFile
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.jetbrains.python.packaging.PyVersionSpecifiers
import org.jetbrains.annotations.ApiStatus
import org.toml.lang.psi.TomlKeyValueOwner
import org.toml.lang.psi.TomlTable
@ApiStatus.Internal
fun PsiFile.isPyProjectToml(): Boolean = this.name == PY_PROJECT_TOML
/**
* Extracts a [PyVersionSpecifiers] from a `pyproject.toml` PSI file.
*
* Checks PEP 621 `project -> requires-python` first, then Poetry `tool.poetry.dependencies -> python`.
* Returns [PyVersionSpecifiers.ANY_SUPPORTED] if neither is found.
*/
@ApiStatus.Internal
fun PsiFile.resolvePythonVersionSpecifiers(): PyVersionSpecifiers {
val spec = findTable("project")?.findValue("requires-python")
?: findTable("tool.poetry.dependencies")?.findValue("python")
?: return PyVersionSpecifiers.ANY_SUPPORTED
return PyVersionSpecifiers(spec)
}
private fun PsiFile.findTable(headerKey: String): TomlKeyValueOwner? =
children.filterIsInstance<TomlTable>().firstOrNull { it.header.key?.text == headerKey }
private fun TomlKeyValueOwner.findValue(key: String): String? =
entries.firstOrNull { it.key.text == key }?.value?.text?.removeSurrounding("\"")?.removeSurrounding("'")
@@ -19,6 +19,7 @@ import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.mapError
import com.jetbrains.python.packaging.PyVersionSpecifiers
import com.jetbrains.python.venvReader.Directory
import com.jetbrains.python.venvReader.VirtualEnvReader
import org.jetbrains.annotations.ApiStatus
@@ -142,9 +143,18 @@ interface PythonInstallerService {
* Returns Unit for now (so you should call [SystemPythonService.findSystemPythons]), but this is a subject to change.
*/
@ApiStatus.Experimental
suspend fun installLatestPython(): Result<Unit, String>
suspend fun installLatestPython(
versionSpecifiers: PyVersionSpecifiers = PyVersionSpecifiers.ANY_SUPPORTED,
): Result<Unit, String>
}
/**
* Finds the first [SystemPython] matching the given [specifiers].
*/
@Internal
fun List<SystemPython>.findMatchingPython(specifiers: PyVersionSpecifiers = PyVersionSpecifiers.ANY_SUPPORTED): SystemPython? =
firstOrNull { specifiers.isValid(it.pythonInfo.languageLevel) }
/**
* See [createVenv]
*/
@@ -1,7 +1,6 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.community.services.systemPython
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.BaseState
import com.intellij.openapi.components.RoamingType
@@ -31,8 +30,8 @@ import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.getOr
import com.jetbrains.python.getOrNull
import com.jetbrains.python.packaging.PyVersionSpecifiers
import com.jetbrains.python.sdk.installer.installBinary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -191,11 +190,12 @@ class SystemPythonServiceImpl internal constructor(
private object LocalPythonInstaller : PythonInstallerService {
override suspend fun installLatestPython(): Result<Unit, String> {
val pythonToInstall =
withContext(Dispatchers.IO) {
PySdkToInstallManager.getAvailableVersionsToInstall().toSortedMap().values.last()
}
override suspend fun installLatestPython(versionSpecifiers: PyVersionSpecifiers): Result<Unit, String> {
val pythonToInstall = withContext(Dispatchers.IO) {
PySdkToInstallManager.getAvailableVersionsToInstall()
.filterKeys { versionSpecifiers.isValid(it) }
.maxByOrNull { it.key }?.value
} ?: return Result.Companion.failure("No matching Python version available for installation")
withContext(Dispatchers.EDT) {
installBinary(pythonToInstall, null) {
}
@@ -12,8 +12,7 @@ import com.intellij.openapi.vfs.findPsiFile
import com.intellij.psi.PsiElement
import com.intellij.psi.util.PsiElementFilter
import com.intellij.psi.util.PsiTreeUtil
import com.jetbrains.python.poetry.VersionType.Companion.getVersionType
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.packaging.PyVersionSpecifiers
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.PythonSelectableInterpreter
import kotlinx.coroutines.Dispatchers
@@ -78,132 +77,25 @@ suspend fun poetryFindPythonVersionFromToml(tomlFile: VirtualFile, project: Proj
@Internal
@Service(Service.Level.PROJECT)
class PoetryPyProjectTomlPythonVersionsService : Disposable {
private val modulePythonVersions: ConcurrentMap<VirtualFile, PoetryPythonVersion> = ConcurrentHashMap()
private val modulePythonVersions: ConcurrentMap<VirtualFile, PyVersionSpecifiers> = ConcurrentHashMap()
companion object {
fun getInstance(project: Project): PoetryPyProjectTomlPythonVersionsService = project.service()
}
fun setVersion(moduleFile: VirtualFile, stringVersion: String) {
modulePythonVersions[moduleFile] = PoetryPythonVersion(stringVersion)
modulePythonVersions[moduleFile] = PyVersionSpecifiers(stringVersion)
}
fun getVersionString(moduleFile: VirtualFile): String = getVersion(moduleFile).stringVersion
fun getVersionString(moduleFile: VirtualFile): String = getVersion(moduleFile).constraintSpec
fun <P : PathHolder> validateInterpretersVersions(moduleFile: VirtualFile, interpreters: Flow<List<PythonSelectableInterpreter<P>>?>): Flow<List<PythonSelectableInterpreter<P>>?> {
val version = getVersion(moduleFile)
return interpreters.map { list -> list?.filter { version.isValid(it.pythonInfo.languageLevel) } }
}
private fun getVersion(moduleFile: VirtualFile): PoetryPythonVersion =
modulePythonVersions[moduleFile] ?: PoetryPythonVersion("")
private fun getVersion(moduleFile: VirtualFile): PyVersionSpecifiers =
modulePythonVersions[moduleFile] ?: PyVersionSpecifiers.ANY_SUPPORTED
override fun dispose() {}
}
@Internal
enum class VersionType {
LESS,
LESS_OR_EQUAL,
EQUAL,
MORE_OR_EQUAL,
MORE;
companion object {
fun String.getVersionType(): VersionType? =
when (this) {
"<" -> LESS
"<=" -> LESS_OR_EQUAL
"=", "" -> EQUAL
"^", ">=" -> MORE_OR_EQUAL
">" -> MORE
else -> null
}
}
}
private fun getDefaultValueByType(type: VersionType): Int? =
when (type) {
VersionType.LESS, VersionType.MORE_OR_EQUAL -> 0
VersionType.LESS_OR_EQUAL, VersionType.MORE -> 20
VersionType.EQUAL -> null
}
private fun Triple<Int, Int?, Int?>.compare(versionTriple: Pair<VersionType, Triple<Int, Int?, Int?>>): Int {
val type = versionTriple.first
val version = versionTriple.second
return this.first.compareTo(version.first).takeIf { it != 0 }
?: this.second?.compareTo(version.second ?: (getDefaultValueByType(type) ?: this.second ?: 0)).takeIf { it != 0 }
?: this.third?.compareTo(version.third ?: (getDefaultValueByType(type) ?: this.third ?: 0)).takeIf { it != 0 }
?: 0
}
@Internal
data class PoetryPythonVersion(val stringVersion: String) {
val descriptions: List<Pair<VersionType, Triple<Int, Int?, Int?>>>
init {
descriptions = parseVersion(stringVersion)
}
private fun parseVersion(versionString: String): List<Pair<VersionType, Triple<Int, Int?, Int?>>> {
if (versionString.isEmpty()) return emptyList()
val versionParts = versionString.split(",")
val result = mutableListOf<Pair<VersionType, Triple<Int, Int?, Int?>>>()
for (part in versionParts) {
val firstDigit = part.indexOfFirst { it.isDigit() }
if (firstDigit == -1) continue
val type = part.substring(0, firstDigit).trim().getVersionType() ?: continue
val version = part.substring(firstDigit).trim()
val versionTriple = PoetryVersionValue.create(version).getOrNull()?.version
versionTriple?.let { result.add(Pair(type, versionTriple)) }
}
return result
}
fun isValid(versionString: String?): Boolean {
if (versionString.isNullOrBlank()) return false
val baseInterpreterVersion = PoetryVersionValue.create(versionString).getOrNull()?.version ?: return false
if (baseInterpreterVersion.first < 3 || baseInterpreterVersion.first == 3 && baseInterpreterVersion.second?.let { it < 6 } == true) return false
for (description in descriptions) {
val type = description.first
val compareResult = baseInterpreterVersion.compare(description)
when (type) {
VersionType.LESS -> if (compareResult >= 0) return false
VersionType.LESS_OR_EQUAL -> if (compareResult > 0) return false
VersionType.EQUAL -> if (compareResult != 0) return false
VersionType.MORE_OR_EQUAL -> if (compareResult < 0) return false
VersionType.MORE -> if (compareResult <= 0) return false
}
}
return true
}
fun isValid(languageLevel: LanguageLevel): Boolean {
val languageLevelString = languageLevel.toString()
return isValid(languageLevelString)
}
}
@JvmInline
value class PoetryVersionValue private constructor(val version: Triple<Int, Int?, Int?>) {
companion object {
fun create(versionString: String): Result<PoetryVersionValue> {
try {
val integers = versionString.split(".").map { it.toInt() }
return when (integers.size) {
1 -> Result.success(PoetryVersionValue(Triple(integers[0], null, null)))
2 -> Result.success(PoetryVersionValue(Triple(integers[0], integers[1], null)))
3 -> Result.success(PoetryVersionValue(Triple(integers[0], integers[1], integers[2])))
else -> Result.failure(NumberFormatException())
}
}
catch (e: NumberFormatException) {
return Result.failure(e)
}
}
}
}
@@ -14,12 +14,18 @@ import com.intellij.platform.util.progress.reportRawProgress
import com.intellij.python.common.tools.ToolId
import com.intellij.python.community.impl.poetry.common.POETRY_TOOL_ID
import com.intellij.python.community.impl.poetry.common.poetryPath
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.openapi.application.readAction
import com.intellij.openapi.vfs.findPsiFile
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.psi.resolvePythonVersionSpecifiers
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.PyVersionSpecifiers
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.poetry.findPoetryLock
import com.jetbrains.python.poetry.getPyProjectTomlForPoetry
import com.jetbrains.python.projectCreation.getSystemPython
import com.jetbrains.python.sdk.PythonSdkType
import com.jetbrains.python.sdk.baseDir
import com.jetbrains.python.sdk.configuration.CheckToml
@@ -120,7 +126,23 @@ internal class PyPoetrySdkConfiguration : PyProjectTomlConfigurationExtension {
)
}
val tomlFile = PyProjectToml.findFile(module)
val poetry = setupPoetry(basePath, null, true, tomlFile == null).getOr { return@withBackgroundProgress it }
val versionSpecifiers = tomlFile?.let { vf ->
readAction { vf.findPsiFile(module.project) }?.resolvePythonVersionSpecifiers()
} ?: PyVersionSpecifiers.ANY_SUPPORTED
val baseSystemPython = getSystemPython(
confirmInstallation = { true },
pythonService = SystemPythonService(),
versionSpecifiers = versionSpecifiers,
).getOr { return@withBackgroundProgress it }
val poetry = setupPoetry(
projectPath = basePath,
basePythonBinaryPath = baseSystemPython.pythonBinary,
installPackages = true,
init = tomlFile == null
).getOr { return@withBackgroundProgress it }
val path = poetry.resolvePythonBinary()
?: return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", poetry))
@@ -27,6 +27,8 @@ import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.getOr
import com.intellij.python.community.services.systemPython.findMatchingPython
import com.jetbrains.python.packaging.PyVersionSpecifiers
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.baseDir
import com.jetbrains.python.sdk.configurePythonSdk
@@ -133,14 +135,13 @@ private suspend fun findExistingVenv(
}
}
private suspend fun getSystemPython(
internal suspend fun getSystemPython(
confirmInstallation: suspend () -> Boolean,
pythonService: SystemPythonService,
versionSpecifiers: PyVersionSpecifiers = PyVersionSpecifiers.ANY_SUPPORTED,
): Result<SystemPython, MessageError> {
// First, find the latest python according to strategy
var systemPythonBinary = pythonService.findSystemPythons(forceRefresh = true).firstOrNull()
var systemPythonBinary = pythonService.findSystemPythons(forceRefresh = true).findMatchingPython(versionSpecifiers)
// No python found?
if (systemPythonBinary == null) {
@@ -149,7 +150,7 @@ private suspend fun getSystemPython(
?: return PyResult.localizedError(PyBundle.message("project.error.install.not.supported"))
if (confirmInstallation()) {
// Install
when (val r = installer.installLatestPython()) {
when (val r = installer.installLatestPython(versionSpecifiers)) {
is Result.Failure -> {
val error = r.error
logger.warn("Python installation failed $error")
@@ -157,14 +158,14 @@ private suspend fun getSystemPython(
}
is Result.Success -> {
// Find the latest python again, after installation
systemPythonBinary = pythonService.findSystemPythons(forceRefresh = true).firstOrNull()
systemPythonBinary = pythonService.findSystemPythons(forceRefresh = true).findMatchingPython(versionSpecifiers)
}
}
}
}
return if (systemPythonBinary == null) {
return PyResult.localizedError(PyBundle.message("project.error.all.pythons.bad"))
PyResult.localizedError(PyBundle.message("project.error.all.pythons.bad"))
}
else {
Result.Success(systemPythonBinary)
@@ -84,7 +84,7 @@ suspend fun runPoetryWithSdk(sdk: Sdk, vararg args: String): PyResult<String> {
@Internal
suspend fun setupPoetry(
projectPath: Path,
basePythonBinaryPath: PythonBinary?,
basePythonBinaryPath: PythonBinary,
installPackages: Boolean,
init: Boolean,
): PyResult<PythonHomePath> {
@@ -92,25 +92,18 @@ suspend fun setupPoetry(
// Build poetry init command with Python version constraint if available
val initArgs = mutableListOf("init", "-n")
if (basePythonBinaryPath != null) {
// Validate Python and get version info
val pythonInfo = basePythonBinaryPath.validatePythonAndGetInfo().getOr { return it }
val major = pythonInfo.languageLevel.majorVersion
val minor = pythonInfo.languageLevel.minorVersion
// Add --python flag with caret constraint (e.g., "^3.10")
initArgs.add("--python")
initArgs.add("^$major.$minor")
}
// Validate Python and get version info
val pythonInfo = basePythonBinaryPath.validatePythonAndGetInfo().getOr { return it }
val major = pythonInfo.languageLevel.majorVersion
val minor = pythonInfo.languageLevel.minorVersion
// Add --python flag with caret constraint (e.g., "^3.10")
initArgs.add("--python")
initArgs.add("^$major.$minor")
runPoetry(projectPath, *initArgs.toTypedArray()).getOr { return it }
}
if (basePythonBinaryPath != null) {
runPoetry(projectPath, "env", "use", basePythonBinaryPath.pathString).getOr { return it }
}
else {
runPoetry(projectPath, "run", "python", "-V").getOr { return it }
}
runPoetry(projectPath, "env", "use", basePythonBinaryPath.pathString).getOr { return it }
if (installPackages) {
runPoetry(projectPath, "install", "--no-root").getOr { return it }
@@ -27,7 +27,7 @@ fun suggestedSdkName(basePath: Path): @NlsSafe String = "Poetry (${PathUtil.getF
@Internal
suspend fun createNewPoetrySdk(
moduleBasePath: Path,
basePythonBinaryPath: PythonBinary?,
basePythonBinaryPath: PythonBinary,
installPackages: Boolean,
): PyResult<Sdk> {
val pythonBinaryPath = setUpPoetry(moduleBasePath, basePythonBinaryPath, installPackages).getOr { return it }
@@ -58,7 +58,7 @@ internal val Sdk.isPoetry: Boolean
return getOrCreateAdditionalData() is PyPoetrySdkAdditionalData
}
private suspend fun setUpPoetry(moduleBasePath: Path, basePythonBinaryPath: PythonBinary?, installPackages: Boolean): PyResult<PythonBinary> {
private suspend fun setUpPoetry(moduleBasePath: Path, basePythonBinaryPath: PythonBinary, installPackages: Boolean): PyResult<PythonBinary> {
val init = PyProjectToml.findInRoot(moduleBasePath) == null
val pythonHomePath = setupPoetry(moduleBasePath, basePythonBinaryPath, installPackages, init).getOr { return it }
val pythonBinaryPath = pythonHomePath.resolvePythonBinary()