PY-90798 Support Hatch context-formatted path dependencies

Resolve {root[:parent...]:uri} references relative to the owning
pyproject.toml to properly resolve dependencies.

ALso, support Hatch-specific grouped dependency arrays
(tool.hatch.envs.*.dependencies)

GitOrigin-RevId: 95da13cf9f4c023cd16681b489cffd94294b56b8
This commit is contained in:
Alexey Katsman
2026-07-29 09:51:51 +00:00
committed by intellij-monorepo-bot
parent 062627ad15
commit 95b9bde0e9
5 changed files with 120 additions and 26 deletions
@@ -32,5 +32,7 @@ internal class HatchPyProjectManager : PyProjectManager {
rootIndex: Map<Directory, ProjectName>,
): ProjectStructureInfo? = null
override fun getTomlDependencySpecifications(): List<TomlDependencySpecification> = emptyList()
override fun getTomlDependencySpecifications(): List<TomlDependencySpecification> = listOf(
TomlDependencySpecification.GroupPep621Dependency("tool.hatch.envs", "dependencies"),
)
}
@@ -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
@@ -150,8 +151,8 @@ private fun processDependenciesWithRootIndex(dependencies: Sequence<Directory>,
private fun collectAllDependencies(
entry: PyProjectTomlProject, tomlDependencySpecifications: List<TomlDependencySpecification>,
): Sequence<Directory> = sequence {
yieldAll(getDependenciesFromProject(entry.pyProjectToml))
yieldAll(getDependenciesFromPep735Groups(entry.pyProjectToml))
yieldAll(getDependenciesFromProject(entry.root, entry.pyProjectToml))
yieldAll(getDependenciesFromPep735Groups(entry.root, entry.pyProjectToml))
yieldAll(getToolSpecificDependencies(entry.root, entry.pyProjectToml.toml, tomlDependencySpecifications))
}
@@ -164,10 +165,7 @@ private fun getToolSpecificDependencies(
is TomlDependencySpecification.PathDependency -> tomlTable.getTable(specification.tomlKey)?.let {
getToolSpecificDependenciesFromTomlTable(root, it)
} ?: emptySet()
is TomlDependencySpecification.Pep621Dependency -> {
val deps = tomlTable.safeGetArr<String>(specification.tomlKey, unquotedDottedKey = true).successOrNull ?: emptyList()
deps.asSequence().mapNotNull(::parsePep621Dependency).toSet()
}
is TomlDependencySpecification.Pep621Dependency -> getPep621Dependencies(root, tomlTable, specification.tomlKey).toSet()
is TomlDependencySpecification.GroupPathDependency -> {
val groups = tomlTable.getTable(specification.tomlKeyToGroup) ?: return@flatMap emptySet()
groups.keySet().flatMap { group ->
@@ -176,10 +174,22 @@ private fun getToolSpecificDependencies(
} ?: emptySet()
}
}
is TomlDependencySpecification.GroupPep621Dependency -> {
val groups = tomlTable.safeGet<TomlTable>(specification.tomlKeyToGroup, unquotedDottedKey = true).successOrNull ?: return@flatMap emptySet()
groups.keySet().flatMap { group ->
getPep621Dependencies(root, groups, "${group}.${specification.tomlKeyFromGroupToDependencies}")
}
}
}
}
}
@RequiresBackgroundThread
private fun getPep621Dependencies(root: Path, tomlTable: TomlTable, tomlKeyToDependencies: String): Set<Path> {
val deps = tomlTable.safeGetArr<String>(tomlKeyToDependencies, unquotedDottedKey = true).successOrNull ?: return emptySet()
return deps.asSequence().mapNotNull { parsePep621Dependency(root, it) }.toSet()
}
@RequiresBackgroundThread
private fun getToolSpecificDependenciesFromTomlTable(root: Path, tomlTable: TomlTable): Set<Directory> {
return tomlTable.keySet().asSequence().mapNotNull {
@@ -188,24 +198,45 @@ private fun getToolSpecificDependenciesFromTomlTable(root: Path, tomlTable: Toml
}
@RequiresBackgroundThread
private fun getDependenciesFromPep735Groups(tomlTable: PyProjectToml): Sequence<Directory> =
tomlTable.project.dependencies.allDepsFromGroups.asSequence().mapNotNull(::parsePep621Dependency)
private fun getDependenciesFromPep735Groups(root: Path, tomlTable: PyProjectToml): Sequence<Directory> =
tomlTable.project.dependencies.allDepsFromGroups.asSequence().mapNotNull { parsePep621Dependency(root, it) }
@RequiresBackgroundThread
private fun getDependenciesFromProject(projectToml: PyProjectToml): Sequence<Directory> {
private fun getDependenciesFromProject(root: Path, projectToml: PyProjectToml): Sequence<Directory> {
val depsFromFile = projectToml.project.dependencies.project
return depsFromFile.asSequence().mapNotNull(::parsePep621Dependency)
return depsFromFile.asSequence().mapNotNull { parsePep621Dependency(root, it) }
}
private fun parsePep621Dependency(depSpec: String): Path? {
private fun parsePep621Dependency(root: Path, depSpec: String): Path? {
val match = PEP_621_PATH_DEPENDENCY.matchEntire(depSpec) ?: return null
val (_, depUri) = match.destructured
return parseDepUri(depUri)
val (_, directReference) = match.destructured
return when {
directReference.startsWith("file:") -> parseDepUri(directReference)
else -> parseHatchContextFormattedPath(root, directReference)
}
}
// e.g. "lib @ file:///home/user/projects/main/lib"
private val PEP_621_PATH_DEPENDENCY = """([\w-]+) @ (file:.*)""".toRegex()
private val PEP_621_PATH_DEPENDENCY = """([\w-]+) @ (.*)""".toRegex()
// e.g. "{root:parent:uri}/lib"
private val HATCH_ROOT_URI = """\{root((?::parent)*):uri}(/.*)?""".toRegex()
private fun parseHatchContextFormattedPath(root: Path, directReference: String): Path? {
val match = HATCH_ROOT_URI.matchEntire(directReference) ?: return null
val (parentModifiers, relativePath) = match.destructured
val parentCount = parentModifiers.split(':').count { it == "parent" }
val formattedRoot = root.nthParent(parentCount) ?: return null
return parseDepFromPathString(formattedRoot, relativePath.removePrefix("/"))
}
private fun Path.nthParent(count: Int): Path? {
var current: Path? = this
for (i in 0 until count) {
current = current?.parent ?: return null
}
return current
}
private fun parseDepUri(depUri: String): Path? =
try {
@@ -4,4 +4,5 @@ sealed interface TomlDependencySpecification {
data class PathDependency(val tomlKey: String) : TomlDependencySpecification
data class Pep621Dependency(val tomlKey: String) : TomlDependencySpecification
data class GroupPathDependency(val tomlKeyToGroup: String, val tomlKeyFromGroupToPath: String) : TomlDependencySpecification
}
data class GroupPep621Dependency(val tomlKeyToGroup: String, val tomlKeyFromGroupToDependencies: String) : TomlDependencySpecification
}
@@ -1,5 +1,6 @@
package com.intellij.python.junit5Tests.unit.alsoWin.pyproject
import com.intellij.idea.TestFor
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.model.internal.pyProjectToml.getDependenciesFromToml
import com.intellij.python.pyproject.model.spi.ProjectName
@@ -42,6 +43,38 @@ internal class GetDependenciesFromTomlTest {
assertThat(result.map[mainName]).containsExactly(libName)
}
@Test
@TestFor(issues = ["PY-90798"])
fun `hatch context formatted project dependencies`(@TempDir tempDir: Path): Unit = timeoutRunBlocking {
val workspaceDir = tempDir.resolve("workspace")
val packagesDir = workspaceDir.resolve("packages")
val mainDir = packagesDir.resolve("main").createDirectories()
val childDir = mainDir.resolve("child").createDirectories()
val siblingDir = packagesDir.resolve("sibling").createDirectories()
val sharedDir = workspaceDir.resolve("shared").createDirectories()
val toml = PyProjectToml.parse("""
[project]
name = "main"
version = "1.0"
dependencies = [
"child @ {root:uri}/child",
"sibling @ {root:parent:uri}/sibling",
"shared @ {root:parent:parent:uri}/shared",
]
""".trimIndent())!!
val mainName = ProjectName("main")
val childName = ProjectName("child")
val siblingName = ProjectName("sibling")
val sharedName = ProjectName("shared")
val entries = mapOf(mainName to TestProject(toml, mainDir))
val rootIndex = mapOf(childDir to childName, siblingDir to siblingName, sharedDir to sharedName)
val result = getDependenciesFromToml(entries, rootIndex, emptyList())
assertThat(result.map[mainName]).containsExactlyInAnyOrder(childName, siblingName, sharedName)
}
@Test
fun `pep 735 dependency groups`(@TempDir tempDir: Path): Unit = timeoutRunBlocking {
val mainDir = tempDir.resolve("main").createDirectories()
@@ -147,6 +180,35 @@ internal class GetDependenciesFromTomlTest {
assertThat(result.map[mainName]).containsExactly(libName)
}
@Test
@TestFor(issues = ["PY-90798"])
fun `tool GroupPep621Dependency`(@TempDir tempDir: Path): Unit = timeoutRunBlocking {
val workspaceDir = tempDir.resolve("workspace")
val mainDir = workspaceDir.resolve("main").createDirectories()
val libDir = workspaceDir.resolve("lib").createDirectories()
val toml = PyProjectToml.parse("""
[project]
name = "main"
version = "1.0"
[tool.hatch.envs.default]
dependencies = [
"pytest>=8",
"lib @ {root:parent:uri}/lib",
]
""".trimIndent())!!
val mainName = ProjectName("main")
val libName = ProjectName("lib")
val entries = mapOf(mainName to TestProject(toml, mainDir))
val rootIndex = mapOf(libDir to libName)
val specs = listOf(TomlDependencySpecification.GroupPep621Dependency("tool.hatch.envs", "dependencies"))
val result = getDependenciesFromToml(entries, rootIndex, specs)
assertThat(result.map[mainName]).containsExactly(libName)
}
@Test
fun `non-path dependencies are ignored`(@TempDir tempDir: Path): Unit = timeoutRunBlocking {
val mainDir = tempDir.resolve("main").createDirectories()
@@ -1,8 +1,10 @@
// 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.idea.TestFor
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
@@ -10,7 +12,6 @@ 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
import org.junit.jupiter.api.assertThrows
@PyDefaultTestApplication
@TestClassInfo(contentRootPath ="python-pyproject/test")
@@ -23,16 +24,13 @@ internal class HatchWorkspacePathDependencyTest {
private val f by pyProjectTomlSyncFixture(projectFixture, tempDirFixture)
@Test
@TestFor(issues = ["PY-90798"])
fun sanity(): Unit = timeoutRunBlocking {
f.reloadProject()
// PY-87339 Hatch monorepo local dependencies are not show in settings
// PY-86924 Hatch workspace members are not handled correctly
assertThrows<AssertionError> {
f.assertProjectStructure(
ExpectedModule("my-hatch-monorepo", contentRoot = ".", deps = listOf("package_a", "package_b")),
ExpectedModule("package_a", contentRoot = "packages/package_a"),
ExpectedModule("package_b", contentRoot = "packages/package_b", deps = listOf("package_a")),
)
}
f.assertProjectStructure(
ExpectedModule("my-hatch-monorepo", contentRoot = ".", deps = listOf("package_a", "package_b"), sourceRoots = listOf(".")),
ExpectedModule("package_a", contentRoot = "packages" / "package_a"),
ExpectedModule("package_b", contentRoot = "packages" / "package_b", deps = listOf("package_a")),
)
}
}