diff --git a/python/openapi/BUILD.bazel b/python/openapi/BUILD.bazel index f888bdeb6a90..afd349b1c573 100644 --- a/python/openapi/BUILD.bazel +++ b/python/openapi/BUILD.bazel @@ -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", diff --git a/python/openapi/intellij.python.community.iml b/python/openapi/intellij.python.community.iml index 0cc7e0a1c39c..e2dda5055f97 100644 --- a/python/openapi/intellij.python.community.iml +++ b/python/openapi/intellij.python.community.iml @@ -21,6 +21,7 @@ + diff --git a/python/openapi/resources/messages/PyCommunityBundle.properties b/python/openapi/resources/messages/PyCommunityBundle.properties index dabf7feb8fff..fb5f1c6a5d5e 100644 --- a/python/openapi/resources/messages/PyCommunityBundle.properties +++ b/python/openapi/resources/messages/PyCommunityBundle.properties @@ -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} diff --git a/python/openapi/src/com/jetbrains/python/packaging/PyVersionSpecifiers.kt b/python/openapi/src/com/jetbrains/python/packaging/PyVersionSpecifiers.kt new file mode 100644 index 000000000000..f4a173a1e7cc --- /dev/null +++ b/python/openapi/src/com/jetbrains/python/packaging/PyVersionSpecifiers.kt @@ -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> = + 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> { + 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> { + 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> { + 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> { + 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) : Comparable { + 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 { + 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 + } + } +} diff --git a/python/openapi/testResources/intellij.python.community._test.xml b/python/openapi/testResources/intellij.python.community._test.xml index 8d0e691d3c44..7197309ed2c2 100644 --- a/python/openapi/testResources/intellij.python.community._test.xml +++ b/python/openapi/testResources/intellij.python.community._test.xml @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/python/openapi/tests/com/intellij/python/junit5Tests/unit/PyVersionSpecifiersTest.kt b/python/openapi/tests/com/intellij/python/junit5Tests/unit/PyVersionSpecifiersTest.kt new file mode 100644 index 000000000000..2e06cef1cbab --- /dev/null +++ b/python/openapi/tests/com/intellij/python/junit5Tests/unit/PyVersionSpecifiersTest.kt @@ -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 & =X.Y.Z & =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)) + } +} diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/psi/util.kt b/python/python-pyproject/src/com/intellij/python/pyproject/psi/util.kt index 55318963d2b3..6b613e47f61d 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/psi/util.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/psi/util.kt @@ -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().firstOrNull { it.header.key?.text == headerKey } + +private fun TomlKeyValueOwner.findValue(key: String): String? = + entries.firstOrNull { it.key.text == key }?.value?.text?.removeSurrounding("\"")?.removeSurrounding("'") diff --git a/python/services/system-python/src/com/intellij/python/community/services/systemPython/api.kt b/python/services/system-python/src/com/intellij/python/community/services/systemPython/api.kt index 38ca140434d5..5fe3dafd9459 100644 --- a/python/services/system-python/src/com/intellij/python/community/services/systemPython/api.kt +++ b/python/services/system-python/src/com/intellij/python/community/services/systemPython/api.kt @@ -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 + suspend fun installLatestPython( + versionSpecifiers: PyVersionSpecifiers = PyVersionSpecifiers.ANY_SUPPORTED, + ): Result } +/** + * Finds the first [SystemPython] matching the given [specifiers]. + */ +@Internal +fun List.findMatchingPython(specifiers: PyVersionSpecifiers = PyVersionSpecifiers.ANY_SUPPORTED): SystemPython? = + firstOrNull { specifiers.isValid(it.pythonInfo.languageLevel) } + /** * See [createVenv] */ diff --git a/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt b/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt index c49a0c535c3c..2642e1d61a23 100644 --- a/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt +++ b/python/services/system-python/src/com/intellij/python/community/services/systemPython/systemPythonServiceImpl.kt @@ -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 { - val pythonToInstall = - withContext(Dispatchers.IO) { - PySdkToInstallManager.getAvailableVersionsToInstall().toSortedMap().values.last() - } + override suspend fun installLatestPython(versionSpecifiers: PyVersionSpecifiers): Result { + 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) { } diff --git a/python/src/com/jetbrains/python/poetry/PoetryFilesUtils.kt b/python/src/com/jetbrains/python/poetry/PoetryFilesUtils.kt index da7eec0e4913..36ae0faa7c82 100644 --- a/python/src/com/jetbrains/python/poetry/PoetryFilesUtils.kt +++ b/python/src/com/jetbrains/python/poetry/PoetryFilesUtils.kt @@ -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 = ConcurrentHashMap() + private val modulePythonVersions: ConcurrentMap = 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

validateInterpretersVersions(moduleFile: VirtualFile, interpreters: Flow>?>): Flow>?> { 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.compare(versionTriple: Pair>): 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>> - - init { - descriptions = parseVersion(stringVersion) - } - - private fun parseVersion(versionString: String): List>> { - if (versionString.isEmpty()) return emptyList() - val versionParts = versionString.split(",") - val result = mutableListOf>>() - - 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) { - companion object { - fun create(versionString: String): Result { - 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) - } - } - } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt b/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt index d380724b3269..25d40d9a902f 100644 --- a/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt +++ b/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt @@ -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)) diff --git a/python/src/com/jetbrains/python/projectCreation/venvWithSdkCreator.kt b/python/src/com/jetbrains/python/projectCreation/venvWithSdkCreator.kt index 5870045fd1ab..b83c1936a7b6 100644 --- a/python/src/com/jetbrains/python/projectCreation/venvWithSdkCreator.kt +++ b/python/src/com/jetbrains/python/projectCreation/venvWithSdkCreator.kt @@ -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 { - - // 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) diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt index 7dc8dbf8ca70..d22b5af20664 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt @@ -84,7 +84,7 @@ suspend fun runPoetryWithSdk(sdk: Sdk, vararg args: String): PyResult { @Internal suspend fun setupPoetry( projectPath: Path, - basePythonBinaryPath: PythonBinary?, + basePythonBinaryPath: PythonBinary, installPackages: Boolean, init: Boolean, ): PyResult { @@ -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 } diff --git a/python/src/com/jetbrains/python/sdk/poetry/poetry.kt b/python/src/com/jetbrains/python/sdk/poetry/poetry.kt index 22f4c135346a..0e5b18907130 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/poetry.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/poetry.kt @@ -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 { 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 { +private suspend fun setUpPoetry(moduleBasePath: Path, basePythonBinaryPath: PythonBinary, installPackages: Boolean): PyResult { val init = PyProjectToml.findInRoot(moduleBasePath) == null val pythonHomePath = setupPoetry(moduleBasePath, basePythonBinaryPath, installPackages, init).getOr { return it } val pythonBinaryPath = pythonHomePath.resolvePythonBinary()