[pycharm] Address feedback

GitOrigin-RevId: ff5e1efdefa9ce24f76a0d628937a586a1363b78
This commit is contained in:
David Lysenko
2025-02-21 11:09:45 +01:00
committed by intellij-monorepo-bot
parent 8d5a145e6d
commit 18eb19aff3
13 changed files with 1026 additions and 56 deletions

31
.idea/libraries/tuweni_toml.xml generated Normal file
View File

@@ -0,0 +1,31 @@
<component name="libraryTable">
<library name="tuweni-toml" type="repository">
<properties maven-id="org.apache.tuweni:tuweni-toml:2.0.0">
<verification>
<artifact url="file://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0.jar">
<sha256sum>b7331e02e955b6b962a8fa89eb8d7db0960d0b232880bfc60e8602fb3fed36ad</sha256sum>
</artifact>
<artifact url="file://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1.jar">
<sha256sum>43516d19beae35909e04d06af6c0c58c17bc94e0070c85e8dc9929ca640dc91d</sha256sum>
</artifact>
</verification>
<exclude>
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib-common" />
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib-jdk7" />
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib-jdk8" />
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib" />
<dependency maven-id="com.google.code.findbugs:jsr305" />
<dependency maven-id="org.jetbrains:annotations" />
</exclude>
</properties>
<CLASSES>
<root url="jar://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES>
<root url="jar://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1-sources.jar!/" />
</SOURCES>
</library>
</component>

1
.idea/modules.xml generated
View File

@@ -1003,6 +1003,7 @@
<module fileurl="file://$PROJECT_DIR$/python/python-psi-api/intellij.python.psi.iml" filepath="$PROJECT_DIR$/python/python-psi-api/intellij.python.psi.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-psi-impl/intellij.python.psi.impl.iml" filepath="$PROJECT_DIR$/python/python-psi-impl/intellij.python.psi.impl.iml" />
<module fileurl="file://$PROJECT_DIR$/python/intellij.python.pydev.iml" filepath="$PROJECT_DIR$/python/intellij.python.pydev.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-pyproject/intellij.python.pyproject.iml" filepath="$PROJECT_DIR$/python/python-pyproject/intellij.python.pyproject.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-sdk/intellij.python.sdk.iml" filepath="$PROJECT_DIR$/python/python-sdk/intellij.python.sdk.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-syntax/intellij.python.syntax.iml" filepath="$PROJECT_DIR$/python/python-syntax/intellij.python.syntax.iml" />
<module fileurl="file://$PROJECT_DIR$/python/python-syntax-core/intellij.python.syntax.core.iml" filepath="$PROJECT_DIR$/python/python-syntax-core/intellij.python.syntax.core.iml" />

View File

@@ -246,5 +246,6 @@
<orderEntry type="module" module-name="intellij.compose.ide.plugin.shared" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.python.community.execService" scope="TEST" />
<orderEntry type="module" module-name="intellij.vcs.git.commit.modal" scope="RUNTIME" />
<orderEntry type="module" module-name="intellij.python.pyproject" scope="TEST" />
</component>
</module>

View File

@@ -38,6 +38,7 @@ object PythonCommunityPluginModules {
"intellij.python.sdk",
"intellij.python.terminal",
"intellij.python.ml.features",
"intellij.python.pyproject",
)
/**

View File

@@ -82,37 +82,7 @@
<orderEntry type="library" name="jna" level="project" />
<orderEntry type="module" module-name="intellij.toml" />
<orderEntry type="module" module-name="intellij.toml.core" />
<orderEntry type="module-library">
<library name="tuweni-toml" type="repository">
<properties maven-id="org.apache.tuweni:tuweni-toml:2.0.0">
<verification>
<artifact url="file://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0.jar">
<sha256sum>b7331e02e955b6b962a8fa89eb8d7db0960d0b232880bfc60e8602fb3fed36ad</sha256sum>
</artifact>
<artifact url="file://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1.jar">
<sha256sum>43516d19beae35909e04d06af6c0c58c17bc94e0070c85e8dc9929ca640dc91d</sha256sum>
</artifact>
</verification>
<exclude>
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib-common" />
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib-jdk7" />
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib-jdk8" />
<dependency maven-id="org.jetbrains.kotlin:kotlin-stdlib" />
<dependency maven-id="com.google.code.findbugs:jsr305" />
<dependency maven-id="org.jetbrains:annotations" />
</exclude>
</properties>
<CLASSES>
<root url="jar://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES>
<root url="jar://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1-sources.jar!/" />
</SOURCES>
</library>
</orderEntry>
<orderEntry type="library" name="tuweni-toml" level="project" />
<orderEntry type="library" name="jsr305" level="project" />
<orderEntry type="library" name="jetbrains-annotations" level="project" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />

View File

@@ -20,6 +20,11 @@
files:
- name: $MAVEN_REPOSITORY$/org/apache/thrift/libthrift/0/libthrift-0.jar
reason: withProjectLibrary
- name: tuweni-toml
files:
- name: $MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2/tuweni-toml-2.jar
- name: $MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4/antlr4-runtime-4.jar
reason: <- intellij.python.community.impl
modules:
- name: intellij.python.community
- name: intellij.python.community.core.impl
@@ -27,9 +32,6 @@
libraries:
ml-completion-prev-exprs-models:
- name: $MAVEN_REPOSITORY$/completion/ml/python/features/ml-completion-prev-exprs-models/1/ml-completion-prev-exprs-models-1.jar
tuweni-toml:
- name: $MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2/tuweni-toml-2.jar
- name: $MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4/antlr4-runtime-4.jar
completion-ranking-python-with-full-line:
- name: $MAVEN_REPOSITORY$/org/jetbrains/intellij/deps/completion/completion-ranking-python-with-full-line/0/completion-ranking-python-with-full-line-0.jar
- name: intellij.python.community.impl.poetry
@@ -43,6 +45,7 @@
- name: intellij.python.psi.impl
- name: intellij.python.pydev
- name: intellij.python.sdk
- name: intellij.python.pyproject
- name: intellij.python.community.plugin
contentModules:
- name: intellij.python.community.plugin.minor

View File

@@ -3,33 +3,20 @@
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />
<orderEntry type="module" module-name="intellij.python.community" />
<orderEntry type="module-library">
<library>
<CLASSES>
<root url="jar://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES>
<root url="jar://$MAVEN_REPOSITORY$/org/apache/tuweni/tuweni-toml/2.0.0/tuweni-toml-2.0.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/antlr/antlr4-runtime/4.7.1/antlr4-runtime-4.7.1-sources.jar!/" />
</SOURCES>
</library>
</orderEntry>
<orderEntry type="module" module-name="intellij.python.psi.impl" />
<orderEntry type="library" name="kotlinc.kotlin-compiler-tests" level="project" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.python.sdk" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.toml.core" />
<orderEntry type="library" name="tuweni-toml" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5Params" level="project" />
</component>
</module>

View File

@@ -1,3 +1,4 @@
// 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.pyproject
import com.intellij.psi.PsiElement
@@ -7,16 +8,45 @@ import com.jetbrains.python.Result.Companion.success
import com.jetbrains.python.mapResult
import org.apache.tuweni.toml.TomlArray
import org.apache.tuweni.toml.TomlTable
import org.jetbrains.annotations.ApiStatus.Internal
import org.toml.lang.psi.TomlKeyValue as PsiTomlKeyValue
import org.toml.lang.psi.TomlTable as PsiTomlTable
import org.toml.lang.psi.TomlLiteral as PsiTomlLiteral
import kotlin.reflect.KClass
/**
* The error union used by [TomlTable.safeGet], [TomlTable.safeGetRequired] and [TomlTable.safeGetArr].
*/
@Internal
sealed class TomlTableSafeGetError {
/**
* Signifies an incorrect type at [path]. Expected value is signified by [expected], actual value is signified by [actual].
*/
data class UnexpectedType(val path: String, val expected: KClass<*>, val actual: KClass<*>) : TomlTableSafeGetError()
/**
* Signifies a missing value at [path].
*/
data class RequiredValueMissing(val path: String) : TomlTableSafeGetError()
}
/**
* Attempts to extract a value of type [T] from an instance of [TomlTable] by key.
* On an invalid type, returns an instance of [Result.Failure] with an error of [TomlTableSafeGetError.UnexpectedType].
* On a missing value, returns an instance of [Result.Success] with a value of null.
* On a present value with the valid type, returns an instance of [Result.Success] with that value.
*
* Example:
*
* ```kotlin
* val foo = tomlTable.safeGet<String>("foo")
* when (foo) {
* is Result.Success -> println("foo is ${foo.result}")
* is Result.Failure -> println("foo is of the incorrect type")
* }
* ```
*/
@Internal
inline fun <reified T> TomlTable.safeGet(key: String): Result<T?, TomlTableSafeGetError.UnexpectedType> {
val value = get("\"$key\"")
@@ -37,6 +67,25 @@ inline fun <reified T> TomlTable.safeGet(key: String): Result<T?, TomlTableSafeG
return success(value)
}
/**
* Attempts to extract a value of type [T] from an instance of [TomlTable] by key, asserting its presence.
* On an invalid type, returns an instance of [Result.Failure] with an error of [TomlTableSafeGetError.UnexpectedType].
* On a missing value, returns an instance of [Result.Failure] with an error of [TomlTableSafeGetError.RequiredValueMissing].
* On a present value with the valid type, returns an instance of [Result.Success] with that value.
*
* Example:
*
* ```kotlin
* tomlTable.safeGetRequired<String>("foo").getOr { error ->
* when (error) {
* is TomlTableSafeGetError.UnexpectedType -> print("incorrect type")
* is TomlTableSafeGetError.RequiredValueMissing -> print("value is missing")
* }
* return
* }
* ```
*/
@Internal
inline fun <reified T> TomlTable.safeGetRequired(key: String): Result<T, TomlTableSafeGetError> =
safeGet<T>(key).mapResult {
if (it == null) {
@@ -47,6 +96,27 @@ inline fun <reified T> TomlTable.safeGetRequired(key: String): Result<T, TomlTab
}
}
/**
* Attempts to extract an array of values of type [T] from an instance of [TomlTable] by key.
* On an invalid type, returns an instance of [Result.Failure] with an error of [TomlTableSafeGetError.UnexpectedType].
* On a missing value, returns an instance of [Result.Success] with a value of null.
* On a present value with the valid type, returns an instance of [Result.Success] with a [List] of type [T].
*
* Example:
*
* ```kotlin
* val foo = tomlTable.safeGetArr<String>("foo")
* when (foo) {
* is Result.Success -> foo.result?.forEachIndexed { index, value ->
* print("foo[$index] is $value")
* } ?: {
* print("foo is null")
* }
* is Result.Failure -> println("foo is of the incorrect type")
* }
* ```
*/
@Internal
inline fun <reified T> TomlTable.safeGetArr(key: String): Result<List<T>?, TomlTableSafeGetError.UnexpectedType> {
val array = safeGet<TomlArray>(key).getOr { return it }
@@ -66,6 +136,19 @@ inline fun <reified T> TomlTable.safeGetArr(key: String): Result<List<T>?, TomlT
})
}
/**
* Attempts to unwrap a [Result].
* On success, returns the unwrapped value and calls the [onNull] callback (if provided) when the value is null.
* On failure, wraps the error by calling the [wrapper] callback, adds the result to the provided [issues] list, then return null.
* This function is useful for cases requiring to collect a list of parsing issues.
*
* Example:
*
* ```kotlin
* val issues = mutableListOf<>
* ```
*/
@Internal
fun <T, E, I> Result<T, E>.getOrIssue(issues: MutableList<I>, wrapper: (E) -> I, onNull: (() -> Unit)? = null): T? =
when (this) {
is Result.Success -> {
@@ -80,16 +163,49 @@ fun <T, E, I> Result<T, E>.getOrIssue(issues: MutableList<I>, wrapper: (E) -> I,
}
}
/**
* Attempts to find a TOML header in [PsiElement] by the name of [name].
* Returns null if the file wasn't TOML or the header wasn't found.
*
* Example:
*
* ```kotlin
* val dependencies = psiFile.findTomlHeader("project").findTomlValueByKey("dependencies")
* ```
*/
@Internal
fun PsiElement.findTomlHeader(name: String): PsiElement? =
children.find { element ->
(element as? PsiTomlTable)?.header?.key?.text == name
}
fun PsiElement.findTomlValueByKey(name: String): PsiElement? =
/**
* Attempts to find a value of a [PsiTomlKeyValue] by the key of [key] in an instance of [PsiElement].
* Returns null if the file wasn't TOML or the key wasn't found.
*
* Example:
*
* ```kotlin
* val dependencies = psiFile.findTomlHeader("project").findTomlValueByKey("dependencies")
* ```
*/
@Internal
fun PsiElement.findTomlValueByKey(key: String): PsiElement? =
(children.find { element ->
(element as? PsiTomlKeyValue)?.key?.text == name
(element as? PsiTomlKeyValue)?.key?.text == key
} as? PsiTomlKeyValue)?.value
/**
* Attempts to find all [PsiTomlLiteral]s found within the children of [PsiElement] that have the text containing [text].
*
* Example:
*
* ```kotlin
* val dependencies = psiFile.findTomlHeader("project").findTomlValueByKey("dependencies")
* val requests = dependencies.findTomlLiteralsContaining("requests")
* ```
*/
@Internal
fun PsiElement.findTomlLiteralsContaining(text: String): List<PsiElement> =
children.filter { element ->
(element as? PsiTomlLiteral)?.text?.contains(text) ?: false

View File

@@ -1,6 +1,15 @@
// 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.pyproject
import org.jetbrains.annotations.ApiStatus.Internal
/**
* Represents a parsed `pyproject.toml` file.
* Any inconsistencies with the spec and the parsed values are represented by the [PyProjectToml.issues] list after parsing.
*
* @see [pyproject.toml specification](https://packaging.python.org/en/latest/specifications/pyproject-toml/)
*/
@Internal
data class PyProjectTable(
val name: String? = null,
val version: String? = null,
@@ -20,17 +29,40 @@ data class PyProjectTable(
val urls: Map<String, String>? = null,
)
/**
* Represents the dependencies of the project.
*/
@Internal
data class PyProjectDependencies(
/**
* Dependencies provided in `project.dependencies`.
*/
val project: List<String> = listOf(),
/**
* Dependencies provided in `dependency-groups.dev`.
*/
val dev: List<String> = listOf(),
/**
* Dependencies provided in `project.optional-dependencies`.
*/
val optional: Map<String, List<String>> = mapOf(),
)
/**
* Represents a file object.
*/
@Internal
data class PyProjectFile(
val name: String,
val contentType: String? = null,
)
/**
* Represents a contact. Both [name] and [email] can't be absent at the same time.
*/
@Internal
data class PyProjectContact(val name: String?, val email: String?) {
init {
if (name == null && email == null) {

View File

@@ -16,22 +16,73 @@ import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.findAmongRoots
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus.Internal
import java.nio.file.Path
/**
* Stores the file name of `pyproject.toml`.
*/
@Internal
const val PY_PROJECT_TOML: String = "pyproject.toml"
/**
* Represents an issue that could occur in [PyProjectToml.parse].
*/
@Internal
sealed class PyProjectIssue {
/**
* Signifies that the name is missing from the `project` section.
*/
data object MissingName : PyProjectIssue()
/**
* Signifies that the version is missing from the `project` section, while also being absent from the `dynamic` array.
*/
data object MissingVersion : PyProjectIssue()
/**
* Wraps [TomlTableSafeGetError].
*/
data class SafeGetError(val error: TomlTableSafeGetError) : PyProjectIssue()
/**
* Signifies that a contact misses both `name` and `email` fields.
*/
data class InvalidContact(val path: String) : PyProjectIssue()
}
/**
* A general handler for `pyproject.toml` files.
*/
@Internal
data class PyProjectToml(
/**
* Represents the parsed `pyproject.toml` file.
* This field can be null when the `project` section is missing.
*/
val project: PyProjectTable?,
/**
* A list of issues that occurred during the execution of [PyProjectToml.parse].
*/
val issues: List<PyProjectIssue>,
/**
* An instance of [TomlTable] provided by the TOML parser.
*/
val toml: TomlTable,
) {
/**
* Gets a specific tool from an object implementing [PyProjectToolFactory].
*
* Example:
*
* ```kotlin
* val pyProject = PyProjectToml.parse(psiFile.virtualFile.inputStream).orThrow()
* val uvTool = pyProject.getTool(UvPyProject)
* val hatch = pyProject.getTool(HatchPyProject)
* ```
*/
fun <T : PyProjectToolFactory<U>, U> getTool(tool: T): U {
return tool.createTool(
mapOf(
@@ -43,6 +94,19 @@ data class PyProjectToml(
}
companion object {
/**
* Attempts to parse [inputStream] and construct an instance of [PyProjectToml].
* On success, returns an instance of [Result.Success] with an instance of [PyProjectToml].
* On failure, returns an instance of [Result.Failure] with a list of [TomlParseError]s.
*
* Example:
*
* ```kotlin
* val pyProject = PyProjectToml.parse(psiFile.virtualFile.inputStream).orThrow()
* val uvTool = pyProject.getTool(UvPyProject)
* val hatch = pyProject.getTool(HatchPyProject)
* ```
*/
fun parse(inputStream: InputStream): Result<PyProjectToml, List<TomlParseError>> {
val issues = mutableListOf<PyProjectIssue>()
val toml = Toml.parse(inputStream)
@@ -163,19 +227,28 @@ data class PyProjectToml(
)
}
fun findFileBlocking(module: Module): VirtualFile? =
findAmongRoots(module, PY_PROJECT_TOML)
/**
* Attempts to find the `pyproject.toml` file in the provided module.
* Returns null if not found.
*/
suspend fun findFile(module: Module): VirtualFile? =
withContext(Dispatchers.IO) {
findAmongRoots(module, PY_PROJECT_TOML)
}
/**
* Attempts to find the module's working directory.
* Returns a pair of [VirtualFile] to [Path], either of which may be null if not found.
*/
suspend fun findModuleWorkingDirectory(module: Module): Pair<VirtualFile?, Path?> {
val file = findFile(module)
return Pair(file, file?.toNioPathOrNull()?.parent ?: module.basePath?.let { Path.of(it) })
}
/**
* Attempts to find the project's working directory.
* Returns null if not found.
*/
fun findProjectWorkingDirectory(project: Project): Path? =
project.basePath?.let { Path.of(it) }

View File

@@ -2,8 +2,36 @@
package com.intellij.python.pyproject
import org.apache.tuweni.toml.TomlTable
import org.jetbrains.annotations.ApiStatus.Internal
/**
* A factory for `pyproject.toml` tool parsers.
*
* Example:
*
* ```kotlin
* data class UvPyProject(val dependencies: List<String>) {
* companion object : PyProjectToolFactory<UvPyProject> {
* override val tables: List<String> = listOf("tool.uv")
*
* override fun createTool(tables: Map<String, TomlTable?>): UvPyProject {
* val dependencies = tables["tool.uv"]?.safeGetArr<String>("dev-dependencies")?.successOrNull ?: listOf()
* return UvPyProject(uvDevDependencies)
* }
* }
* }
* ```
*/
@Internal
interface PyProjectToolFactory<T> {
/**
* A list of strings that represent [TomlTable]s to be provided by [PyProjectToml.getTool] to [createTool]'s map.
* If a table is absent from the file, the corresponding value in the map will be null.
*/
val tables: List<String>
/**
* Constructs a concrete tool [T].
*/
fun createTool(tables: Map<String, TomlTable?>): T
}

View File

@@ -0,0 +1,722 @@
package com.intellij.python.pyproject
import com.intellij.python.pyproject.PyProjectIssue.InvalidContact
import com.intellij.python.pyproject.PyProjectIssue.MissingName
import com.intellij.python.pyproject.PyProjectIssue.MissingVersion
import com.intellij.python.pyproject.PyProjectIssue.SafeGetError
import com.intellij.python.pyproject.TomlTableSafeGetError.RequiredValueMissing
import com.intellij.python.pyproject.TomlTableSafeGetError.UnexpectedType
import com.jetbrains.python.Result.Failure
import com.jetbrains.python.getOrThrow
import com.jetbrains.python.isFailure
import org.apache.tuweni.toml.TomlArray
import org.apache.tuweni.toml.TomlTable
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import kotlin.reflect.KClass
class PyProjectTomlTest {
@Test
fun parseProvidesErrorsOnFailure() {
// GIVEN
val configContents = "[proj"
// WHEN
val result = PyProjectToml.parse(configContents.byteInputStream())
// THEN
assert(result.isFailure)
assert((result as Failure).error.isNotEmpty())
}
@Test
fun toolsCanBeCreated() {
// GIVEN
val configContents = """
[project]
name="Some project"
version="1.2.3"
[shared_category]
foo="test foo"
[tool.test]
bar="test bar"
baz="test baz"
""".trimIndent()
val pyproject = PyProjectToml.parse(configContents.byteInputStream()).orThrow()
// WHEN
val testTool = pyproject.getTool(TestPyProject)
// THEN
assertEquals(2, testTool.tables.size)
assert(testTool.tables["tool.test"] is TomlTable)
assert(testTool.tables["shared_category"] is TomlTable)
}
@Test
fun toolsCanBeCreatedWithoutProject() {
// GIVEN
val configContents = """
[shared_category]
foo="test foo"
[tool.test]
bar="test bar"
baz="test baz"
""".trimIndent()
// WHEN
val pyproject = PyProjectToml.parse(configContents.byteInputStream()).orThrow()
val testTool = pyproject.getTool(TestPyProject)
// THEN
assertEquals(pyproject.project, null)
assertEquals(pyproject.issues.size, 0)
assertEquals(2, testTool.tables.size)
assert(testTool.tables["tool.test"] is TomlTable)
assert(testTool.tables["shared_category"] is TomlTable)
}
@Test
fun toolsCanBeCreatedWithProjectThatHasIssues() {
// GIVEN
val configContents = """
[project]
[shared_category]
foo="test foo"
[tool.test]
bar="test bar"
baz="test baz"
""".trimIndent()
// WHEN
val pyproject = PyProjectToml.parse(configContents.byteInputStream()).orThrow()
val testTool = pyproject.getTool(TestPyProject)
// THEN
assertEquals(pyproject.issues, listOf(MissingName, MissingVersion))
assertEquals(pyproject.project, PyProjectTable())
assertEquals(2, testTool.tables.size)
assert(testTool.tables["tool.test"] is TomlTable)
assert(testTool.tables["shared_category"] is TomlTable)
}
@Test
fun absentToolSectionsResultInNull() {
// GIVEN
val configContents = """
[project]
name="Some project"
version="1.2.3"
""".trimIndent()
val pyproject = PyProjectToml.parse(configContents.byteInputStream()).orThrow()
// WHEN
val testTool = pyproject.getTool(TestPyProject)
// THEN
assertEquals(testTool.tables["tool.test"], null)
assertEquals(testTool.tables["shared_category"], null)
}
@ParameterizedTest(name = "{0}")
@MethodSource("parseTestCases")
fun parseTests(name: String, pyprojectToml: String, expectedProjectTable: PyProjectTable?, expectedIssues: List<PyProjectIssue>) {
val result = PyProjectToml.parse(pyprojectToml.byteInputStream())
val unwrapped = result.getOrThrow()
assertEquals(expectedProjectTable, unwrapped.project)
assertEquals(expectedIssues, unwrapped.issues)
}
companion object {
@JvmStatic
fun parseTestCases(): List<Arguments> = listOf(
ParseTestCase(
"empty config results with no table and empty issues",
"",
null,
listOf()
),
ParseTestCase(
"empty project section results with table and name + version issues",
"[project]",
PyProjectTable(),
listOf(MissingName, MissingVersion)
),
ParseTestCase(
"empty name results in an issue",
"""
[project]
version = "123"
""".trimIndent(),
PyProjectTable(version = "123"),
listOf(MissingName)
),
ParseTestCase(
"empty name results in an issue, even when mentioned in dynamic",
"""
[project]
version = "123"
dynamic = ["name"]
""".trimIndent(),
PyProjectTable(version = "123", dynamic = listOf("name")),
listOf(MissingName)
),
ParseTestCase(
"empty version results in an issue",
"""
[project]
name = "some_name"
""".trimIndent(),
PyProjectTable(name = "some_name"),
listOf(MissingVersion)
),
ParseTestCase(
"empty version doesn't result in an issue when it's present in dynamic",
"""
[project]
name = "some_name"
dynamic = ["version"]
""".trimIndent(),
PyProjectTable(name = "some_name", dynamic = listOf("version")),
listOf()
),
ParseTestCase(
"name of wrong type results in an issue",
"""
[project]
name = 12
version = "123"
""".trimIndent(),
PyProjectTable(version = "123"),
listOf(SafeGetError(UnexpectedType("name", String::class, Long::class)))
),
ParseTestCase(
"version of wrong type results in an issue",
"""
[project]
name = "name"
version = 123
""".trimIndent(),
PyProjectTable(name = "name"),
listOf(SafeGetError(UnexpectedType("version", String::class, Long::class)))
),
ParseTestCase(
"name and version resolve correctly when correctly specified",
"""
[project]
name = "name"
version = "123"
""".trimIndent(),
PyProjectTable(name = "name", version = "123"),
listOf()
),
*listOf<Pair<String, KClass<*>>>(
"requires-python" to String::class,
"authors" to TomlArray::class,
"maintainers" to TomlArray::class,
"description" to String::class,
"readme" to TomlTable::class,
"license" to String::class,
"license-files" to TomlArray::class,
"keywords" to TomlArray::class,
"classifiers" to TomlArray::class,
"dependencies" to TomlArray::class,
"optional-dependencies" to TomlTable::class,
"scripts" to TomlTable::class,
"gui-scripts" to TomlTable::class,
"urls" to TomlTable::class,
).map {
ParseTestCase(
"${it.first} of wrong type results in an issue",
"""
[project]
name = "name"
version = "123"
${it.first} = 123
""".trimIndent(),
PyProjectTable(name = "name", version = "123"),
listOf(SafeGetError(UnexpectedType(it.first, it.second, Long::class)))
)
}.toTypedArray(),
*listOf("authors", "maintainers").flatMap {
listOf(
ParseTestCase(
"contacts of wrong type in $it result in an issue",
"""
[project]
name = "name"
version = "123"
$it = [
123,
]
""".trimIndent(),
PyProjectTable(name = "name", version = "123"),
listOf(
SafeGetError(UnexpectedType("$it[0]", TomlTable::class, Long::class)),
)
),
ParseTestCase(
"contacts without name and email in $it result in an issue",
"""
[project]
name = "name"
version = "123"
$it = [
{foo = 123, bar = "qwf"}
]
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
authors = if (it == "authors") listOf() else null,
maintainers = if (it == "maintainers") listOf() else null,
),
listOf(
InvalidContact("$it[0]"),
)
),
ParseTestCase(
"contacts with only a name in $it resolve",
"""
[project]
name = "name"
version = "123"
$it = [
{name = "name1"},
{name = "name2"}
]
""".trimIndent(),
run {
val contacts = listOf(PyProjectContact(name = "name1", email = null), PyProjectContact(name = "name2", email = null))
PyProjectTable(
name = "name",
version = "123",
authors = if (it == "authors") contacts else null,
maintainers = if (it == "maintainers") contacts else null,
)
},
listOf(),
),
ParseTestCase(
"contacts with only an email in $it resolve",
"""
[project]
name = "name"
version = "123"
$it = [
{email = "email1"},
{email = "email2"}
]
""".trimIndent(),
run {
val contacts = listOf(PyProjectContact(name = null, email = "email1"), PyProjectContact(name = null, email = "email2"))
PyProjectTable(
name = "name",
version = "123",
authors = if (it == "authors") contacts else null,
maintainers = if (it == "maintainers") contacts else null,
)
},
listOf(),
),
ParseTestCase(
"contacts with both name and email in $it resolve",
"""
[project]
name = "name"
version = "123"
$it = [
{name = "name1", email = "email1"},
{name = "name2", email = "email2"}
]
""".trimIndent(),
run {
val contacts = listOf(PyProjectContact(name = "name1", email = "email1"), PyProjectContact(name = "name2", email = "email2"))
PyProjectTable(
name = "name",
version = "123",
authors = if (it == "authors") contacts else null,
maintainers = if (it == "maintainers") contacts else null,
)
},
listOf(),
)
)
}.toTypedArray(),
*listOf("license-files", "keywords", "classifiers", "dependencies").map {
ParseTestCase(
"elements in $it that are of the wrong type resolve in an issue",
"""
[project]
name = "name"
version = "123"
$it = [123]
""".trimIndent(),
PyProjectTable(name = "name", version = "123"),
listOf(SafeGetError(UnexpectedType("$it[0]", String::class, Long::class)))
)
}.toTypedArray(),
ParseTestCase(
"correctly defined dependencies resolve",
"""
[project]
name = "name"
version = "123"
dependencies = ["a", "b"]
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
dependencies = PyProjectDependencies(
project = listOf("a", "b")
)
),
listOf()
),
ParseTestCase(
"dev with wrong type in dependency-groups results in an issue",
"""
[project]
name = "name"
version = "123"
[dependency-groups]
dev = 123
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
),
listOf(SafeGetError(UnexpectedType("dev", TomlArray::class, Long::class)))
),
ParseTestCase(
"correctly defined dev dependencies resolve",
"""
[project]
name = "name"
version = "123"
[dependency-groups]
dev = ["a", "b"]
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
dependencies = PyProjectDependencies(
dev = listOf("a", "b")
)
),
listOf()
),
ParseTestCase(
"optional dependency entries with wrong type result in an issue",
"""
[project]
name = "name"
version = "123"
[project.optional-dependencies]
a = 123
b = [123]
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
),
listOf(
SafeGetError(UnexpectedType("a", TomlArray::class, Long::class)),
SafeGetError(UnexpectedType("b[0]", String::class, Long::class)),
)
),
ParseTestCase(
"correctly defined optional dependencies resolve",
"""
[project]
name = "name"
version = "123"
[project.optional-dependencies]
foo = ["a", "b"]
bar = ["c", "d"]
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
dependencies = PyProjectDependencies(
optional = mapOf(
"foo" to listOf("a", "b"),
"bar" to listOf("c", "d")
)
)
),
listOf()
),
*listOf("scripts", "gui-scripts", "urls").flatMap {
listOf(
ParseTestCase(
"$it entries with wrong type result in an issue",
"""
[project]
name = "name"
version = "123"
[project.$it]
a = 123
b = 123
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
scripts = if (it == "scripts") mapOf() else null,
guiScripts = if (it == "gui-scripts") mapOf() else null,
urls = if (it == "urls") mapOf() else null,
),
listOf(
SafeGetError(UnexpectedType("a", String::class, Long::class)),
SafeGetError(UnexpectedType("b", String::class, Long::class)),
)
),
ParseTestCase(
"correctly defined entries in $it resolve",
"""
[project]
name = "name"
version = "123"
[project.$it]
a = "item1"
b = "item2"
""".trimIndent(),
run {
val items = mapOf("a" to "item1", "b" to "item2")
PyProjectTable(
name = "name",
version = "123",
scripts = if (it == "scripts") items else null,
guiScripts = if (it == "gui-scripts") items else null,
urls = if (it == "urls") items else null,
)
},
listOf()
),
)
}.toTypedArray(),
ParseTestCase(
"readme can be defined as a string",
"""
[project]
name = "name"
version = "123"
readme = "README.md"
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
readme = PyProjectFile("README.md"),
),
listOf(),
),
ParseTestCase(
"readme can be defined as an object",
"""
[project]
name = "name"
version = "123"
readme = {name = "README.md", content-type = "text/markdown"}
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
readme = PyProjectFile("README.md", "text/markdown"),
),
listOf(),
),
ParseTestCase(
"readme object with missing name results in an issue",
"""
[project]
name = "name"
version = "123"
readme = {content-type = "text/markdown"}
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
),
listOf(SafeGetError(RequiredValueMissing("name"))),
),
ParseTestCase(
"readme object with missing content-type results in an issue",
"""
[project]
name = "name"
version = "123"
readme = {name = "README.md"}
""".trimIndent(),
PyProjectTable(
name = "name",
version = "123",
),
listOf(SafeGetError(RequiredValueMissing("content-type"))),
),
ParseTestCase(
"correctly parses full example",
"""
[project]
name = "spam-eggs"
version = "2020.0.0"
dependencies = [
"httpx",
"gidgethub[httpx]>4.0.0",
"django>2.1; os_name != 'nt'",
"django>2.0; os_name == 'nt'",
]
requires-python = ">=3.8"
authors = [
{name = "Pradyun Gedam", email = "pradyun@example.com"},
{name = "Tzu-Ping Chung", email = "tzu-ping@example.com"},
{name = "Another person"},
{email = "different.person@example.com"},
]
maintainers = [
{name = "Brett Cannon", email = "brett@example.com"}
]
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
license = "MIT"
license-files = ["LICEN[CS]E.*"]
keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python"
]
[project.optional-dependencies]
gui = ["PyQt5"]
cli = [
"rich",
"click",
]
[project.urls]
Homepage = "https://example.com"
Documentation = "https://readthedocs.org"
Repository = "https://github.com/me/spam.git"
"Bug Tracker" = "https://github.com/me/spam/issues"
Changelog = "https://github.com/me/spam/blob/master/CHANGELOG.md"
[project.scripts]
spam-cli = "spam:main_cli"
[project.gui-scripts]
spam-gui = "spam:main_gui"
[dependency-groups]
dev = ["foo", "bar"]
""".trimIndent(),
PyProjectTable(
name = "spam-eggs",
version = "2020.0.0",
requiresPython = ">=3.8",
authors = listOf(
PyProjectContact(name = "Pradyun Gedam", email = "pradyun@example.com"),
PyProjectContact(name = "Tzu-Ping Chung", email = "tzu-ping@example.com"),
PyProjectContact(name = "Another person", email = null),
PyProjectContact(name = null, email = "different.person@example.com"),
),
maintainers = listOf(
PyProjectContact(name = "Brett Cannon", email = "brett@example.com"),
),
description = "Lovely Spam! Wonderful Spam!",
readme = PyProjectFile("README.rst"),
license = "MIT",
licenseFiles = listOf("LICEN[CS]E.*"),
keywords = listOf("egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"),
classifiers = listOf(
"Development Status :: 4 - Beta",
"Programming Language :: Python",
),
dependencies = PyProjectDependencies(
project = listOf(
"httpx",
"gidgethub[httpx]>4.0.0",
"django>2.1; os_name != 'nt'",
"django>2.0; os_name == 'nt'",
),
dev = listOf("foo", "bar"),
optional = mapOf(
"gui" to listOf("PyQt5"),
"cli" to listOf("rich", "click"),
),
),
urls = mapOf(
"Homepage" to "https://example.com",
"Documentation" to "https://readthedocs.org",
"Repository" to "https://github.com/me/spam.git",
"Bug Tracker" to "https://github.com/me/spam/issues",
"Changelog" to "https://github.com/me/spam/blob/master/CHANGELOG.md",
),
scripts = mapOf(
"spam-cli" to "spam:main_cli",
),
guiScripts = mapOf(
"spam-gui" to "spam:main_gui",
),
),
listOf(),
),
).map {
Arguments.of(it.name, it.pyprojectToml, it.expectedProjectTable, it.expectedIssues)
}
data class ParseTestCase(
val name: String,
val pyprojectToml: String,
val expectedProjectTable: PyProjectTable?,
val expectedIssues: List<PyProjectIssue>,
)
data class TestPyProject(val tables: Map<String, TomlTable?>) {
companion object : PyProjectToolFactory<TestPyProject> {
override val tables: List<String> = listOf("tool.test", "shared_category")
override fun createTool(tables: Map<String, TomlTable?>): TestPyProject = TestPyProject(tables)
}
}
}
}

View File

@@ -7,6 +7,7 @@ import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.module.Module
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.PsiFile
@@ -20,6 +21,7 @@ import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.findAmongRoots
internal class UvPackageVersionsInspection : LocalInspectionTool() {
override fun buildVisitor(
@@ -39,12 +41,15 @@ internal class UvPackageVersionsInspection : LocalInspectionTool() {
return ModuleUtilCore.findModuleForPsiElement(element)
}
@RequiresBackgroundThread
private fun Module.pyProjectTomlBlocking(): VirtualFile? = findAmongRoots(this, PY_PROJECT_TOML)
@RequiresBackgroundThread
override fun visitFile(file: PsiFile) {
val module = guessModule(file) ?: return
val sdk = PythonSdkUtil.findPythonSdk(module) ?: return
if (!sdk.isUv || file.name != PY_PROJECT_TOML || file.virtualFile != PyProjectToml.findFileBlocking(module)) {
if (!sdk.isUv || file.name != PY_PROJECT_TOML || file.virtualFile != module.pyProjectTomlBlocking()) {
return
}