[pycharm] PY-79369 Consolidate logic for pyproject.toml handling

GitOrigin-RevId: cf5b90daeaf3686b044e98888be6675618e9471e
This commit is contained in:
David Lysenko
2025-02-25 14:45:15 +00:00
committed by intellij-monorepo-bot
parent 7b16a10379
commit a570aebb18
6 changed files with 396 additions and 0 deletions
@@ -173,5 +173,6 @@
<orderEntry type="module" module-name="intellij.python.community.services.systemPython" />
<orderEntry type="module" module-name="intellij.python.community.services.internal.impl" />
<orderEntry type="library" name="io.github.z4kn4fein.semver.jvm" level="project" />
<orderEntry type="module" module-name="intellij.python.pyproject" />
</component>
</module>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<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" />
</component>
</module>
@@ -0,0 +1,96 @@
package com.intellij.python.pyproject
import com.intellij.psi.PsiElement
import com.jetbrains.python.Result
import com.jetbrains.python.Result.Companion.failure
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.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
sealed class TomlTableSafeGetError {
data class UnexpectedType(val path: String, val expected: KClass<*>, val actual: KClass<*>) : TomlTableSafeGetError()
data class RequiredValueMissing(val path: String) : TomlTableSafeGetError()
}
inline fun <reified T> TomlTable.safeGet(key: String): Result<T?, TomlTableSafeGetError.UnexpectedType> {
val value = get("\"$key\"")
if (value == null) {
return success(null)
}
if (value !is T) {
return failure(
TomlTableSafeGetError.UnexpectedType(
key,
T::class,
value::class,
)
)
}
return success(value)
}
inline fun <reified T> TomlTable.safeGetRequired(key: String): Result<T, TomlTableSafeGetError> =
safeGet<T>(key).mapResult {
if (it == null) {
failure(TomlTableSafeGetError.RequiredValueMissing(key))
}
else {
success(it)
}
}
inline fun <reified T> TomlTable.safeGetArr(key: String): Result<List<T>?, TomlTableSafeGetError.UnexpectedType> {
val array = safeGet<TomlArray>(key).getOr { return it }
if (array == null) {
return success(null)
}
return success(array.toList().mapIndexed { index, value ->
if (value !is T) {
return failure(TomlTableSafeGetError.UnexpectedType(
"$key[$index]",
T::class,
value::class,
))
}
value
})
}
fun <T, E, I> Result<T, E>.getOrIssue(issues: MutableList<I>, wrapper: (E) -> I, onNull: (() -> Unit)? = null): T? =
when (this) {
is Result.Success -> {
if (result == null && onNull != null) {
onNull()
}
result
}
is Result.Failure -> {
issues += wrapper(error)
null
}
}
fun PsiElement.findTomlHeader(name: String): PsiElement? =
children.find { element ->
(element as? PsiTomlTable)?.header?.key?.text == name
}
fun PsiElement.findTomlValueByKey(name: String): PsiElement? =
(children.find { element ->
(element as? PsiTomlKeyValue)?.key?.text == name
} as? PsiTomlKeyValue)?.value
fun PsiElement.findTomlLiteralsContaining(text: String): List<PsiElement> =
children.filter { element ->
(element as? PsiTomlLiteral)?.text?.contains(text) ?: false
}
@@ -0,0 +1,40 @@
// 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
data class PyProjectTable(
val name: String? = null,
val version: String? = null,
val requiresPython: String? = null,
val authors: List<PyProjectContact>? = null,
val maintainers: List<PyProjectContact>? = null,
val description: String? = null,
val readme: PyProjectFile? = null,
val license: String? = null,
val licenseFiles: List<String>? = null,
val keywords: List<String>? = null,
val classifiers: List<String>? = null,
val dynamic: List<String>? = null,
val dependencies: PyProjectDependencies = PyProjectDependencies(),
val scripts: Map<String, String>? = null,
val guiScripts: Map<String, String>? = null,
val urls: Map<String, String>? = null,
)
data class PyProjectDependencies(
val project: List<String> = listOf(),
val dev: List<String> = listOf(),
val optional: Map<String, List<String>> = mapOf(),
)
data class PyProjectFile(
val name: String,
val contentType: String? = null,
)
data class PyProjectContact(val name: String?, val email: String?) {
init {
if (name == null && email == null) {
throw IllegalArgumentException("at least name or email should be provided")
}
}
}
@@ -0,0 +1,215 @@
// 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.jetbrains.python.Result.Companion.failure
import com.jetbrains.python.Result.Companion.success
import com.jetbrains.python.Result
import org.apache.tuweni.toml.Toml
import org.apache.tuweni.toml.TomlParseError
import org.apache.tuweni.toml.TomlTable
import java.io.InputStream
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.toNioPathOrNull
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.findAmongRoots
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
const val PY_PROJECT_TOML: String = "pyproject.toml"
sealed class PyProjectIssue {
data object MissingName : PyProjectIssue()
data object MissingVersion : PyProjectIssue()
data class SafeGetError(val error: TomlTableSafeGetError) : PyProjectIssue()
data class InvalidContact(val path: String) : PyProjectIssue()
}
data class PyProjectToml(
val project: PyProjectTable?,
val issues: List<PyProjectIssue>,
val toml: TomlTable,
) {
fun <T : PyProjectToolFactory<U>, U> getTool(tool: T): U {
return tool.createTool(
mapOf(
*tool.tables.map {
it to toml.getTable(it)
}.toTypedArray()
)
)
}
companion object {
fun parse(inputStream: InputStream): Result<PyProjectToml, List<TomlParseError>> {
val issues = mutableListOf<PyProjectIssue>()
val toml = Toml.parse(inputStream)
if (toml.hasErrors()) {
return failure(toml.errors())
}
val projectTable = toml.safeGet<TomlTable>("project").getOrIssue(issues)
if (projectTable == null) {
return success(PyProjectToml(null, issues, toml))
}
val name = projectTable.safeGet<String>("name").getOrIssue(issues) {
issues += PyProjectIssue.MissingName
}
val dynamic = projectTable.safeGetArr<String>("dynamic").getOrIssue(issues)
val version = projectTable.safeGet<String>("version").getOrIssue(issues) {
if (dynamic?.contains("version") != true) {
issues += PyProjectIssue.MissingVersion
}
}
val requiresPython = projectTable.safeGet<String>("requires-python").getOrIssue(issues)
val authors = projectTable.parseContacts("authors", issues)
val maintainers = projectTable.parseContacts("maintainers", issues)
val description = projectTable.safeGet<String>("description").getOrIssue(issues)
val license = projectTable.safeGet<String>("license").getOrIssue(issues)
val licenseFiles = projectTable.safeGetArr<String>("license-files").getOrIssue(issues)
val keywords = projectTable.safeGetArr<String>("keywords").getOrIssue(issues)
val classifiers = projectTable.safeGetArr<String>("classifiers").getOrIssue(issues)
val readme = when (val res = projectTable.safeGet<String>("readme")) {
is Result.Success -> {
res.getOrIssue(issues)?.let { name ->
PyProjectFile(name)
}
}
is Result.Failure -> {
val table = projectTable
.safeGet<TomlTable>("readme")
.getOrIssue(issues)
val name = table
?.safeGetRequired<String>("name")
?.getOrIssue(issues)
val contentType = table
?.safeGetRequired<String>("content-type")
?.getOrIssue(issues)
if (name != null && contentType != null) {
PyProjectFile(name, contentType)
}
else {
null
}
}
}
val projectDependencies = projectTable.safeGetArr<String>("dependencies").getOrIssue(issues) ?: listOf()
val devDependencies =
toml
.safeGet<TomlTable>("dependency-groups")
.getOrIssue(issues)
?.safeGetArr<String>("dev")
?.getOrIssue(issues)
?.toList()
?: listOf()
val optionalDependencies =
projectTable
.safeGet<TomlTable>("optional-dependencies")
.getOrIssue(issues)
?.let { table ->
mapOf(
*table.keySet().mapNotNull { key ->
table.safeGetArr<String>(key).getOrIssue(issues)?.let { value ->
key to value
}
}.toTypedArray()
)
}
?: mapOf()
val scripts = projectTable.parseMap("scripts", issues)
val guiScripts = projectTable.parseMap("gui-scripts", issues)
val urls = projectTable.parseMap("urls", issues)
return success(
PyProjectToml(
PyProjectTable(
name,
version,
requiresPython,
authors,
maintainers,
description,
readme,
license,
licenseFiles,
keywords,
classifiers,
dynamic,
PyProjectDependencies(
projectDependencies,
devDependencies,
optionalDependencies
),
scripts,
guiScripts,
urls,
),
issues,
toml,
)
)
}
fun findFileBlocking(module: Module): VirtualFile? =
findAmongRoots(module, PY_PROJECT_TOML)
suspend fun findFile(module: Module): VirtualFile? =
withContext(Dispatchers.IO) {
findAmongRoots(module, PY_PROJECT_TOML)
}
suspend fun findModuleWorkingDirectory(module: Module): Pair<VirtualFile?, Path?> {
val file = findFile(module)
return Pair(file, file?.toNioPathOrNull()?.parent ?: module.basePath?.let { Path.of(it) })
}
fun findProjectWorkingDirectory(project: Project): Path? =
project.basePath?.let { Path.of(it) }
private fun TomlTable.parseContacts(
key: String,
issues: MutableList<PyProjectIssue>,
): List<PyProjectContact>? {
val table = safeGetArr<TomlTable>(key).getOrIssue(issues) ?: return null
return table.mapIndexedNotNull { index, authorTable ->
val name = authorTable.safeGet<String>("name").getOrIssue(issues)
val email = authorTable.safeGet<String>("email").getOrIssue(issues)
if (name == null && email == null) {
issues += PyProjectIssue.InvalidContact("$key[$index]")
return@mapIndexedNotNull null
}
PyProjectContact(name, email)
}
}
private fun TomlTable.parseMap(key: String, issues: MutableList<PyProjectIssue>): Map<String, String>? {
val table = safeGet<TomlTable>(key).getOrIssue(issues) ?: return null
return mapOf(
*table.keySet().mapNotNull { key ->
table.safeGet<String>(key).getOrIssue(issues)?.let { value ->
key to value
}
}.toTypedArray()
)
}
private fun <T> Result<T, TomlTableSafeGetError>.getOrIssue(issues: MutableList<PyProjectIssue>, onNull: (() -> Unit)? = null) =
getOrIssue(issues, { PyProjectIssue.SafeGetError(it) }, onNull)
}
}
@@ -0,0 +1,9 @@
// 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.apache.tuweni.toml.TomlTable
interface PyProjectToolFactory<T> {
val tables: List<String>
fun createTool(tables: Map<String, TomlTable?>): T
}