PY-79488 Expose more system-specific information in project graphs

In particular, for uv, retain the information about workspace membership.
Make discovery of nested workspace members more robust.

GitOrigin-RevId: 9af260bbb47baac9d5fec7e2b605931ceb0ce3c4
This commit is contained in:
Mikhail Golubev
2025-05-16 21:04:25 +03:00
committed by intellij-monorepo-bot
parent 4d3376bd64
commit 35c66600cc
20 changed files with 415 additions and 84 deletions

View File

@@ -19,10 +19,10 @@ import kotlin.reflect.KClass
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
abstract class BaseProjectModelService<E : EntitySource> {
abstract class BaseProjectModelService<E : EntitySource, P : ExternalProject> {
abstract val systemName: @NlsSafe String
abstract val projectModelResolver: PythonProjectModelResolver
abstract val projectModelResolver: PythonProjectModelResolver<P>
abstract fun getSettings(project: Project): ProjectModelSettings

View File

@@ -10,9 +10,15 @@ import org.apache.tuweni.toml.TomlTable
import java.nio.file.Path
import kotlin.io.path.*
data class PoetryProject(
override val name: String,
override val root: Path,
override val dependencies: List<ExternalProjectDependency>
) : ExternalProject
@OptIn(ExperimentalPathApi::class)
object PoetryProjectModelResolver : PythonProjectModelResolver {
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph? {
object PoetryProjectModelResolver : PythonProjectModelResolver<PoetryProject> {
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<PoetryProject>? {
if (!root.resolve(PoetryConstants.PYPROJECT_TOML).exists()) {
return null
}
@@ -23,7 +29,7 @@ object PoetryProjectModelResolver : PythonProjectModelResolver {
if (poetryProjects.isNotEmpty()) {
val modules = poetryProjects
.map {
ExternalProject(
PoetryProject(
name = it.projectName,
root = it.root,
dependencies = it.editablePathDependencies.map { entry ->

View File

@@ -17,8 +17,8 @@ import kotlin.reflect.KClass
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
object PoetryProjectModelService : BaseProjectModelService<PoetryEntitySource>() {
override val projectModelResolver: PythonProjectModelResolver
object PoetryProjectModelService : BaseProjectModelService<PoetryEntitySource, PoetryProject>() {
override val projectModelResolver: PythonProjectModelResolver<PoetryProject>
get() = PoetryProjectModelResolver
override val systemName: @NlsSafe String

View File

@@ -12,17 +12,21 @@ import kotlin.io.path.visitFileTree
* These modules might depend on each other, but it's not a requirement.
* The root itself can be a valid module root, but it's not a requirement.
*/
data class ExternalProjectGraph(val root: Path, val projects: List<ExternalProject>)
data class ExternalProjectGraph<P : ExternalProject>(val root: Path, val projects: List<P>)
/**
* Defines a project module in a particular directory with its unique name, and a set of module dependencies
* (usually editable Python path dependencies to other modules in the same IJ project).
*/
data class ExternalProject(val name: String, val root: Path, val dependencies: List<ExternalProjectDependency>)
interface ExternalProject {
val name: String
val root: Path
val dependencies: List<ExternalProjectDependency>
}
data class ExternalProjectDependency(val name: String, val path: Path)
interface PythonProjectModelResolver {
interface PythonProjectModelResolver<P : ExternalProject> {
/**
* If the `root` directory is considered a project root in a particular project management system
* (e.g. it contains pyproject.toml or other such marker files), traverse it and return a subgraph describing modules
@@ -41,7 +45,7 @@ interface PythonProjectModelResolver {
* this method should return `null` for `libs/` but module graphs containing *only* modules `project1` and `project2`
* for the directories `project1/` and `project2` respectively, even if there is a dependency between them.
*/
fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph?
fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<P>?
/**
* Find all project model graphs within the given directory (presumably the root directory of an IJ project).
@@ -56,13 +60,13 @@ interface PythonProjectModelResolver {
* project2/
* pyproject.toml
* ```
* If `project1` depends on `project2` (or vice-versa), this methods should return a single graph with its
* If `project1` depends on `project2` (or vice-versa), this methods should return a single graph with its
* root in `libs/` containing modules for both `project1` and `project2`.
* If these two projects are independents, there will be two graphs for `project1` and `project2` respectively.
*/
@OptIn(ExperimentalPathApi::class)
fun discoverIndependentProjectGraphs(root: Path): List<ExternalProjectGraph> {
val graphs = mutableListOf<ExternalProjectGraph>()
fun discoverIndependentProjectGraphs(root: Path): List<ExternalProjectGraph<P>> {
val graphs = mutableListOf<ExternalProjectGraph<P>>()
root.visitFileTree {
onPreVisitDirectory { dir, _ ->
val buildSystemRoot = discoverProjectRootSubgraph(dir)
@@ -91,7 +95,7 @@ interface ProjectModelSyncListener {
fun onFinish(projectRoot: Path): Unit = Unit
}
private fun mergeRootsReferringToEachOther(roots: MutableList<ExternalProjectGraph>): List<ExternalProjectGraph> {
private fun <P : ExternalProject> mergeRootsReferringToEachOther(roots: List<ExternalProjectGraph<P>>): List<ExternalProjectGraph<P>> {
fun commonAncestorPath(paths: Iterable<Path>): Path {
val normalized = paths.map { it.normalize() }
return normalized.reduce { p1, p2 -> FileUtil.findAncestor(p1, p2)!! }
@@ -106,7 +110,7 @@ private fun mergeRootsReferringToEachOther(roots: MutableList<ExternalProjectGra
}
val expandedProjectRootsByRootPath = expandedProjectRoots.sortedBy { it.root }
val mergedProjectRoots = mutableListOf<ExternalProjectGraph>()
val mergedProjectRoots = mutableListOf<ExternalProjectGraph<P>>()
for (root in expandedProjectRootsByRootPath) {
if (mergedProjectRoots.isEmpty()) {
mergedProjectRoots.add(root)

View File

@@ -8,81 +8,139 @@ import com.jetbrains.python.projectModel.ExternalProjectGraph
import com.jetbrains.python.projectModel.PythonProjectModelResolver
import org.apache.tuweni.toml.Toml
import org.apache.tuweni.toml.TomlTable
import java.nio.file.FileVisitResult
import java.nio.file.Path
import java.nio.file.PathMatcher
import kotlin.io.path.*
private const val DEFAULT_VENV_DIR = ".venv"
data class UvProject(
override val name: String,
override val root: Path,
override val dependencies: List<ExternalProjectDependency>,
val isWorkspace: Boolean,
val parentWorkspace: UvProject?,
) : ExternalProject
private data class UvPyProjectToml(
val projectName: String,
val root: Path,
val workspaceDependencies: List<String>,
val pathDependencies: Map<String, Path>,
val workspaceMemberPathMatchers: List<PathMatcher>,
val workspaceExcludePathMatchers: List<PathMatcher>,
) {
val isWorkspace = workspaceMemberPathMatchers.isNotEmpty()
}
@OptIn(ExperimentalPathApi::class)
object UvProjectModelResolver : PythonProjectModelResolver {
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph? {
object UvProjectModelResolver : PythonProjectModelResolver<UvProject> {
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<UvProject>? {
if (!root.resolve(UvConstants.PYPROJECT_TOML).exists()) {
return null
}
val rootUvProject = readUvPyProjectToml(root / "pyproject.toml")
if (rootUvProject == null) {
return null
}
val workspaceMemberMatchers = rootUvProject.workspaceMemberGlobs.map { getPathMatcher(it) }
val workspaceExcludeMatchers = rootUvProject.workspaceExcludeGlobs.map { getPathMatcher(it) }
// TODO check if we can speed up traversal by not traversing further into workspace member directories
// Can workspace members contain editable path dependencies inside?
val allUvProjects: List<UvPyProjectToml> = root
.walk()
// TODO skip excluded project directories
.filterNot { it.startsWith(root / DEFAULT_VENV_DIR) }
.filter { it.name == UvConstants.PYPROJECT_TOML }
.mapNotNull(::readUvPyProjectToml)
.toList()
val workspaceMembers: Map<String, UvPyProjectToml>
if (workspaceMemberMatchers.isEmpty()) {
workspaceMembers = emptyMap()
}
else {
workspaceMembers = allUvProjects
.filter { uvProject ->
if (uvProject == rootUvProject) return@filter true
val relProjectPath = uvProject.root.relativeTo(root)
return@filter workspaceExcludeMatchers.none { it.matches(relProjectPath) }
&& workspaceMemberMatchers.any { it.matches(relProjectPath) }
}.associateBy { it.projectName }
}
return ExternalProjectGraph(
root = root,
projects = allUvProjects
.map { uvProject ->
val pathDependencies = uvProject.editablePathDependencies.map { ExternalProjectDependency(it.key, it.value) }
val resolvedWorkspaceDependencies = uvProject.workspaceDependencies.mapNotNull {
val workspaceMember = workspaceMembers[it]
if (workspaceMember != null) ExternalProjectDependency(it, workspaceMember.root)
else null
}
ExternalProject(
name = uvProject.projectName,
root =uvProject.root,
dependencies = pathDependencies + resolvedWorkspaceDependencies
)
val workspaceMembers = mutableMapOf<UvPyProjectToml, MutableMap<String, UvPyProjectToml>>()
val standaloneProjects = mutableListOf<UvPyProjectToml>()
val workspaceStack = ArrayDeque<UvPyProjectToml>()
root.visitFileTree {
onPreVisitDirectory { dir, _ ->
if (dir.name == DEFAULT_VENV_DIR) {
return@onPreVisitDirectory FileVisitResult.SKIP_SUBTREE
}
)
val projectToml = readUvPyProjectToml(dir / "pyproject.toml")
if (projectToml == null) {
return@onPreVisitDirectory FileVisitResult.CONTINUE
}
if (projectToml.isWorkspace) {
workspaceStack.add(projectToml)
workspaceMembers.put(projectToml, mutableMapOf())
return@onPreVisitDirectory FileVisitResult.CONTINUE
}
if (workspaceStack.isNotEmpty()) {
val closestWorkspace = workspaceStack.last()
val relProjectPath = projectToml.root.relativeTo(closestWorkspace.root)
val isWorkspaceMember = closestWorkspace.workspaceExcludePathMatchers.none { it.matches(relProjectPath) } &&
closestWorkspace.workspaceMemberPathMatchers.any { it.matches(relProjectPath) }
if (isWorkspaceMember) {
workspaceMembers[closestWorkspace]!!.put(projectToml.projectName, projectToml)
return@onPreVisitDirectory FileVisitResult.CONTINUE
}
}
standaloneProjects.add(projectToml)
return@onPreVisitDirectory FileVisitResult.CONTINUE
}
onPostVisitDirectory { dir, _ ->
if (workspaceStack.lastOrNull()?.root == dir) {
workspaceStack.removeLast()
}
FileVisitResult.CONTINUE
}
}
val allUvProjects = mutableListOf<UvProject>()
standaloneProjects.mapTo(allUvProjects) {
UvProject(
name = it.projectName,
root = it.root,
dependencies = it.pathDependencies.map { dep -> ExternalProjectDependency(name = dep.key, path = dep.value) },
isWorkspace = false,
parentWorkspace = null,
)
}
for ((wsRootToml, wsMembersByNames) in workspaceMembers) {
fun resolved(wsDependencies: List<String>): Map<String, Path> {
return wsDependencies.mapNotNull { name -> wsMembersByNames[name]?.let { name to it.root } }.toMap()
}
val wsRootProject = UvProject(
name = wsRootToml.projectName,
root = wsRootToml.root,
dependencies = (wsRootToml.pathDependencies + resolved(wsRootToml.workspaceDependencies))
.map { ExternalProjectDependency(name = it.key, path = it.value) },
isWorkspace = true,
parentWorkspace = null,
)
allUvProjects.add(wsRootProject)
for ((_, wsMemberToml) in wsMembersByNames) {
allUvProjects.add(UvProject(
name = wsMemberToml.projectName,
root = wsMemberToml.root,
dependencies = (wsMemberToml.pathDependencies + resolved(wsMemberToml.workspaceDependencies))
.map { ExternalProjectDependency(name = it.key, path = it.value) },
isWorkspace = false,
parentWorkspace = wsRootProject,
))
}
}
return ExternalProjectGraph(root = root, projects = allUvProjects)
}
private fun readUvPyProjectToml(pyprojectTomlPath: Path): UvPyProjectToml? {
if (!(pyprojectTomlPath.exists())) {
return null
}
val pyprojectToml = Toml.parse(pyprojectTomlPath)
val projectName = pyprojectToml.getString("project.name")
if (projectName == null) {
return null
}
val workspaceTable = pyprojectToml.getTable("tool.uv.workspace")
val includeGlobs = mutableListOf<String>()
val excludeGlobs = mutableListOf<String>()
val includeGlobs: List<PathMatcher>
val excludeGlobs: List<PathMatcher>
if (workspaceTable != null) {
workspaceTable.getArrayOrEmpty("members")
includeGlobs = workspaceTable.getArrayOrEmpty("members")
.toList()
.mapNotNullTo(includeGlobs) { it as? String }
workspaceTable.getArrayOrEmpty("exclude")
.filterIsInstance<String>()
.map { getPathMatcher(it) }
excludeGlobs = workspaceTable.getArrayOrEmpty("exclude")
.toList()
.mapNotNullTo(excludeGlobs) { it as? String }
.filterIsInstance<String>()
.map { getPathMatcher(it) }
}
else {
includeGlobs = emptyList()
excludeGlobs = emptyList()
}
val workspaceDependencies = mutableListOf<String>()
val editablePathDependencies = mutableMapOf<String, Path>()
@@ -95,7 +153,7 @@ object UvProjectModelResolver : PythonProjectModelResolver {
}
else if (depSpec.getBoolean("editable") == true) {
val depPath = depSpec.getString("path")?.let { pyprojectTomlPath.parent.resolve(it).normalize() }
if (depPath != null && depPath.isDirectory() && depPath.resolve(UvConstants.PYPROJECT_TOML).exists()) {
if (depPath != null && depPath.isDirectory() && (depPath / UvConstants.PYPROJECT_TOML).exists()) {
editablePathDependencies[depName] = depPath
}
}
@@ -105,18 +163,9 @@ object UvProjectModelResolver : PythonProjectModelResolver {
projectName = projectName,
root = pyprojectTomlPath.parent,
workspaceDependencies = workspaceDependencies,
editablePathDependencies = editablePathDependencies,
workspaceMemberGlobs = includeGlobs,
workspaceExcludeGlobs = excludeGlobs,
pathDependencies = editablePathDependencies,
workspaceMemberPathMatchers = includeGlobs,
workspaceExcludePathMatchers = excludeGlobs,
)
}
private data class UvPyProjectToml(
val projectName: String,
val root: Path,
val workspaceDependencies: List<String>,
val editablePathDependencies: Map<String, Path>,
val workspaceMemberGlobs: List<String>,
val workspaceExcludeGlobs: List<String>,
)
}

View File

@@ -17,8 +17,8 @@ import kotlin.reflect.KClass
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
object UvProjectModelService : BaseProjectModelService<UvEntitySource>() {
override val projectModelResolver: PythonProjectModelResolver
object UvProjectModelService : BaseProjectModelService<UvEntitySource, UvProject>() {
override val projectModelResolver: PythonProjectModelResolver<UvProject>
get() = UvProjectModelResolver
override val systemName: @NlsSafe String

View File

@@ -0,0 +1,7 @@
[project]
name = "root-ws-member"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,12 @@
[project]
name = "ws-root"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []
[tool.uv.workspace]
members = [
"dir/subdir/root-ws-member",
]

View File

@@ -0,0 +1,12 @@
[project]
name = "intermediate-non-ws"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []
[tool.uv.workspace]
members = [
# "root-ws-member",
]

View File

@@ -0,0 +1,7 @@
[project]
name = "root-ws-member"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,13 @@
[project]
name = "root-ws"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []
[tool.uv.workspace]
members = [
"root-ws-direct-member",
"intermediate-non-ws/root-ws-member",
]

View File

@@ -0,0 +1,7 @@
[project]
name = "root-ws-direct-member"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,7 @@
[project]
name = "nested-ws-member"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,12 @@
[project]
name = "nested-ws"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []
[tool.uv.workspace]
members = [
"nested-ws-member",
]

View File

@@ -0,0 +1,17 @@
[project]
name = "root-ws"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = [
"nested-ws-member",
]
[tool.uv.workspace]
members = [
"root-ws-member",
]
[tool.uv.sources]
nested-ws-member = { workspace = true }

View File

@@ -0,0 +1,7 @@
[project]
name = "root-ws-member"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,7 @@
[project]
name = "project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,12 @@
[project]
name = "ws-root"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []
[tool.uv.workspace]
members = [
"ws-member",
]

View File

@@ -0,0 +1,7 @@
[project]
name = "ws-member"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10.5"
dependencies = []

View File

@@ -0,0 +1,145 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.projectModel.uv
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.intellij.util.io.copyRecursively
import com.jetbrains.python.PythonTestUtil.getTestDataPath
import com.jetbrains.python.projectModel.ExternalProjectGraph
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import org.junit.jupiter.api.assertNotNull
import java.nio.file.Path
import java.nio.file.Paths.get
import kotlin.io.path.div
@TestApplication
class UvProjectModelResolverTest {
private val testRoot by tempPathFixture()
private lateinit var testInfo: TestInfo
@BeforeEach
fun setUp(testInfo: TestInfo) {
this.testInfo = testInfo
}
@Test
fun nestedWorkspaces() {
val ijProjectRoot = testRoot / "project"
testDataDir.copyRecursively(ijProjectRoot)
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "root-ws")
assertNotNull(graph)
assertEquals(4, graph.projects.size)
val rootWs = graph.project("root-ws")
assertNotNull(rootWs)
assertTrue(rootWs.isWorkspace)
assertEquals(ijProjectRoot / "root-ws", rootWs.root)
val rootWsMember = graph.project("root-ws-member")
assertNotNull(rootWsMember)
assertFalse(rootWsMember.isWorkspace)
assertEquals(ijProjectRoot / "root-ws" / "root-ws-member", rootWsMember.root)
assertEquals(rootWs, rootWsMember.parentWorkspace)
val nestedWs = graph.project("nested-ws")
assertNotNull(nestedWs)
assertTrue(nestedWs.isWorkspace)
assertEquals(ijProjectRoot / "root-ws" / "nested-ws", nestedWs.root)
val nestedWsMember = graph.project("nested-ws-member")
assertNotNull(nestedWsMember)
assertFalse(nestedWsMember.isWorkspace)
assertEquals(ijProjectRoot / "root-ws" / "nested-ws" / "nested-ws-member", nestedWsMember.root)
assertEquals(nestedWs, nestedWsMember.parentWorkspace)
}
@Test
fun workspaceInsideStandalone() {
val ijProjectRoot = testRoot / "project"
testDataDir.copyRecursively(ijProjectRoot)
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "project")
assertNotNull(graph)
assertEquals(3, graph.projects.size)
val standaloneProject = graph.project("project")
assertNotNull(standaloneProject)
assertFalse(standaloneProject.isWorkspace)
assertEquals(ijProjectRoot / "project" , standaloneProject.root)
val wsRoot = graph.project("ws-root")
assertNotNull(wsRoot)
assertTrue(wsRoot.isWorkspace)
assertEquals(ijProjectRoot / "project" / "ws-root", wsRoot.root)
val wsMember = graph.project("ws-member")
assertNotNull(wsMember)
assertFalse(wsMember.isWorkspace)
assertEquals(ijProjectRoot / "project" / "ws-root" / "ws-member", wsMember.root)
assertEquals(wsRoot, wsMember.parentWorkspace)
}
@Test
fun intermediateNonWorkspaceProjects() {
val ijProjectRoot = testRoot / "project"
testDataDir.copyRecursively(ijProjectRoot)
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "root-ws")
assertNotNull(graph)
assertEquals(4, graph.projects.size)
val wsRoot = graph.project("root-ws")
assertNotNull(wsRoot)
assertTrue(wsRoot.isWorkspace)
assertEquals(ijProjectRoot / "root-ws", wsRoot.root)
val intermediateNonWsProject = graph.project("intermediate-non-ws")
assertNotNull(intermediateNonWsProject)
assertFalse(intermediateNonWsProject.isWorkspace)
assertNull(intermediateNonWsProject.parentWorkspace)
assertEquals(ijProjectRoot / "root-ws" / "intermediate-non-ws", intermediateNonWsProject.root)
val nestedWsMember = graph.project("root-ws-member")
assertNotNull(nestedWsMember)
assertFalse(nestedWsMember.isWorkspace)
assertEquals(wsRoot, nestedWsMember.parentWorkspace)
assertEquals(ijProjectRoot / "root-ws" / "intermediate-non-ws" / "root-ws-member", nestedWsMember.root)
val directWsMember = graph.project("root-ws-direct-member")
assertNotNull(directWsMember)
assertFalse(directWsMember.isWorkspace)
assertEquals(wsRoot, directWsMember.parentWorkspace)
assertEquals(ijProjectRoot / "root-ws" / "root-ws-direct-member", directWsMember.root)
}
@Test
fun intermediateNonProjectDirs() {
val ijProjectRoot = testRoot / "project"
testDataDir.copyRecursively(ijProjectRoot)
val graph = UvProjectModelResolver.discoverProjectRootSubgraph(ijProjectRoot / "ws-root")
assertNotNull(graph)
assertEquals(2, graph.projects.size)
val wsRoot = graph.project("ws-root")
assertNotNull(wsRoot)
assertTrue(wsRoot.isWorkspace)
assertEquals(ijProjectRoot / "ws-root", wsRoot.root)
val wsMember = graph.project("root-ws-member")
assertNotNull(wsMember)
assertFalse(wsMember.isWorkspace)
assertEquals(wsRoot, wsMember.parentWorkspace)
assertEquals(ijProjectRoot / "ws-root" / "dir" / "subdir" / "root-ws-member", wsMember.root)
}
private val testDataDir: Path
get() = get(getTestDataPath()) / "projectModel" / testInfo.testMethod.get().name
private fun ExternalProjectGraph<UvProject>.project(name: String): UvProject? = projects.firstOrNull { it.name == name }
}