diff --git a/python/python-hatch/src/com/intellij/python/hatch/impl/HatchPyProjectManager.kt b/python/python-hatch/src/com/intellij/python/hatch/impl/HatchPyProjectManager.kt index 4bfcbf88f16e..3da61fe78240 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/impl/HatchPyProjectManager.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/impl/HatchPyProjectManager.kt @@ -32,5 +32,7 @@ internal class HatchPyProjectManager : PyProjectManager { rootIndex: Map, ): ProjectStructureInfo? = null - override fun getTomlDependencySpecifications(): List = emptyList() + override fun getTomlDependencySpecifications(): List = listOf( + TomlDependencySpecification.GroupPep621Dependency("tool.hatch.envs", "dependencies"), + ) } 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..80df6d46845d 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 @@ -150,8 +151,8 @@ private fun processDependenciesWithRootIndex(dependencies: Sequence, private fun collectAllDependencies( entry: PyProjectTomlProject, tomlDependencySpecifications: List, ): Sequence = 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(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(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 { + val deps = tomlTable.safeGetArr(tomlKeyToDependencies, unquotedDottedKey = true).successOrNull ?: return emptySet() + return deps.asSequence().mapNotNull { parsePep621Dependency(root, it) }.toSet() +} + @RequiresBackgroundThread private fun getToolSpecificDependenciesFromTomlTable(root: Path, tomlTable: TomlTable): Set { return tomlTable.keySet().asSequence().mapNotNull { @@ -188,24 +198,45 @@ private fun getToolSpecificDependenciesFromTomlTable(root: Path, tomlTable: Toml } @RequiresBackgroundThread -private fun getDependenciesFromPep735Groups(tomlTable: PyProjectToml): Sequence = - tomlTable.project.dependencies.allDepsFromGroups.asSequence().mapNotNull(::parsePep621Dependency) +private fun getDependenciesFromPep735Groups(root: Path, tomlTable: PyProjectToml): Sequence = + tomlTable.project.dependencies.allDepsFromGroups.asSequence().mapNotNull { parsePep621Dependency(root, it) } @RequiresBackgroundThread -private fun getDependenciesFromProject(projectToml: PyProjectToml): Sequence { +private fun getDependenciesFromProject(root: Path, projectToml: PyProjectToml): Sequence { 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 { diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/TomlDependencySpecification.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/TomlDependencySpecification.kt index 9f5b494b87e3..7ae98aafd13a 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/TomlDependencySpecification.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/TomlDependencySpecification.kt @@ -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 -} \ No newline at end of file + data class GroupPep621Dependency(val tomlKeyToGroup: String, val tomlKeyFromGroupToDependencies: String) : TomlDependencySpecification +} diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/GetDependenciesFromTomlTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/GetDependenciesFromTomlTest.kt index 774e9666828c..a7d5457951e4 100644 --- a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/GetDependenciesFromTomlTest.kt +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/GetDependenciesFromTomlTest.kt @@ -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() diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/HatchWorkspacePathDependencyTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/HatchWorkspacePathDependencyTest.kt index 3d589d9049de..2e645b43bdc2 100644 --- a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/HatchWorkspacePathDependencyTest.kt +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/alsoWin/pyproject/model/testplan/HatchWorkspacePathDependencyTest.kt @@ -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 { - 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")), + ) } }