mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
e71389d8b8
commit
eab2b8b719
@@ -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("'")
|
||||
|
||||
+11
-1
@@ -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]
|
||||
*/
|
||||
|
||||
+7
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user