diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt index d038d266e32a..89dba17505e3 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt @@ -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 { 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(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(specification.tomlKeyToGroup, unquotedDottedKey = true).successOrNull ?: return@flatMap emptySet() groups.keySet().flatMap { group -> - groups.getTable("${group}.${specification.tomlKeyFromGroupToPath}")?.let { + groups.safeGet("${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 { return tomlTable.keySet().asSequence().mapNotNull { - tomlTable.getString("${it}.path")?.let { depPathString -> parseDepFromPathString(root, depPathString) } + // PY-91089: safeGet instead of getString, which throws when `.path` holds a non-string value. + tomlTable.safeGet("${it}.path", unquotedDottedKey = true).successOrNull?.let { depPathString -> parseDepFromPathString(root, depPathString) } }.toSet() } diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt index fc6fc3199d24..5f488c4df548 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt @@ -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("build-system.build-backend", unquotedDottedKey = true).successOrNull?.let { buildBackend -> pyProjectManagers.firstOrNull { it.id.id in buildBackend }?.let { buildTool -> participatedManagers.add(buildTool.id) } diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/UvWorkspaceArraySourcesTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/UvWorkspaceArraySourcesTest.kt new file mode 100644 index 000000000000..e42e25647523 --- /dev/null +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/UvWorkspaceArraySourcesTest.kt @@ -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")), + ) + } +} diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/UvWorkspaceArrayWorkspaceTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/UvWorkspaceArrayWorkspaceTest.kt new file mode 100644 index 000000000000..72e38c58c737 --- /dev/null +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/UvWorkspaceArrayWorkspaceTest.kt @@ -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")), + ) + } +} diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/README.md b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/README.md new file mode 100644 index 000000000000..30830664cd42 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/README.md @@ -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. diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-core/pyproject.toml b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-core/pyproject.toml new file mode 100644 index 000000000000..542d01a898f3 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-core/pyproject.toml @@ -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"] diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-core/src/orgwiki_core/__init__.py b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-core/src/orgwiki_core/__init__.py new file mode 100644 index 000000000000..7e6bdf942a8b --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-core/src/orgwiki_core/__init__.py @@ -0,0 +1,2 @@ +def ping() -> str: + return "orgwiki-core" diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-test/pyproject.toml b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-test/pyproject.toml new file mode 100644 index 000000000000..939a65854244 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-test/pyproject.toml @@ -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 } diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-test/src/orgwiki_test/__init__.py b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-test/src/orgwiki_test/__init__.py new file mode 100644 index 000000000000..25b054bcce96 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/orgwiki-test/src/orgwiki_test/__init__.py @@ -0,0 +1,3 @@ +from orgwiki_core import ping + +__all__ = ["ping"] diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/pyproject.toml b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/pyproject.toml new file mode 100644 index 000000000000..a4f3742e2955 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-sources/pyproject.toml @@ -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 } diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/README.md b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/README.md new file mode 100644 index 000000000000..db72f1884280 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/README.md @@ -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. diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-core/pyproject.toml b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-core/pyproject.toml new file mode 100644 index 000000000000..542d01a898f3 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-core/pyproject.toml @@ -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"] diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-core/src/orgwiki_core/__init__.py b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-core/src/orgwiki_core/__init__.py new file mode 100644 index 000000000000..7e6bdf942a8b --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-core/src/orgwiki_core/__init__.py @@ -0,0 +1,2 @@ +def ping() -> str: + return "orgwiki-core" diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-test/pyproject.toml b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-test/pyproject.toml new file mode 100644 index 000000000000..939a65854244 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-test/pyproject.toml @@ -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 } diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-test/src/orgwiki_test/__init__.py b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-test/src/orgwiki_test/__init__.py new file mode 100644 index 000000000000..25b054bcce96 --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/orgwiki-test/src/orgwiki_test/__init__.py @@ -0,0 +1,3 @@ +from orgwiki_core import ping + +__all__ = ["ping"] diff --git a/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/pyproject.toml b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/pyproject.toml new file mode 100644 index 000000000000..a8b6dae7d64d --- /dev/null +++ b/python/python-pyproject/testData/monorepo/PY-91089-uv-workspace-array-workspace/pyproject.toml @@ -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 } diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyProjectManager.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyProjectManager.kt index 16a86477c91a..c5d59994a7b3 100644 --- a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyProjectManager.kt +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyProjectManager.kt @@ -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("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("tool.uv.workspace", unquotedDottedKey = true).successOrNull ?: return null + val members = workspace.safeGet("members").successOrNull?.asMatchers ?: emptyList() + val exclude = workspace.safeGet("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("workspace").successOrNull == true) { workspaceDeps.add(ProjectName(depName)) depByNormalizedName.remove(normalizedKey) } else { - val path = table.getString("path") ?: continue + val path = table.safeGet("path").successOrNull ?: continue try { pathDeps.add(ownerRoot.resolve(path).normalize()) depByNormalizedName.remove(normalizedKey)