PY-91195 uv workspace: per-dependency [tool.uv.sources] arrays (multiple sources by marker) are not resolved as module dependencies

Merge-request: IJ-MR-215299
Merged-by: Ilya Kazakevich <ilya.kazakevich@jetbrains.com>

GitOrigin-RevId: 91cd1b8caeb075e0802acef3b48cc022062afd0e
This commit is contained in:
Ivan Kulikov
2026-07-30 13:12:55 +00:00
committed by intellij-monorepo-bot
parent 51ef102a77
commit 689fa89daa
8 changed files with 125 additions and 13 deletions
@@ -0,0 +1,39 @@
// 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-91195 follow-up: each `[tool.uv.sources]` entry is an array of tables (multiple sources selected
* by platform marker — a valid uv feature), and the workspace is declared with the inline-table form
* `[tool.uv] workspace = { members = [...] }`. All members must be discovered as modules.
*/
@PyDefaultTestApplication
@TestClassInfo(contentRootPath = "python-pyproject/test")
@TestDataPath($$"$CONTENT_ROOT/../testData/monorepo/PY-91195-uv-sources-marker-array")
internal class UvSourcesMarkerArrayTest {
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("pythonproject", contentRoot = ".", deps = listOf("sub-project-a", "sub-project-b"), sourceRoots = listOf(".")),
ExpectedModule("sub-project-a", contentRoot = "sub-projects" / "sub-project-a", sourceRoots = listOf("sub-projects" / "sub-project-a" / "src")),
ExpectedModule("sub-project-b", contentRoot = "sub-projects" / "sub-project-b", sourceRoots = listOf("sub-projects" / "sub-project-b" / "src")),
)
}
}
@@ -0,0 +1,5 @@
# pythonproject
PY-91195 follow-up: a uv workspace where each `[tool.uv.sources]` entry is an array of tables
(multiple sources selected by platform marker — a valid uv feature). The members must still be
discovered as modules.
@@ -0,0 +1,22 @@
[project]
name = "pythonproject"
version = "0.1.0"
description = "Root project"
requires-python = ">=3.12"
dependencies = [
"sub-project-a",
"sub-project-b",
]
[tool.uv]
workspace = { members = ["sub-projects/*"] }
[tool.uv.sources]
sub-project-a = [
{ workspace = true, marker = "sys_platform == 'linux'" },
{ workspace = true, marker = "sys_platform == 'win32'" },
]
sub-project-b = [
{ workspace = true, marker = "sys_platform == 'linux'" },
{ workspace = true, marker = "sys_platform == 'win32'" },
]
@@ -0,0 +1,14 @@
[project]
name = "sub-project-a"
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/sub_project_a"]
@@ -0,0 +1,14 @@
[project]
name = "sub-project-b"
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/sub_project_b"]
@@ -203,26 +203,40 @@ private fun getUvDependencies(
val workspaceDeps = mutableListOf<ProjectName>()
val pathDeps = hashSetOf<Path>()
for ((sourcesTable, ownerRoot) in sourcesTablesWithRoots) {
for ((sourceKey, depTable) in sourcesTable.toMap().entries) {
for ((sourceKey, sourceValue) in sourcesTable.toMap().entries) {
val normalizedKey = PyPackageName.normalizeProjectName(sourceKey)
val depName = depByNormalizedName[normalizedKey] ?: continue
val table = depTable as? TomlTable ?: continue
// 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)
// A source entry is either a single table or an array of tables (multiple sources selected by
// platform marker — a valid uv feature); resolve every table it contains (PY-91195).
val depTables = when (sourceValue) {
is TomlTable -> listOf(sourceValue)
is TomlArray -> sourceValue.toList().filterIsInstance<TomlTable>()
else -> continue
}
else {
val path = table.safeGet<String>("path").successOrNull ?: continue
try {
pathDeps.add(ownerRoot.resolve(path).normalize())
depByNormalizedName.remove(normalizedKey)
var resolved = false
for (table in depTables) {
// 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))
resolved = true
}
catch (e: InvalidPathException) {
logger.info("Can't resolve $path against $ownerRoot", e)
else {
val path = table.safeGet<String>("path").successOrNull ?: continue
try {
pathDeps.add(ownerRoot.resolve(path).normalize())
resolved = true
}
catch (e: InvalidPathException) {
logger.info("Can't resolve $path against $ownerRoot", e)
}
}
}
// Once resolved by a higher-priority table, don't let parent workspace tables override it.
if (resolved) {
depByNormalizedName.remove(normalizedKey)
}
}
}
return DependencyInfo(workspaceDeps = workspaceDeps.toSet(), pathDeps = pathDeps)