PY-91089 uv workspace: new member added to pyproject.toml is never discovered as a module

Merge-request: IJ-MR-215001
Merged-by: Ilya Kazakevich <ilya.kazakevich@jetbrains.com>
(cherry picked from commit 93493f7236d9d5e4d1e07bbed615381082f2b63d)

GitOrigin-RevId: 7ea2ba8ad041a539fae1cb01b516422f74cc55c4
This commit is contained in:
Ivan Kulikov
2026-07-28 11:47:45 +00:00
committed by intellij-monorepo-bot
parent 237631cb56
commit 76a4a4c1f6
17 changed files with 230 additions and 11 deletions
@@ -7,6 +7,7 @@ import com.intellij.python.pyproject.model.spi.ProjectDependencies
import com.intellij.python.pyproject.model.spi.ProjectName
import com.intellij.python.pyproject.model.spi.PyProjectTomlProject
import com.intellij.python.pyproject.model.spi.TomlDependencySpecification
import com.intellij.python.pyproject.safeGet
import com.intellij.python.pyproject.safeGetArr
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.Result
@@ -161,7 +162,10 @@ private fun getToolSpecificDependencies(
): Sequence<Directory> {
return tomlDependencySpecifications.asSequence().flatMap { specification ->
when (specification) {
is TomlDependencySpecification.PathDependency -> tomlTable.getTable(specification.tomlKey)?.let {
// PY-91089: use safeGet instead of TomlTable.getTable, which throws TomlInvalidTypeException
// (not returns null) when the key holds a non-table value such as an array (the `[[tool.uv.sources]]`
// double-bracket typo). An unhandled throw here aborts the whole model sync and hides every member.
is TomlDependencySpecification.PathDependency -> tomlTable.safeGet<TomlTable>(specification.tomlKey, unquotedDottedKey = true).successOrNull?.let {
getToolSpecificDependenciesFromTomlTable(root, it)
} ?: emptySet()
is TomlDependencySpecification.Pep621Dependency -> {
@@ -169,9 +173,9 @@ private fun getToolSpecificDependencies(
deps.asSequence().mapNotNull(::parsePep621Dependency).toSet()
}
is TomlDependencySpecification.GroupPathDependency -> {
val groups = tomlTable.getTable(specification.tomlKeyToGroup) ?: return@flatMap emptySet()
val groups = tomlTable.safeGet<TomlTable>(specification.tomlKeyToGroup, unquotedDottedKey = true).successOrNull ?: return@flatMap emptySet()
groups.keySet().flatMap { group ->
groups.getTable("${group}.${specification.tomlKeyFromGroupToPath}")?.let {
groups.safeGet<TomlTable>("${group}.${specification.tomlKeyFromGroupToPath}", unquotedDottedKey = true).successOrNull?.let {
getToolSpecificDependenciesFromTomlTable(root, it)
} ?: emptySet()
}
@@ -183,7 +187,8 @@ private fun getToolSpecificDependencies(
@RequiresBackgroundThread
private fun getToolSpecificDependenciesFromTomlTable(root: Path, tomlTable: TomlTable): Set<Directory> {
return tomlTable.keySet().asSequence().mapNotNull {
tomlTable.getString("${it}.path")?.let { depPathString -> parseDepFromPathString(root, depPathString) }
// PY-91089: safeGet instead of getString, which throws when `<dep>.path` holds a non-string value.
tomlTable.safeGet<String>("${it}.path", unquotedDottedKey = true).successOrNull?.let { depPathString -> parseDepFromPathString(root, depPathString) }
}.toSet()
}
@@ -32,6 +32,7 @@ import com.intellij.platform.workspace.storage.url.VirtualFileUrlManager
import com.intellij.project.stateStore
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.safeGet
import com.intellij.python.pyproject.model.internal.PY_PROJECT_SYSTEM_ID
import com.intellij.python.pyproject.model.internal.PyProjectTomlBundle
import com.intellij.python.pyproject.model.internal.pyProjectToml.FSWalkInfoWithToml
@@ -479,7 +480,8 @@ private suspend fun parseRawEntries(fsInfo: FSWalkInfoWithToml, pyProjectManager
}
}
if (participatedManagers.isEmpty()) {
toml.toml.getString("build-system.build-backend")?.let { buildBackend ->
// PY-91089: safeGet instead of getString, which throws when `build-system.build-backend` is not a string.
toml.toml.safeGet<String>("build-system.build-backend", unquotedDottedKey = true).successOrNull?.let { buildBackend ->
pyProjectManagers.firstOrNull { it.id.id in buildBackend }?.let { buildTool ->
participatedManagers.add(buildTool.id)
}
@@ -0,0 +1,42 @@
// 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.alsoWin.pyproject.model.testplan
import com.intellij.python.junit5Tests.framework.PyDefaultTestApplication
import com.intellij.python.junit5Tests.framework.metaInfo.TestClassInfo
import com.intellij.python.junit5Tests.unit.alsoWin.pyproject.div
import com.intellij.python.junit5Tests.unit.alsoWin.pyproject.model.ExpectedModule
import com.intellij.python.junit5Tests.unit.alsoWin.pyproject.model.pyProjectTomlSyncFixture
import com.intellij.testFramework.TestDataPath
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.fixture.projectFixture
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import org.junit.jupiter.api.Test
/**
* PY-91089: the root `pyproject.toml` declares `tool.uv.sources` as an array of tables
* (`[[tool.uv.sources]]`) instead of a table (`[tool.uv.sources]`) — a common double-bracket
* typo. `uv lock`/`uv sync` still succeed because uv ignores non-member files, so the project
* looks fine outside the IDE. The malformed value must NOT abort the model sync: every
* workspace member — including the freshly added `orgwiki-test` — must still be discovered as
* a module.
*/
@PyDefaultTestApplication
@TestClassInfo(contentRootPath = "python-pyproject/test")
@TestDataPath($$"$CONTENT_ROOT/../testData/monorepo/PY-91089-uv-workspace-array-sources")
internal class UvWorkspaceArraySourcesTest {
companion object {
private val tempDirFixture = tempPathFixture()
private val projectFixture = projectFixture(pathFixture = tempDirFixture)
}
private val f by pyProjectTomlSyncFixture(projectFixture, tempDirFixture)
@Test
fun sanity(): Unit = timeoutRunBlocking {
f.reloadProject()
f.assertProjectStructure(
ExpectedModule("orgwiki", contentRoot = ".", sourceRoots = listOf(".")),
ExpectedModule("orgwiki-core", contentRoot = "orgwiki-core", sourceRoots = listOf("orgwiki-core" / "src")),
ExpectedModule("orgwiki-test", contentRoot = "orgwiki-test", deps = listOf("orgwiki-core"), sourceRoots = listOf("orgwiki-test" / "src")),
)
}
}
@@ -0,0 +1,40 @@
// 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.alsoWin.pyproject.model.testplan
import com.intellij.python.junit5Tests.framework.PyDefaultTestApplication
import com.intellij.python.junit5Tests.framework.metaInfo.TestClassInfo
import com.intellij.python.junit5Tests.unit.alsoWin.pyproject.div
import com.intellij.python.junit5Tests.unit.alsoWin.pyproject.model.ExpectedModule
import com.intellij.python.junit5Tests.unit.alsoWin.pyproject.model.pyProjectTomlSyncFixture
import com.intellij.testFramework.TestDataPath
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.fixture.projectFixture
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import org.junit.jupiter.api.Test
/**
* PY-91089: the root `pyproject.toml` declares `tool.uv.workspace` as an array of tables
* (`[[tool.uv.workspace]]`) instead of a table. The malformed value must NOT abort the model sync:
* both members are still discovered as modules (only their workspace grouping — and the deps derived
* from it — is lost, since the workspace declaration is unusable).
*/
@PyDefaultTestApplication
@TestClassInfo(contentRootPath = "python-pyproject/test")
@TestDataPath($$"$CONTENT_ROOT/../testData/monorepo/PY-91089-uv-workspace-array-workspace")
internal class UvWorkspaceArrayWorkspaceTest {
companion object {
private val tempDirFixture = tempPathFixture()
private val projectFixture = projectFixture(pathFixture = tempDirFixture)
}
private val f by pyProjectTomlSyncFixture(projectFixture, tempDirFixture)
@Test
fun sanity(): Unit = timeoutRunBlocking {
f.reloadProject()
f.assertProjectStructure(
ExpectedModule("orgwiki", contentRoot = ".", sourceRoots = listOf(".")),
ExpectedModule("orgwiki-core", contentRoot = "orgwiki-core", sourceRoots = listOf("orgwiki-core" / "src")),
ExpectedModule("orgwiki-test", contentRoot = "orgwiki-test", sourceRoots = listOf("orgwiki-test" / "src")),
)
}
}
@@ -0,0 +1,5 @@
# orgwiki
PY-91089 replica: a uv workspace whose root `pyproject.toml` declares `tool.uv.sources`
as an array of tables (`[[tool.uv.sources]]`) instead of a table. The workspace members
`orgwiki-core` and `orgwiki-test` must still be discovered as modules.
@@ -0,0 +1,14 @@
[project]
name = "orgwiki-core"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orgwiki_core"]
@@ -0,0 +1,2 @@
def ping() -> str:
return "orgwiki-core"
@@ -0,0 +1,19 @@
[project]
name = "orgwiki-test"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"orgwiki-core",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orgwiki_test"]
[tool.uv.sources]
orgwiki-core = { workspace = true }
@@ -0,0 +1,3 @@
from orgwiki_core import ping
__all__ = ["ping"]
@@ -0,0 +1,19 @@
[project]
name = "orgwiki"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = [
"orgwiki-core",
"orgwiki-test",
]
[tool.uv.workspace]
members = ["orgwiki-core", "orgwiki-test"]
# PY-91089: `tool.uv.sources` is written as an array of tables (`[[...]]`) instead of a
# table (`[...]`). This is a common double-bracket typo; uv ignores non-member files so
# `uv lock`/`uv sync` still succeed, but the IDE must not let it abort member discovery.
[[tool.uv.sources]]
orgwiki-core = { workspace = true }
orgwiki-test = { workspace = true }
@@ -0,0 +1,5 @@
# orgwiki
PY-91089 replica: a uv workspace whose root `pyproject.toml` declares `tool.uv.workspace`
as an array of tables (`[[tool.uv.workspace]]`) instead of a table. The members
`orgwiki-core` and `orgwiki-test` must still be discovered as modules.
@@ -0,0 +1,14 @@
[project]
name = "orgwiki-core"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orgwiki_core"]
@@ -0,0 +1,2 @@
def ping() -> str:
return "orgwiki-core"
@@ -0,0 +1,19 @@
[project]
name = "orgwiki-test"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"orgwiki-core",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orgwiki_test"]
[tool.uv.sources]
orgwiki-core = { workspace = true }
@@ -0,0 +1,3 @@
from orgwiki_core import ping
__all__ = ["ping"]
@@ -0,0 +1,18 @@
[project]
name = "orgwiki"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = [
"orgwiki-core",
"orgwiki-test",
]
# PY-91089: `tool.uv.workspace` is written as an array of tables (`[[...]]`) instead of a table (`[...]`).
# The malformed workspace declaration must not abort discovery — the members must still become modules.
[[tool.uv.workspace]]
members = ["orgwiki-core", "orgwiki-test"]
[tool.uv.sources]
orgwiki-core = { workspace = true }
orgwiki-test = { workspace = true }
@@ -5,6 +5,7 @@ import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.util.getPathMatcher
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.safeGet
import com.intellij.python.pyproject.model.spi.ProjectDependencies
import com.intellij.python.pyproject.model.spi.ProjectName
import com.intellij.python.pyproject.model.spi.ProjectStructureInfo
@@ -72,9 +73,12 @@ internal class UvPyProjectManager : PyProjectManager {
}
// Each member might have tool.uv.sources table.
// PY-91089: use safeGet, not TomlTable.getTable, which throws TomlInvalidTypeException when the key
// holds a non-table value (e.g. the `[[tool.uv.sources]]` array-of-tables typo). Such a member is
// simply treated as having no sources instead of aborting the whole workspace model sync.
val memberToUvSourceTable = entries
.mapNotNull { (projectName, toml) ->
toml.pyProjectToml.toml.getTable("tool.uv.sources")?.let { projectName to it }
toml.pyProjectToml.toml.safeGet<TomlTable>("tool.uv.sources", unquotedDottedKey = true).successOrNull?.let { projectName to it }
}
.toMap()
@@ -168,9 +172,11 @@ private data class SourceTableWithOwner(val table: TomlTable, val ownerRoot: Pat
@RequiresBackgroundThread
private fun getWorkspaceMembers(toml: TomlTable): WorkspaceInfo? {
val workspace = toml.getTable("tool.uv.workspace") ?: return null
val members = workspace.getArrayOrEmpty("members").asMatchers
val exclude = workspace.getArrayOrEmpty("exclude").asMatchers
// PY-91089: safeGet instead of getTable/getArrayOrEmpty, which throw TomlInvalidTypeException when
// the key holds an unexpected type (e.g. the `[[tool.uv.workspace]]` array typo, or `members = "x"`).
val workspace = toml.safeGet<TomlTable>("tool.uv.workspace", unquotedDottedKey = true).successOrNull ?: return null
val members = workspace.safeGet<TomlArray>("members").successOrNull?.asMatchers ?: emptyList()
val exclude = workspace.safeGet<TomlArray>("exclude").successOrNull?.asMatchers ?: emptyList()
if (members.isEmpty()) return null
return WorkspaceInfo(members = members, exclude = exclude)
}
@@ -202,12 +208,13 @@ private fun getUvDependencies(
val depName = depByNormalizedName[normalizedKey] ?: continue
val table = depTable as? TomlTable ?: continue
if (table.getBoolean("workspace") == true) {
// PY-91089: safeGet instead of getBoolean/getString, which throw when the value has an unexpected type.
if (table.safeGet<Boolean>("workspace").successOrNull == true) {
workspaceDeps.add(ProjectName(depName))
depByNormalizedName.remove(normalizedKey)
}
else {
val path = table.getString("path") ?: continue
val path = table.safeGet<String>("path").successOrNull ?: continue
try {
pathDeps.add(ownerRoot.resolve(path).normalize())
depByNormalizedName.remove(normalizedKey)