PY-82580: pyproject.toml refactoring to combine all pyproject.toml EPs to single API

The main idea is to decouple tool implementation from low-level platform APIs: Open project processor, workspace model e.t.c.
Implementing `spi.Tool` should be enough to introduce new tool.

GitOrigin-RevId: 1177b57fc0eee1ca2c88cac5b20618a6170bf521
This commit is contained in:
Ilya.Kazakevich
2025-09-29 06:39:56 +02:00
committed by intellij-monorepo-bot
parent fab7ea8eb3
commit 2888d207d9
75 changed files with 1276 additions and 2602 deletions

View File

@@ -9348,13 +9348,6 @@ jvm_import(
source_jar = "@org_antlr-antlr4-runtime-4_13_0_http//file"
)
java_library(
name = "tuweni-toml-provided",
exports = [":tuweni-toml"],
neverlink = True,
visibility = ["//visibility:public"]
)
java_library(
name = "vavr",
exports = [

View File

@@ -302,7 +302,6 @@ jvm_library(
"@lib//:jna",
"//plugins/toml",
"//plugins/toml/core",
"@lib//:tuweni-toml-provided",
"@lib//:jsr305",
"@lib//:jetbrains-annotations",
"@lib//:kotlin-stdlib",

View File

@@ -61,7 +61,6 @@ jvm_library(
"//python/python-pyproject:pyproject",
"//python/python-hatch:hatch",
"@lib//:io-github-z4kn4fein-semver-jvm-provided",
"@lib//:tuweni-toml-provided",
"//platform/non-modal-welcome-screen",
],
runtime_deps = ["//python/python-features-trainer:featuresTrainer"],
@@ -128,7 +127,6 @@ jvm_library(
"//platform/testFramework/junit5:junit5_test_lib",
"@lib//:junit5",
"@lib//:io-github-z4kn4fein-semver-jvm-provided",
"@lib//:tuweni-toml-provided",
"//platform/non-modal-welcome-screen",
],
plugins = ["@lib//:compose-plugin"]

View File

@@ -76,7 +76,6 @@
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" scope="PROVIDED" name="io.github.z4kn4fein.semver.jvm" level="project" />
<orderEntry type="library" scope="PROVIDED" name="tuweni-toml" level="project" />
<orderEntry type="module" module-name="intellij.platform.ide.nonModalWelcomeScreen" />
</component>
</module>

View File

@@ -51,6 +51,7 @@ class PythonSdkConfigurator : DirectoryProjectConfigurator {
StartupManager.getInstance(project).runWhenProjectIsInitialized {
PyPackageCoroutine.launch(project) {
if (module.isDisposed) return@launch
val extension = findExtension(module)
val title = extension?.getIntention(module) ?: PySdkBundle.message("python.configuring.interpreter.progress")
withBackgroundProgress(project, title, true) {

View File

@@ -3,6 +3,7 @@ package com.intellij.pycharm.community.ide.impl.configuration
import com.intellij.openapi.application.EDT
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil
@@ -12,15 +13,16 @@ import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.util.progress.reportRawProgress
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.model.internal.projectModelEnabled
import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.poetry.findPoetryLock
import com.jetbrains.python.poetry.getPyProjectTomlForPoetry
import com.jetbrains.python.sdk.impl.resolvePythonBinary
import com.jetbrains.python.sdk.PythonSdkType
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
import com.jetbrains.python.sdk.impl.resolvePythonBinary
import com.jetbrains.python.sdk.poetry.PyPoetrySdkAdditionalData
import com.jetbrains.python.sdk.poetry.getPoetryExecutable
import com.jetbrains.python.sdk.poetry.setupPoetry
@@ -34,6 +36,10 @@ import kotlin.io.path.pathString
@ApiStatus.Internal
class PyPoetrySdkConfiguration : PyProjectSdkConfigurationExtension {
init {
if (projectModelEnabled) throw ExtensionNotApplicableException.create()
}
companion object {
private val LOGGER = Logger.getInstance(PyPoetrySdkConfiguration::class.java)
}

View File

@@ -4,17 +4,17 @@ package com.intellij.pycharm.community.ide.impl.configuration
import com.intellij.codeInspection.util.IntentionName
import com.intellij.openapi.application.EDT
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.vfs.readText
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.model.internal.projectModelEnabled
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrLogException
import com.jetbrains.python.onSuccess
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.uv.UvProjectModelService
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
@@ -33,6 +33,11 @@ private val logger = fileLogger()
@ApiStatus.Internal
class PyUvSdkConfiguration : PyProjectSdkConfigurationExtension {
init {
if (projectModelEnabled) throw ExtensionNotApplicableException.create()
}
override suspend fun getIntention(module: Module): @IntentionName String? {
val tomlFile = PyProjectToml.findFile(module) ?: return null
getUvExecutable() ?: return null
@@ -61,14 +66,7 @@ class PyUvSdkConfiguration : PyProjectSdkConfigurationExtension {
override fun supportsHeadlessModel(): Boolean = true
private suspend fun createUv(module: Module): PyResult<Sdk> {
val sdkAssociatedModule: Module
if (enablePyProjectToml) {
val uvWorkspace = UvProjectModelService.findWorkspace(module)
sdkAssociatedModule = uvWorkspace?.root ?: module
}
else {
sdkAssociatedModule = module
}
val sdkAssociatedModule: Module = module
val workingDir: Path? = tryResolvePath(sdkAssociatedModule.basePath)
if (workingDir == null) {
return PyResult.failure(MessageError("Can't determine working dir for the module"))

View File

@@ -121,7 +121,6 @@
<orderEntry type="library" name="jna" level="project" />
<orderEntry type="module" module-name="intellij.toml" />
<orderEntry type="module" module-name="intellij.toml.core" />
<orderEntry type="library" scope="PROVIDED" name="tuweni-toml" level="project" />
<orderEntry type="library" name="jsr305" level="project" />
<orderEntry type="library" name="jetbrains-annotations" level="project" />
<orderEntry type="library" name="kotlin-stdlib" level="project" />

View File

@@ -76,19 +76,14 @@
<pluginSuggestionProvider order="first" implementation="com.jetbrains.python.suggestions.PycharmProSuggestionProvider"/>
<postStartupActivity implementation="com.jetbrains.python.poetry.PoetryPyProjectTomlPostStartupActivity"/>
<!--Poetry project model-->
<projectOpenProcessor implementation="com.jetbrains.python.projectModel.poetry.PoetryProjectOpenProcessor"/>
<externalSystemUnlinkedProjectAware implementation="com.jetbrains.python.projectModel.poetry.PoetryUnlinkedProjectAware"/>
<postStartupActivity implementation="com.jetbrains.python.projectModel.poetry.PoetryProjectAware$PoetrySyncStartupActivity"/>
<!--uv project model-->
<projectOpenProcessor implementation="com.jetbrains.python.projectModel.uv.UvProjectOpenProcessor"/>
<externalSystemUnlinkedProjectAware implementation="com.jetbrains.python.projectModel.uv.UvUnlinkedProjectAware"/>
<postStartupActivity implementation="com.jetbrains.python.projectModel.uv.UvProjectAware$UvSyncStartupActivity"/>
<virtualFileCustomDataProvider implementation="com.jetbrains.python.psi.PyLangLevelVirtualFileCustomDataProvider"/>
</extensions>
<extensions defaultExtensionNs="com.intellij.python.pyproject.model">
<tool implementation="com.jetbrains.python.projectModel.poetry.PoetryTool"/>
<tool implementation="com.jetbrains.python.projectModel.uv.UvTool"/>
</extensions>
<projectListeners>
<listener
class="com.jetbrains.python.inspections.PyInterpreterInspection$Visitor$CacheCleaner"
@@ -100,10 +95,6 @@
topic="com.jetbrains.python.packaging.common.PythonPackageManagementListener"/>
<listener class="com.jetbrains.python.statistics.PyPackageDaemonListener"
topic="com.intellij.codeInsight.daemon.DaemonCodeAnalyzer$DaemonListener"/>
<listener class="com.jetbrains.python.projectModel.poetry.PoetryProjectAware$PoetryListener"
topic="com.jetbrains.python.projectModel.poetry.PoetrySettingsListener"/>
<listener class="com.jetbrains.python.projectModel.uv.UvProjectAware$UvListener"
topic="com.jetbrains.python.projectModel.uv.UvSettingsListener"/>
</projectListeners>
<extensions defaultExtensionNs="com.intellij">
@@ -684,8 +675,6 @@
<!-- Setting up cross-module dependencies -->
<registryKey key="python.detect.cross.module.dependencies" defaultValue="false"
description="Try to detect and automatically set-up module dependencies in a multi-module project"/>
<registryKey key="python.pyproject.model" defaultValue="true" restartRequired="true"
description="Automatically set up multi-module pyproject.toml-based projects"/>
<feedback.idleFeedbackSurvey implementation="com.jetbrains.python.statistics.feedback.PythonJobSurvey"/>
<statistics.counterUsagesCollector implementationClass="com.jetbrains.python.statistics.feedback.PythonJobStatisticsCollector"/>
@@ -1072,12 +1061,6 @@
<add-to-group group-id="MarkRootGroup" anchor="after" relative-to-action="MarkSourceRoot"/>
</action>
<action id="Python.PoetrySync" class="com.jetbrains.python.projectModel.poetry.PoetrySyncAction"/>
<action id="Python.PoetryLink" class="com.jetbrains.python.projectModel.poetry.PoetryLinkAction"/>
<action id="Python.UvSync" class="com.jetbrains.python.projectModel.uv.UvSyncAction"/>
<action id="Python.UvLink" class="com.jetbrains.python.projectModel.uv.UvLinkAction"/>
<!--suppress PluginXmlI18n -->
<group id="Internal.Python" internal="true" popup="true" text="Python">
<!--suppress PluginXmlI18n -->

View File

@@ -17,7 +17,7 @@ jvm_library(
name = "pyproject",
module_name = "intellij.python.pyproject",
visibility = ["//visibility:public"],
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form", "gen/**/*.kt", "gen/**/*.java"], allow_empty = True),
resources = [":pyproject_resources"],
deps = [
"@lib//:kotlin-stdlib",
@@ -28,7 +28,16 @@ jvm_library(
"//platform/util",
"//plugins/toml/core",
"@lib//:tuweni-toml",
]
"//platform/external-system-api:externalSystem",
"//platform/ide-core",
"//platform/progress/shared:ide-progress",
"//platform/backend/workspace",
"//platform/workspace/storage",
"//platform/external-system-impl:externalSystem-impl",
"//platform/platform-impl:ide-impl",
"//platform/workspace/jps",
],
exports = ["@lib//:tuweni-toml"]
)
jvm_library(
@@ -50,7 +59,16 @@ jvm_library(
"@lib//:tuweni-toml",
"@lib//:junit5",
"@lib//:junit5Params",
]
"//platform/external-system-api:externalSystem",
"//platform/ide-core",
"//platform/progress/shared:ide-progress",
"//platform/backend/workspace",
"//platform/workspace/storage",
"//platform/external-system-impl:externalSystem-impl",
"//platform/platform-impl:ide-impl",
"//platform/workspace/jps",
],
exports = ["@lib//:tuweni-toml"]
)
### auto-generated section `build intellij.python.pyproject` end

View File

@@ -0,0 +1,42 @@
package com.intellij.python.pyproject.model.internal.impl
import com.intellij.platform.workspace.storage.ConnectionId
import com.intellij.platform.workspace.storage.WorkspaceEntityInternalApi
import com.intellij.platform.workspace.storage.metadata.impl.MetadataStorageBase
import com.intellij.platform.workspace.storage.metadata.model.EntityMetadata
import com.intellij.platform.workspace.storage.metadata.model.ExtPropertyMetadata
import com.intellij.platform.workspace.storage.metadata.model.FinalClassMetadata
import com.intellij.platform.workspace.storage.metadata.model.OwnPropertyMetadata
import com.intellij.platform.workspace.storage.metadata.model.StorageTypeMetadata
import com.intellij.platform.workspace.storage.metadata.model.ValueTypeMetadata
@OptIn(WorkspaceEntityInternalApi::class)
internal object MetadataStorageImpl: MetadataStorageBase() {
override fun initializeMetadata() {
val primitiveTypeStringNotNullable = ValueTypeMetadata.SimpleType.PrimitiveType(isNullable = false, type = "String")
val primitiveTypeMapNotNullable = ValueTypeMetadata.SimpleType.PrimitiveType(isNullable = false, type = "Map")
var typeMetadata: StorageTypeMetadata
typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.intellij.python.pyproject.model.internal.PyProjectTomlEntitySource", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "virtualFileUrl", valueType = ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.KnownClass(fqName = "com.intellij.platform.workspace.storage.url.VirtualFileUrl")), withDefault = false)), supertypes = listOf("com.intellij.platform.workspace.storage.EntitySource"))
addMetadata(typeMetadata)
typeMetadata = EntityMetadata(fqName = "com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity", entityDataFqName = "com.intellij.python.pyproject.model.internal.impl.PyProjectTomlWorkspaceEntityData", supertypes = listOf("com.intellij.platform.workspace.storage.WorkspaceEntity"), properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "entitySource", valueType = ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.KnownClass(fqName = "com.intellij.platform.workspace.storage.EntitySource")), withDefault = false),
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "participatedTools", valueType = ValueTypeMetadata.ParameterizedType(generics = listOf(ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.intellij.python.pyproject.model.spi.ToolId", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "id", valueType = primitiveTypeStringNotNullable, withDefault = false)), supertypes = listOf())),
ValueTypeMetadata.SimpleType.CustomType(isNullable = true, typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.intellij.platform.workspace.jps.entities.ModuleId", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "name", valueType = primitiveTypeStringNotNullable, withDefault = false),
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "presentableName", valueType = primitiveTypeStringNotNullable, withDefault = false)), supertypes = listOf("com.intellij.platform.workspace.storage.SymbolicEntityId")))), primitive = primitiveTypeMapNotNullable), withDefault = false),
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "module", valueType = ValueTypeMetadata.EntityReference(connectionType = ConnectionId.ConnectionType.ONE_TO_ONE, entityFqName = "com.intellij.platform.workspace.jps.entities.ModuleEntity", isChild = false, isNullable = false), withDefault = false)), extProperties = listOf(ExtPropertyMetadata(isComputable = false, isOpen = false, name = "pyProjectTomlEntity", receiverFqn = "com.intellij.platform.workspace.jps.entities.ModuleEntity", valueType = ValueTypeMetadata.EntityReference(connectionType = ConnectionId.ConnectionType.ONE_TO_ONE, entityFqName = "com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity", isChild = true, isNullable = false), withDefault = false)), isAbstract = false)
addMetadata(typeMetadata)
}
override fun initializeMetadataHash() {
addMetadataHash(typeFqn = "com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity", metadataHash = 775488913)
addMetadataHash(typeFqn = "com.intellij.python.pyproject.model.spi.ToolId", metadataHash = 2027768588)
addMetadataHash(typeFqn = "com.intellij.platform.workspace.jps.entities.ModuleId", metadataHash = -575206713)
addMetadataHash(typeFqn = "com.intellij.platform.workspace.storage.EntitySource", metadataHash = -1282078904)
addMetadataHash(typeFqn = "com.intellij.python.pyproject.model.internal.PyProjectTomlEntitySource", metadataHash = -1054650782)
}
}

View File

@@ -0,0 +1,251 @@
package com.intellij.python.pyproject.model.internal.impl
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.jps.entities.ModuleId
import com.intellij.platform.workspace.storage.*
import com.intellij.platform.workspace.storage.annotations.Parent
import com.intellij.platform.workspace.storage.impl.EntityLink
import com.intellij.platform.workspace.storage.impl.ModifiableWorkspaceEntityBase
import com.intellij.platform.workspace.storage.impl.WorkspaceEntityBase
import com.intellij.platform.workspace.storage.impl.WorkspaceEntityData
import com.intellij.platform.workspace.storage.impl.containers.toMutableWorkspaceSet
import com.intellij.platform.workspace.storage.impl.extractOneToOneParent
import com.intellij.platform.workspace.storage.impl.updateOneToOneParentOfChild
import com.intellij.platform.workspace.storage.instrumentation.EntityStorageInstrumentation
import com.intellij.platform.workspace.storage.instrumentation.EntityStorageInstrumentationApi
import com.intellij.platform.workspace.storage.instrumentation.MutableEntityStorageInstrumentation
import com.intellij.platform.workspace.storage.metadata.model.EntityMetadata
import com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity
import com.intellij.python.pyproject.model.spi.ToolId
@GeneratedCodeApiVersion(3)
@GeneratedCodeImplVersion(7)
@OptIn(WorkspaceEntityInternalApi::class)
internal class PyProjectTomlWorkspaceEntityImpl(private val dataSource: PyProjectTomlWorkspaceEntityData) : PyProjectTomlWorkspaceEntity, WorkspaceEntityBase(
dataSource) {
private companion object {
internal val MODULE_CONNECTION_ID: ConnectionId = ConnectionId.create(ModuleEntity::class.java,
PyProjectTomlWorkspaceEntity::class.java,
ConnectionId.ConnectionType.ONE_TO_ONE, false)
private val connections = listOf<ConnectionId>(
MODULE_CONNECTION_ID,
)
}
override val participatedTools: Map<ToolId, ModuleId?>
get() {
readField("participatedTools")
return dataSource.participatedTools
}
override val module: ModuleEntity
get() = snapshot.extractOneToOneParent(MODULE_CONNECTION_ID, this)!!
override val entitySource: EntitySource
get() {
readField("entitySource")
return dataSource.entitySource
}
override fun connectionIdList(): List<ConnectionId> {
return connections
}
internal class Builder(result: PyProjectTomlWorkspaceEntityData?) : ModifiableWorkspaceEntityBase<PyProjectTomlWorkspaceEntity, PyProjectTomlWorkspaceEntityData>(
result), PyProjectTomlWorkspaceEntity.Builder {
internal constructor() : this(PyProjectTomlWorkspaceEntityData())
override fun applyToBuilder(builder: MutableEntityStorage) {
if (this.diff != null) {
if (existsInBuilder(builder)) {
this.diff = builder
return
}
else {
error("Entity PyProjectTomlWorkspaceEntity is already created in a different builder")
}
}
this.diff = builder
addToBuilder()
this.id = getEntityData().createEntityId()
// After adding entity data to the builder, we need to unbind it and move the control over entity data to builder
// Builder may switch to snapshot at any moment and lock entity data to modification
this.currentEntityData = null
// Process linked entities that are connected without a builder
processLinkedEntities(builder)
checkInitialization() // TODO uncomment and check failed tests
}
private fun checkInitialization() {
val _diff = diff
if (!getEntityData().isEntitySourceInitialized()) {
error("Field WorkspaceEntity#entitySource should be initialized")
}
if (!getEntityData().isParticipatedToolsInitialized()) {
error("Field PyProjectTomlWorkspaceEntity#participatedTools should be initialized")
}
if (_diff != null) {
if (_diff.extractOneToOneParent<WorkspaceEntityBase>(MODULE_CONNECTION_ID, this) == null) {
error("Field PyProjectTomlWorkspaceEntity#module should be initialized")
}
}
else {
if (this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)] == null) {
error("Field PyProjectTomlWorkspaceEntity#module should be initialized")
}
}
}
override fun connectionIdList(): List<ConnectionId> {
return connections
}
// Relabeling code, move information from dataSource to this builder
override fun relabel(dataSource: WorkspaceEntity, parents: Set<WorkspaceEntity>?) {
dataSource as PyProjectTomlWorkspaceEntity
if (this.entitySource != dataSource.entitySource) this.entitySource = dataSource.entitySource
if (this.participatedTools != dataSource.participatedTools) this.participatedTools = dataSource.participatedTools.toMutableMap()
updateChildToParentReferences(parents)
}
override var entitySource: EntitySource
get() = getEntityData().entitySource
set(value) {
checkModificationAllowed()
getEntityData(true).entitySource = value
changedProperty.add("entitySource")
}
override var participatedTools: Map<ToolId, ModuleId?>
get() = getEntityData().participatedTools
set(value) {
checkModificationAllowed()
getEntityData(true).participatedTools = value
changedProperty.add("participatedTools")
}
override var module: ModuleEntity.Builder
get() {
val _diff = diff
return if (_diff != null) {
@OptIn(EntityStorageInstrumentationApi::class)
((_diff as MutableEntityStorageInstrumentation).getParentBuilder(MODULE_CONNECTION_ID, this) as? ModuleEntity.Builder)
?: (this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)]!! as ModuleEntity.Builder)
}
else {
this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)]!! as ModuleEntity.Builder
}
}
set(value) {
checkModificationAllowed()
val _diff = diff
if (_diff != null && value is ModifiableWorkspaceEntityBase<*, *> && value.diff == null) {
if (value is ModifiableWorkspaceEntityBase<*, *>) {
value.entityLinks[EntityLink(true, MODULE_CONNECTION_ID)] = this
}
// else you're attaching a new entity to an existing entity that is not modifiable
_diff.addEntity(value as ModifiableWorkspaceEntityBase<WorkspaceEntity, *>)
}
if (_diff != null && (value !is ModifiableWorkspaceEntityBase<*, *> || value.diff != null)) {
_diff.updateOneToOneParentOfChild(MODULE_CONNECTION_ID, this, value)
}
else {
if (value is ModifiableWorkspaceEntityBase<*, *>) {
value.entityLinks[EntityLink(true, MODULE_CONNECTION_ID)] = this
}
// else you're attaching a new entity to an existing entity that is not modifiable
this.entityLinks[EntityLink(false, MODULE_CONNECTION_ID)] = value
}
changedProperty.add("module")
}
override fun getEntityClass(): Class<PyProjectTomlWorkspaceEntity> = PyProjectTomlWorkspaceEntity::class.java
}
}
@OptIn(WorkspaceEntityInternalApi::class)
internal class PyProjectTomlWorkspaceEntityData : WorkspaceEntityData<PyProjectTomlWorkspaceEntity>() {
lateinit var participatedTools: Map<ToolId, ModuleId?>
internal fun isParticipatedToolsInitialized(): Boolean = ::participatedTools.isInitialized
override fun wrapAsModifiable(diff: MutableEntityStorage): WorkspaceEntity.Builder<PyProjectTomlWorkspaceEntity> {
val modifiable = PyProjectTomlWorkspaceEntityImpl.Builder(null)
modifiable.diff = diff
modifiable.id = createEntityId()
return modifiable
}
@OptIn(EntityStorageInstrumentationApi::class)
override fun createEntity(snapshot: EntityStorageInstrumentation): PyProjectTomlWorkspaceEntity {
val entityId = createEntityId()
return snapshot.initializeEntity(entityId) {
val entity = PyProjectTomlWorkspaceEntityImpl(this)
entity.snapshot = snapshot
entity.id = entityId
entity
}
}
override fun getMetadata(): EntityMetadata {
return MetadataStorageImpl.getMetadataByTypeFqn(
"com.intellij.python.pyproject.model.internal.PyProjectTomlWorkspaceEntity") as EntityMetadata
}
override fun getEntityInterface(): Class<out WorkspaceEntity> {
return PyProjectTomlWorkspaceEntity::class.java
}
override fun createDetachedEntity(parents: List<WorkspaceEntity.Builder<*>>): WorkspaceEntity.Builder<*> {
return PyProjectTomlWorkspaceEntity(participatedTools, entitySource) {
parents.filterIsInstance<ModuleEntity.Builder>().singleOrNull()?.let { this.module = it }
}
}
override fun getRequiredParents(): List<Class<out WorkspaceEntity>> {
val res = mutableListOf<Class<out WorkspaceEntity>>()
res.add(ModuleEntity::class.java)
return res
}
override fun equals(other: Any?): Boolean {
if (other == null) return false
if (this.javaClass != other.javaClass) return false
other as PyProjectTomlWorkspaceEntityData
if (this.entitySource != other.entitySource) return false
if (this.participatedTools != other.participatedTools) return false
return true
}
override fun equalsIgnoringEntitySource(other: Any?): Boolean {
if (other == null) return false
if (this.javaClass != other.javaClass) return false
other as PyProjectTomlWorkspaceEntityData
if (this.participatedTools != other.participatedTools) return false
return true
}
override fun hashCode(): Int {
var result = entitySource.hashCode()
result = 31 * result + participatedTools.hashCode()
return result
}
override fun hashCodeIgnoringEntitySource(): Int {
var result = javaClass.hashCode()
result = 31 * result + participatedTools.hashCode()
return result
}
}

View File

@@ -0,0 +1,7 @@
/**
* Workspace internal generated tools, do not touch
*/
@ApiStatus.Internal
package com.intellij.python.pyproject.model.internal.impl;
import org.jetbrains.annotations.ApiStatus;

View File

@@ -6,6 +6,7 @@
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" />
</content>
<orderEntry type="inheritedJdk" />
@@ -17,8 +18,16 @@
<orderEntry type="module" module-name="intellij.python.sdk" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.toml.core" />
<orderEntry type="library" name="tuweni-toml" level="project" />
<orderEntry type="library" exported="" name="tuweni-toml" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5Params" level="project" />
<orderEntry type="module" module-name="intellij.platform.externalSystem" />
<orderEntry type="module" module-name="intellij.platform.ide.core" />
<orderEntry type="module" module-name="intellij.platform.ide.progress" />
<orderEntry type="module" module-name="intellij.platform.backend.workspace" />
<orderEntry type="module" module-name="intellij.platform.workspace.storage" />
<orderEntry type="module" module-name="intellij.platform.externalSystem.impl" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.workspace.jps" />
</component>
</module>

View File

@@ -5,4 +5,22 @@
<module name="intellij.python.sdk"/>
<plugin id="org.toml.lang"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<registryKey defaultValue="false" description="Load project structure from pyproject.toml" key="intellij.python.pyproject.model" restartRequired="true"/>
<externalSystemUnlinkedProjectAware
implementation="com.intellij.python.pyproject.model.internal.PyExternalSystemUnlinkedProjectAware"/>
<projectOpenProcessor implementation="com.intellij.python.pyproject.model.internal.PyProjectOpenProcessor"/>
<postStartupActivity implementation="com.intellij.python.pyproject.model.internal.PyProjectSyncActivity"/>
</extensions>
<actions>
<action class="com.intellij.python.pyproject.model.internal.PyProjectTomlSyncAction" id="PyProjectTomlSyncAction"/>
</actions>
<extensionPoints>
<extensionPoint qualifiedName="com.intellij.python.pyproject.model.tool" interface="com.intellij.python.pyproject.model.spi.Tool"
dynamic="true"/>
</extensionPoints>
</idea-plugin>

View File

@@ -0,0 +1,5 @@
intellij.python.pyproject.unlink.model=Unlinking model..
intellij.python.pyproject.link.and.sync.model=Synchronizing project structure with pyproject.toml
intellij.python.pyproject.name=Generate project from pyproject.toml
action.PyProjectTomlSyncAction.text=Sync Project with `pyproject.toml`
action.PyProjectTomlSyncAction.description=Rebuild project model based on pyproject.toml

View File

@@ -0,0 +1,20 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.Disposable
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectAware
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectListener
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectReloadContext
import com.intellij.openapi.project.Project
import java.nio.file.Path
internal class PyExternalSystemProjectAware(override val projectId: ExternalSystemProjectId, override val settingsFiles: Set<String>, private val project: Project, private val projectModelRoot: Path) : ExternalSystemProjectAware {
override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) {
project.messageBus.connect(parentDisposable).subscribe(PROJECT_AWARE_TOPIC, listener)
}
override fun reloadProject(context: ExternalSystemProjectReloadContext) {
linkProjectWithProgressInBackground(project, projectModelRoot)
}
}

View File

@@ -0,0 +1,41 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.Disposable
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
import com.intellij.openapi.externalSystem.autolink.ExternalSystemUnlinkedProjectAware
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import java.io.IOException
import java.nio.file.Path
internal class PyExternalSystemUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
override val systemId: ProjectSystemId = SYSTEM_ID
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean = PyOpenProjectProvider.canOpenProject(buildFile)
override fun isLinkedProject(project: Project, externalProjectPath: String): Boolean = isProjectLinked(project)
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
unlinkProjectWithProgress(project, externalProjectPath)
}
override suspend fun linkAndLoadProjectAsync(project: Project, externalProjectPath: String) {
val path = try {
Path.of(externalProjectPath)
}
catch (e: IOException) {
logger.warn("Provided path is wrong, probably workspace is broken: ${externalProjectPath}", e)
null
}
linkProjectWithProgressInBackground(project, path)
}
override fun subscribe(project: Project, listener: ExternalSystemProjectLinkListener, parentDisposable: Disposable) {
project.messageBus.connect(parentDisposable).subscribe(PROJECT_LINKER_AWARE_TOPIC, listener)
}
}
private val logger = fileLogger()

View File

@@ -0,0 +1,26 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.ide.impl.runUnderModalProgressIfIsEdt
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.isFile
import com.intellij.python.pyproject.PY_PROJECT_TOML
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal object PyOpenProjectProvider : AbstractOpenProjectProvider() {
override val systemId = SYSTEM_ID
override fun isProjectFile(file: VirtualFile): Boolean = @Suppress("DEPRECATION") // We have no choice: API is broken
(runUnderModalProgressIfIsEdt {
file.isFile && file.name == PY_PROJECT_TOML || file.isDirectory && file.findChild(PY_PROJECT_TOML) != null
})
override suspend fun linkProject(projectFile: VirtualFile, project: Project) {
val projectDirectory = withContext(Dispatchers.IO) {
getProjectDirectory(projectFile)
}
linkProjectWithProgress(project, projectDirectory.toNioPath())
}
}

View File

@@ -0,0 +1,17 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.projectImport.ProjectOpenProcessor
import org.jetbrains.annotations.Nls
internal class PyProjectOpenProcessor : ProjectOpenProcessor() {
override val name: @Nls String = PyProjectTomlBundle.message("intellij.python.pyproject.name")
override fun canOpenProject(file: VirtualFile): Boolean = projectModelEnabled && PyOpenProjectProvider.canOpenProject(file)
override suspend fun openProjectAsync(virtualFile: VirtualFile, projectToClose: Project?, forceOpenInNewFrame: Boolean): Project? = PyOpenProjectProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame)
override fun canImportProjectAfterwards(): Boolean = true
override suspend fun importProjectAfterwardsAsync(project: Project, file: VirtualFile) = PyOpenProjectProvider.linkToExistingProjectAsync(file, project)
}

View File

@@ -0,0 +1,17 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
internal class PyProjectSyncActivity : ProjectActivity {
init {
if (!projectModelEnabled) {
throw ExtensionNotApplicableException.create()
}
}
override suspend fun execute(project: Project) {
linkProjectWithProgressInBackground(project)
}
}

View File

@@ -0,0 +1,25 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.pyproject.model.internal;
import com.intellij.DynamicBundle;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.PropertyKey;
import java.util.function.Supplier;
final class PyProjectTomlBundle extends DynamicBundle {
public static final @NonNls String BUNDLE = "messages.PyProjectTomlBundle";
public static final PyProjectTomlBundle INSTANCE = new PyProjectTomlBundle();
private PyProjectTomlBundle() { super(BUNDLE); }
public static @NotNull @Nls String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, Object @NotNull ... params) {
return INSTANCE.getMessage(key, params);
}
public static @NotNull Supplier<@Nls String> messagePointer(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, Object @NotNull ... params) {
return INSTANCE.getLazyMessage(key, params);
}
}

View File

@@ -0,0 +1,22 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
internal class PyProjectTomlSyncAction : AnAction(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.text")) {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
if (!projectModelEnabled) {
return
}
linkProjectWithProgressInBackground(project)
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = e.project != null && projectModelEnabled
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}

View File

@@ -0,0 +1,58 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.jps.entities.ModuleId
import com.intellij.platform.workspace.storage.*
import com.intellij.platform.workspace.storage.annotations.Parent
import com.intellij.python.pyproject.model.spi.ToolId
internal interface PyProjectTomlWorkspaceEntity : WorkspaceEntity {
// [tool, probablyWorkspaceRoot?]. If root is null -> tool didn't implement workspace, just participated in this entry creation
val participatedTools: Map<ToolId, ModuleId?>
@Parent
val module: ModuleEntity
//region generated code
@GeneratedCodeApiVersion(3)
interface Builder : WorkspaceEntity.Builder<PyProjectTomlWorkspaceEntity> {
override var entitySource: EntitySource
var participatedTools: Map<ToolId, ModuleId?>
var module: ModuleEntity.Builder
}
companion object : EntityType<PyProjectTomlWorkspaceEntity, Builder>() {
@JvmOverloads
@JvmStatic
@JvmName("create")
operator fun invoke(
participatedTools: Map<ToolId, ModuleId?>,
entitySource: EntitySource,
init: (Builder.() -> Unit)? = null,
): Builder {
val builder = builder()
builder.participatedTools = participatedTools
builder.entitySource = entitySource
init?.invoke(builder)
return builder
}
}
//endregion
}
//region generated code
internal fun MutableEntityStorage.modifyPyProjectTomlWorkspaceEntity(
entity: PyProjectTomlWorkspaceEntity,
modification: PyProjectTomlWorkspaceEntity.Builder.() -> Unit,
): PyProjectTomlWorkspaceEntity {
return modifyEntity(PyProjectTomlWorkspaceEntity.Builder::class.java, entity, modification)
}
internal var ModuleEntity.Builder.pyProjectTomlEntity: PyProjectTomlWorkspaceEntity.Builder
by WorkspaceEntity.extensionBuilder(PyProjectTomlWorkspaceEntity::class.java)
//endregion
internal val ModuleEntity.pyProjectTomlEntity: PyProjectTomlWorkspaceEntity
by WorkspaceEntity.extension()

View File

@@ -0,0 +1,7 @@
/**
* Low level package that connects SPI with the Platform, should never be accessed directly
*/
@ApiStatus.Internal
package com.intellij.python.pyproject.model.internal;
import org.jetbrains.annotations.ApiStatus;

View File

@@ -0,0 +1,60 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectListener
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.util.messages.Topic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.nio.file.Path
// "upper-level" API to called by various actions
@Topic.ProjectLevel
internal val PROJECT_AWARE_TOPIC: Topic<ExternalSystemProjectListener> = Topic(ExternalSystemProjectListener::class.java, Topic.BroadcastDirection.NONE)
@Topic.ProjectLevel
internal val PROJECT_LINKER_AWARE_TOPIC: Topic<ExternalSystemProjectLinkListener> = Topic(ExternalSystemProjectLinkListener::class.java, Topic.BroadcastDirection.NONE)
internal val SYSTEM_ID = ProjectSystemId("PyProjectToml")
val projectModelEnabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model")
internal suspend fun unlinkProjectWithProgress(project: Project, externalProjectPath: String) {
withBackgroundProgress(project = project, title = PyProjectTomlBundle.message("intellij.python.pyproject.unlink.model")) {
unlinkProject(project, externalProjectPath)
}
}
internal suspend fun linkProjectWithProgress(project: Project, projectModelRoot: Path) {
withBackgroundProgress(project = project, title = PyProjectTomlBundle.message("intellij.python.pyproject.link.and.sync.model")) {
linkProject(project, projectModelRoot)
}
}
internal fun linkProjectWithProgressInBackground(project: Project, projectModelRoot: Path? = null) {
ApplicationManager.getApplication().service<MyService>().scope.launch {
val projectModelRoot = projectModelRoot ?: withContext(Dispatchers.IO) {
// can't guess as it might return the src of the first module
project.baseDir.toNioPath()
}
linkProjectWithProgress(project, projectModelRoot)
}
}
@Service
private class MyService(val scope: CoroutineScope)

View File

@@ -0,0 +1,68 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.python.pyproject.PyProjectToml
import com.jetbrains.python.Result
import com.jetbrains.python.venvReader.Directory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Path
import kotlin.io.path.name
import kotlin.io.path.readText
import kotlin.io.path.visitFileTree
// Tools to walk FS and parse pyproject.toml
internal suspend fun walkFileSystem(root: Directory): FSWalkInfo {
val files = ArrayList<Path>(10)
val excludeDir = ArrayList<Directory>(10)
withContext(Dispatchers.IO) {
root.visitFileTree {
onVisitFile { file, _ ->
if (file.name == PY_PROJECT_TOML) {
files.add(file)
}
return@onVisitFile FileVisitResult.CONTINUE
}
onPostVisitDirectory { directory, _ ->
return@onPostVisitDirectory if (directory.name.startsWith(".")) {
excludeDir.add(directory)
FileVisitResult.SKIP_SUBTREE
}
else {
FileVisitResult.CONTINUE
}
}
}
}
// TODO: with a big number of files, use `chunk` to parse them concurrently
val tomlFiles = files.map { file ->
val toml = readFile(file) ?: return@map null
file to toml
}.filterNotNull().toMap()
return FSWalkInfo(tomlFiles = tomlFiles, excludeDir.toSet())
}
internal data class FSWalkInfo(val tomlFiles: Map<Path, PyProjectToml>, val excludeDir: Set<Directory>)
private val logger = fileLogger()
private suspend fun readFile(file: Path): PyProjectToml? {
val content = try {
withContext(Dispatchers.IO) { file.readText() }
}
catch (e: IOException) {
logger.warn("Can't read $file", e)
return null
}
return when (val r = withContext(Dispatchers.Default) { PyProjectToml.parse(content) }) {
is Result.Failure -> {
logger.warn("Errors on $file: ${r.error.joinToString(", ")}")
null
}
is Result.Success -> r.result
}
}

View File

@@ -0,0 +1,226 @@
package com.intellij.python.pyproject.model.internal
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectTracker
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemRefreshStatus
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.removeUserData
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.workspace.jps.entities.*
import com.intellij.platform.workspace.storage.EntitySource
import com.intellij.platform.workspace.storage.EntityStorage
import com.intellij.platform.workspace.storage.MutableEntityStorage
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
import com.intellij.platform.workspace.storage.url.VirtualFileUrlManager
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.model.spi.*
import com.jetbrains.python.venvReader.Directory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
import kotlin.io.path.name
import kotlin.io.path.pathString
// Workspace adapter functions
internal fun isProjectLinked(project: Project): Boolean =
project.workspaceModel.currentSnapshot.entitiesBySource(sourceFilter = { it is PyProjectTomlEntitySource }).any()
internal suspend fun unlinkProject(project: Project, externalProjectPath: String) {
val tracker = ExternalSystemProjectTracker.getInstance(project)
project.getUserData(PY_PROJECT_TOML_KEY)?.let { oldProjectId ->
tracker.remove(oldProjectId)
}
project.removeUserData(PY_PROJECT_TOML_KEY)
withContext(Dispatchers.Default) {
project.workspaceModel.update(PyProjectTomlBundle.message("intellij.python.pyproject.unlink.model")) { storage ->
storage.replaceBySource({ it is PyProjectTomlEntitySource }, MutableEntityStorage.create())
}
}
project.messageBus.syncPublisher(PROJECT_LINKER_AWARE_TOPIC).onProjectUnlinked(externalProjectPath)
}
internal suspend fun linkProject(project: Project, projectModelRoot: Path) {
val externalProjectPath = projectModelRoot.pathString
val (files, excludeDirs) = walkFileSystem(projectModelRoot)
val entries = generatePyProjectTomlEntries(files, excludeDirs)
unlinkProject(project, externalProjectPath)
val tracker = ExternalSystemProjectTracker.getInstance(project)
val projectAware = PyExternalSystemProjectAware(ExternalSystemProjectId(SYSTEM_ID, externalProjectPath), files.map { it.key.pathString }.toSet(), project, projectModelRoot)
tracker.register(projectAware)
tracker.activate(projectAware.projectId)
project.putUserData(PY_PROJECT_TOML_KEY, projectAware.projectId)
val storage = createEntityStorage(project, entries, project.workspaceModel.getVirtualFileUrlManager())
val listener = project.messageBus.syncPublisher(PROJECT_AWARE_TOPIC)
listener.onProjectReloadStart()
try {
project.workspaceModel.update(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.description")) { mutableStorage -> // Fake module entity is added by default if nothing was discovered
removeFakeModuleEntity(mutableStorage)
mutableStorage.replaceBySource({ it is PyProjectTomlEntitySource }, storage)
}
}
catch (e: Exception) {
listener.onProjectReloadFinish(ExternalSystemRefreshStatus.FAILURE)
throw e
}
listener.onProjectReloadFinish(ExternalSystemRefreshStatus.SUCCESS)
project.messageBus.syncPublisher(PROJECT_LINKER_AWARE_TOPIC).onProjectLinked(externalProjectPath)
}
private suspend fun generatePyProjectTomlEntries(files: Map<Path, PyProjectToml>, allExcludeDirs: Set<Directory>): Set<PyProjectTomlBasedEntryImpl> = withContext(Dispatchers.Default) {
val entries = ArrayList<PyProjectTomlBasedEntryImpl>()
// Any tool that helped us somehow must be tracked here
for ((tomlFile, toml) in files.entries) {
val participatedTools = mutableSetOf<ToolId>()
val root = tomlFile.parent
var projectNameAsString = toml.project?.name
if (projectNameAsString == null) {
val toolAndName = getNameFromEP(toml)
if (toolAndName != null) {
projectNameAsString = toolAndName.second
participatedTools.add(toolAndName.first.id)
}
}
val projectName = ProjectName(projectNameAsString ?: "${root.name}@${tomlFile.hashCode()}")
val sourceRootsAndTools = Tool.EP.extensionList.flatMap { tool -> tool.getSrcRoots(toml.toml, root).map { Pair(tool, it) } }.toSet()
val sourceRoots = sourceRootsAndTools.map { it.second }.toSet()
participatedTools.addAll(sourceRootsAndTools.map { it.first.id })
val excludedDirs = allExcludeDirs.filter { it.startsWith(root) }
val relationsWithTools: List<PyProjectTomlToolRelation> = participatedTools.map { PyProjectTomlToolRelation.SimpleRelation(it) }
val entry = PyProjectTomlBasedEntryImpl(tomlFile, HashSet(relationsWithTools), toml, projectName, root, mutableSetOf(), sourceRoots, excludedDirs.toSet())
entries.add(entry)
}
val entriesByName = entries.associateBy { it.name }
val namesByDir = entries.associate { Pair(it.root, it.name) }
val allNames = entriesByName.keys
for (tool in Tool.EP.extensionList) {
val (dependencies, workspaceMembers) = tool.getProjectStructure(entriesByName, namesByDir)
for ((name, deps) in dependencies) {
val orphanNames = deps - allNames
assert(orphanNames.isEmpty()) { "Tool $tool retuned wrong project names ${orphanNames.joinToString(", ")}" }
val entity = entriesByName[name] ?: error("Tool $tool returned broken name $name")
entity.dependencies.addAll(deps)
if (deps.isNotEmpty()) {
entity.relationsWithTools.add(PyProjectTomlToolRelation.SimpleRelation(tool.id))
}
workspaceMembers[entity.name]?.let { workspace ->
entity.relationsWithTools.add(PyProjectTomlToolRelation.WorkspaceMember(tool.id, workspace))
}
}
}
return@withContext entries.toSet()
}
private suspend fun getNameFromEP(projectToml: PyProjectToml): Pair<Tool, @NlsSafe String>? = withContext(Dispatchers.Default) {
Tool.EP.extensionList.firstNotNullOfOrNull { tool -> tool.getProjectName(projectToml.toml)?.let { Pair(tool, it) } }
}
private suspend fun createEntityStorage(
project: Project,
graph: Set<PyProjectTomlBasedEntryImpl>,
virtualFileUrlManager: VirtualFileUrlManager,
): EntityStorage = withContext(Dispatchers.Default) {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val storage = MutableEntityStorage.create()
for (pyProject in graph) {
val existingModuleEntity = project.workspaceModel.currentSnapshot
.entitiesBySource { it is PyProjectTomlEntitySource }
.filterIsInstance<ModuleEntity>()
.find { it.name == pyProject.name.name }
val existingSdkEntity = existingModuleEntity
?.dependencies
?.find { it is SdkDependency } as? SdkDependency
val sdkDependency = existingSdkEntity ?: InheritedSdkDependency
val entitySource = PyProjectTomlEntitySource(pyProject.tomlFile.toVirtualFileUrl(virtualFileUrlManager))
val moduleEntity = storage addEntity ModuleEntity(pyProject.name.name, emptyList(), entitySource) {
dependencies += sdkDependency
dependencies += ModuleSourceDependency
for (moduleName in pyProject.dependencies) {
dependencies += ModuleDependency(ModuleId(moduleName.name), true, DependencyScope.COMPILE, false)
}
contentRoots = listOf(ContentRootEntity(pyProject.root.toVirtualFileUrl(fileUrlManager), emptyList(), entitySource) {
sourceRoots = pyProject.sourceRoots.map { srcRoot ->
SourceRootEntity(srcRoot.toVirtualFileUrl(fileUrlManager), PYTHON_SOURCE_ROOT_TYPE, entitySource)
}
excludedUrls = pyProject.excludedRoots.map { excludedRoot ->
ExcludeUrlEntity(excludedRoot.toVirtualFileUrl(fileUrlManager), entitySource)
}
})
val participatedTools: MutableMap<ToolId, ModuleId?> = pyProject.relationsWithTools.associate { Pair(it.toolId, null) }.toMutableMap()
for (relation in pyProject.relationsWithTools) {
when (relation) {
is PyProjectTomlToolRelation.SimpleRelation -> Unit
is PyProjectTomlToolRelation.WorkspaceMember -> {
participatedTools[relation.toolId] = ModuleId(relation.workspace.name)
}
}
}
pyProjectTomlEntity = PyProjectTomlWorkspaceEntity(participatedTools = participatedTools, entitySource)
exModuleOptions = ExternalSystemModuleOptionsEntity(entitySource) {
externalSystem = PYTHON_SOURCE_ROOT_TYPE.name
}
}
moduleEntity.symbolicId
}
return@withContext storage
}
private class PyProjectTomlEntitySource(tomlFile: VirtualFileUrl) : EntitySource {
override val virtualFileUrl: VirtualFileUrl = tomlFile
}
// For the time being mark them as java-sources to indicate that in the Project tool window
private val PYTHON_SOURCE_ROOT_TYPE: SourceRootTypeId = SourceRootTypeId("java-source")
private val PY_PROJECT_TOML_KEY = Key.create<ExternalSystemProjectId>("pyProjectTomlAware")
private data class PyProjectTomlBasedEntryImpl(
val tomlFile: Path,
val relationsWithTools: MutableSet<PyProjectTomlToolRelation>,
override val pyProjectToml: PyProjectToml,
val name: ProjectName,
override val root: Directory,
val dependencies: MutableSet<ProjectName>,
val sourceRoots: Set<Directory>,
val excludedRoots: Set<Directory>,
) : PyProjectTomlProject
/**
* Removes the default IJ module created for the root of the project
* (that's going to be replaced with a module belonging to a specific project management system).
*
* @see com.intellij.openapi.project.impl.getOrInitializeModule
*/
private fun removeFakeModuleEntity(storage: MutableEntityStorage) {
val contentRoots = storage
.entitiesBySource { it !is PyProjectTomlEntitySource }
.filterIsInstance<ContentRootEntity>()
.toList()
for (entity in contentRoots) {
storage.removeEntity(entity.module)
storage.removeEntity(entity)
}
}
/**
* What does [toolId] have to do with a certain projec?
*/
private sealed interface PyProjectTomlToolRelation {
val toolId: ToolId
/**
* There is something tool-specific in `pyproject.toml`
*/
data class SimpleRelation(override val toolId: ToolId) : PyProjectTomlToolRelation
/**
* This module is a part of workspace, and the root is [workspace]
*/
data class WorkspaceMember(override val toolId: ToolId, val workspace: WorkspaceName) : PyProjectTomlToolRelation
}

View File

@@ -0,0 +1,8 @@
/**
* Builds project module from `pyproject.toml.
* To support new tool, see `spi` package
*/
@ApiStatus.Internal
package com.intellij.python.pyproject.model;
import org.jetbrains.annotations.ApiStatus;

View File

@@ -0,0 +1,9 @@
package com.intellij.python.pyproject.model.spi
import com.intellij.openapi.util.NlsSafe
/**
* project name in pyproject.toml
*/
@JvmInline
value class ProjectName(val name: @NlsSafe String)

View File

@@ -0,0 +1,15 @@
package com.intellij.python.pyproject.model.spi
typealias WorkspaceMember = ProjectName
typealias WorkspaceName = ProjectName
data class ProjectStructureInfo(
/**
* [project -> its dependencies]
*/
val dependencies: Map<ProjectName, Set<ProjectName>>,
/**
* Projects that are memvbers of workspace point to the root of workspace
*/
val membersToWorkspace: Map<WorkspaceMember, WorkspaceName>,
)

View File

@@ -0,0 +1,19 @@
package com.intellij.python.pyproject.model.spi
import com.intellij.python.pyproject.PyProjectToml
import com.jetbrains.python.venvReader.Directory
/**
* Particular project described by `pyproject.toml`
*/
interface PyProjectTomlProject {
/**
* Wrapper over toml file
*/
val pyProjectToml: PyProjectToml
/**
* Project root directory: `pyproject.toml` sits in it
*/
val root: Directory
}

View File

@@ -0,0 +1,30 @@
package com.intellij.python.pyproject.model.spi
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.util.NlsSafe
import com.jetbrains.python.venvReader.Directory
import org.apache.tuweni.toml.TomlTable
interface Tool {
companion object {
internal val EP = ExtensionPointName.create<Tool>("com.intellij.python.pyproject.model.tool")
}
val id: ToolId
/**
* Tools that support old (tool-specific) naming should return it here
*/
suspend fun getProjectName(projectToml: TomlTable): @NlsSafe String?
/**
* Tool uses [entries] ([rootIndex] contains the same data, used as index rooDir->project name) to report project dependencies and workspace members.
* All project names must be taken from provided data (use [rootIndex] to get name by directory)
*/
suspend fun getProjectStructure(entries: Map<ProjectName, PyProjectTomlProject>, rootIndex: Map<Directory, ProjectName>): ProjectStructureInfo
/**
* Tool that supports build systems might return additional src directories
*/
suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory>
}

View File

@@ -0,0 +1,9 @@
package com.intellij.python.pyproject.model.spi
import org.jetbrains.annotations.NonNls
/**
* Each tool must have unique id i.e: `uv`
*/
@JvmInline
value class ToolId(val id: @NonNls String)

View File

@@ -0,0 +1,7 @@
/**
* Implement {@link com.intellij.python.pyproject.model.spi.Tool} and register it
*/
@ApiStatus.Internal
package com.intellij.python.pyproject.model.spi;
import org.jetbrains.annotations.ApiStatus;

View File

@@ -22,7 +22,7 @@ interface PyProjectSdkConfigurationExtension {
companion object {
@JvmStatic
val EP_NAME = ExtensionPointName.create<PyProjectSdkConfigurationExtension>("Pythonid.projectSdkConfigurationExtension")
val EP_NAME: ExtensionPointName<PyProjectSdkConfigurationExtension> = ExtensionPointName.create<PyProjectSdkConfigurationExtension>("Pythonid.projectSdkConfigurationExtension")
@JvmStatic
@RequiresBackgroundThread

View File

@@ -7,11 +7,10 @@ import com.intellij.ide.projectView.impl.ProjectRootsUtil;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleUtilCore;
import com.intellij.openapi.roots.FileIndexFacade;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiDirectory;
import com.intellij.psi.PsiElement;
import com.jetbrains.python.projectModel.ProjectModelKt;
import com.intellij.python.pyproject.model.internal.PlatformToolsKt;
import com.jetbrains.python.psi.PyUtil;
import org.jetbrains.annotations.NotNull;
@@ -22,7 +21,7 @@ public final class PyDirectoryIconProvider extends IconProvider {
@Override
public Icon getIcon(@NotNull PsiElement element, int flags) {
if (element instanceof PsiDirectory directory) {
if (isMultimoduleProjectDetectionEnabled()) {
if (PlatformToolsKt.getProjectModelEnabled()) {
if (ProjectRootsUtil.isModuleContentRoot(directory.getVirtualFile(), directory.getProject())) {
return AllIcons.Nodes.Module;
}
@@ -36,9 +35,6 @@ public final class PyDirectoryIconProvider extends IconProvider {
return null;
}
private static boolean isMultimoduleProjectDetectionEnabled() {
return ProjectModelKt.getEnablePyProjectToml();
}
private static boolean isSpecialDirectory(@NotNull PsiDirectory directory) {
final VirtualFile vFile = directory.getVirtualFile();

View File

@@ -27,7 +27,6 @@ import com.intellij.openapi.roots.ModuleRootManager;
import com.intellij.openapi.roots.ProjectRootManager;
import com.intellij.openapi.roots.ui.configuration.ProjectSettingsService;
import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.openapi.util.UserDataHolderBase;
import com.intellij.platform.backend.workspace.WorkspaceModelChangeListener;
import com.intellij.platform.workspace.jps.entities.ModuleEntity;
@@ -42,9 +41,6 @@ import com.intellij.util.containers.ContainerUtil;
import com.intellij.workspaceModel.ide.impl.legacyBridge.module.ModuleEntityUtils;
import com.jetbrains.python.PyPsiBundle;
import com.jetbrains.python.PythonIdeLanguageCustomization;
import com.jetbrains.python.projectModel.ProjectModelKt;
import com.jetbrains.python.projectModel.uv.UvProjectModelService;
import com.jetbrains.python.projectModel.uv.UvProjectModelService.UvWorkspace;
import com.jetbrains.python.psi.LanguageLevel;
import com.jetbrains.python.psi.PyFile;
import com.jetbrains.python.psi.impl.PyBuiltinCache;
@@ -123,75 +119,17 @@ public final class PyInterpreterInspection extends PyInspection {
else {
message = PyPsiBundle.message("INSP.interpreter.no.python.interpreter.configured.for.module");
}
registerProblemWithCommonFixes(node, message, module, null, fixes, pyCharm);
}
else {
final @NlsSafe String associatedModulePath = PySdkExtKt.getAssociatedModulePath(sdk);
if (module != null && !PlatformUtils.isFleetBackend()) {
boolean isAlreadyUsedByModule = (PySdkExtKt.getPythonSdk(module) == sdk);
boolean isAssociatedWithThisModule = associatedModulePath != null && associatedModulePath.equals(BasePySdkExtKt.getBasePath(module));
// TODO: this logic should be generalized via the workspace manager
boolean isAssociatedWithUvRoot = associatedModulePath != null && ProjectModelKt.getEnablePyProjectToml() &&
isAssociatedWithUvWorkspaceRootModule(associatedModulePath, module);
if (!isAlreadyUsedByModule && !isAssociatedWithThisModule && !isAssociatedWithUvRoot &&
(associatedModulePath == null || PySdkExtKt.isAssociatedWithAnotherModule(sdk, module))) {
final PyInterpreterInspectionQuickFixData fixData = PySdkProvider.EP_NAME.getExtensionList().stream()
.map(ext -> ext.createEnvironmentAssociationFix(module, sdk, pyCharm, associatedModulePath))
.filter(it -> it != null)
.findFirst()
.orElse(null);
if (fixData != null) {
fixes.add(fixData.getQuickFix());
registerProblemWithCommonFixes(node, fixData.getMessage(), module, sdk, fixes, pyCharm);
return;
}
}
}
if (!PySdkExtKt.getSdkSeemsValid(sdk)) {
final @InspectionMessage String message;
if (pyCharm) {
message = PyPsiBundle.message("INSP.interpreter.invalid.python.interpreter.selected.for.project");
}
else {
message = PyPsiBundle.message("INSP.interpreter.invalid.python.interpreter.selected.for.module");
}
registerProblemWithCommonFixes(node, message, module, sdk, fixes, pyCharm);
}
else {
final LanguageLevel languageLevel = PythonSdkType.getLanguageLevelForSdk(sdk);
if (!LanguageLevel.SUPPORTED_LEVELS.contains(languageLevel)) {
final @InspectionMessage String message;
if (pyCharm) {
message = PyPsiBundle.message("INSP.interpreter.python.has.reached.its.end.of.life.and.is.no.longer.supported.in.pycharm",
languageLevel);
}
else {
message = PyPsiBundle.message("INSP.interpreter.python.has.reached.its.end.life.and.is.no.longer.supported.in.python.plugin",
languageLevel);
}
registerProblemWithCommonFixes(node, message, module, sdk, fixes, pyCharm);
}
}
registerProblemWithCommonFixes(node, message, module, fixes, pyCharm);
}
}
private static boolean isAssociatedWithUvWorkspaceRootModule(@Nullable String sdkAssociatedPath, @NotNull Module module) {
if (sdkAssociatedPath == null) return false;
UvWorkspace<@NotNull Module> uvWorkspace = UvProjectModelService.INSTANCE.findWorkspace(module);
if (uvWorkspace == null) return false;
return sdkAssociatedPath.equals(BasePySdkExtKt.getBasePath(uvWorkspace.getRoot()));
}
private void registerProblemWithCommonFixes(PyFile node,
@InspectionMessage String message,
@Nullable Module module,
Sdk sdk,
List<LocalQuickFix> fixes,
boolean pyCharm) {
if (module != null && pyCharm && sdk == null) {
if (module != null && pyCharm) {
final String sdkName = ProjectRootManager.getInstance(node.getProject()).getProjectSdkName();
ContainerUtil.addIfNotNull(fixes, getSuitableSdkFix(sdkName, module));
}

View File

@@ -1,163 +0,0 @@
// 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
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.workspace.jps.entities.*
import com.intellij.platform.workspace.storage.EntitySource
import com.intellij.platform.workspace.storage.EntityStorage
import com.intellij.platform.workspace.storage.MutableEntityStorage
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.jetbrains.python.PyBundle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.SystemIndependent
import java.nio.file.Path
import kotlin.reflect.KClass
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
abstract class BaseProjectModelService<E : EntitySource, P : ExternalProject> {
internal abstract val systemName: @NlsSafe String
internal abstract val projectModelResolver: PythonProjectModelResolver<P>
internal abstract fun getSettings(project: Project): ProjectModelSettings
protected abstract fun getSyncListener(project: Project): ProjectModelSyncListener
protected abstract fun createEntitySource(project: Project, singleProjectRoot: Path): EntitySource
protected abstract fun getEntitySourceClass(): KClass<out EntitySource>
suspend fun linkAllProjectModelRoots(project: Project, basePath: @SystemIndependent @NonNls String) {
val allProjectRoots = withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.discovering.projects", systemName)) {
projectModelResolver.discoverIndependentProjectGraphs(Path.of(basePath)).map { it.root }
}
getSettings(project).setLinkedProjects(allProjectRoots)
}
suspend fun syncAllProjectModelRoots(project: Project) {
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.syncing.all.projects", systemName)) {
// TODO progress bar, listener with events
getSettings(project).getLinkedProjects().forEach {
syncProjectModelRootImpl(project, it)
}
}
}
internal suspend fun syncProjectModelRoot(project: Project, projectModelRoot: Path) {
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.syncing.projects.at", systemName, projectModelRoot)) {
syncProjectModelRootImpl(project, projectModelRoot)
}
}
internal suspend fun forgetProjectModelRoot(project: Project, projectModelRoot: Path) {
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.unlinking.projects.at", systemName, projectModelRoot)) {
getSettings(project).removeLinkedProject(projectModelRoot)
forgetProjectModelRootImpl(project, projectModelRoot)
}
}
private suspend fun forgetProjectModelRootImpl(project: Project, projectModelRoot: Path) {
val source = createEntitySource(project, projectModelRoot)
project.workspaceModel.update("Forgetting a $systemName project at $projectModelRoot") { storage ->
storage.replaceBySource({ it == source }, MutableEntityStorage.Companion.create())
}
}
private suspend fun syncProjectModelRootImpl(project: Project, projectRoot: Path) {
val listener = getSyncListener(project)
listener.onStart(projectRoot)
try {
val source = createEntitySource(project, projectRoot)
val graph = projectModelResolver.discoverIndependentProjectGraphs(projectRoot)
if (graph.isEmpty()) {
return
}
val allModules = graph.flatMap { it.projects }
val storage = createProjectModel(project, allModules, source)
project.workspaceModel.update("$systemName sync at ${projectRoot}") { mutableStorage ->
// Fake module entity is added by default if nothing was discovered
if (allModules.any { it.root == project.baseNioPath }) {
removeFakeModuleEntity(project, mutableStorage)
}
mutableStorage.replaceBySource({ it == source }, storage)
}
}
finally {
listener.onFinish(projectRoot)
}
}
private suspend fun createProjectModel(
project: Project,
graph: List<ExternalProject>,
source: EntitySource,
): EntityStorage = withContext(Dispatchers.Default) {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val storage = MutableEntityStorage.create()
for (extProject in graph) {
val existingModuleEntity = project.workspaceModel.currentSnapshot
.entitiesBySource { it == source }
.filterIsInstance<ModuleEntity>()
.find { it.name == extProject.name }
val existingSdkEntity = existingModuleEntity
?.dependencies
?.find { it is SdkDependency } as? SdkDependency
val sdkDependency = existingSdkEntity ?: InheritedSdkDependency
storage addEntity ModuleEntity(extProject.name, emptyList(), source) {
dependencies += sdkDependency
dependencies += ModuleSourceDependency
for (moduleName in extProject.dependencies) {
dependencies += ModuleDependency(ModuleId(moduleName.name), true, DependencyScope.COMPILE, false)
}
contentRoots = listOf(ContentRootEntity(extProject.root.toVirtualFileUrl(fileUrlManager), emptyList(), source) {
sourceRoots = extProject.sourceRoots.map { srcRoot ->
SourceRootEntity(srcRoot.toVirtualFileUrl(fileUrlManager), PYTHON_SOURCE_ROOT_TYPE, source)
}
excludedUrls = extProject.excludedRoots.map { excludedRoot ->
ExcludeUrlEntity(excludedRoot.toVirtualFileUrl(fileUrlManager), source)
}
})
exModuleOptions = ExternalSystemModuleOptionsEntity(source) {
externalSystem = systemName
linkedProjectId = extProject.fullName
}
}
}
return@withContext storage
}
/**
* Removes the default IJ module created for the root of the project
* (that's going to be replaced with a module belonging to a specific project management system).
*
* @see com.intellij.openapi.project.impl.getOrInitializeModule
*/
internal fun removeFakeModuleEntity(project: Project, storage: MutableEntityStorage) {
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val basePathUrl = project.baseNioPath?.toVirtualFileUrl(virtualFileUrlManager) ?: return
val contentRoots = storage
.entitiesBySource { !getEntitySourceClass().isInstance(it) }
.filterIsInstance<ContentRootEntity>()
.filter { it.url == basePathUrl }
.toList()
for (entity in contentRoots) {
storage.removeEntity(entity.module)
storage.removeEntity(entity)
}
}
private val Project.baseNioPath: Path?
get() = basePath?.let { Path.of(it) }
companion object {
// For the time being mark them as java-sources to indicate that in the Project tool window
val PYTHON_SOURCE_ROOT_TYPE: SourceRootTypeId = SourceRootTypeId("java-source")
}
}

View File

@@ -1,38 +0,0 @@
// 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
import com.intellij.ide.impl.runUnderModalProgressIfIsEdt
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.projectImport.ProjectOpenProcessor
/**
* Automatically configures a new project without `.idea/` as a project managed by Poetry if there is
* a top-level pyproject.toml at the project root.
* The user will be asked if
* - There are several possible build systems for the project.
* - The top-level pyproject.toml is added afterward in a project with existing `.idea/`.
* - pyproject.toml files are found in non-top-level directories (requires IJPL-180733).
*/
internal abstract class PyProjectTomlOpenProcessorBase : ProjectOpenProcessor() {
abstract val importProvider: AbstractOpenProjectProvider
final override fun canOpenProject(file: VirtualFile): Boolean = enablePyProjectToml && importProvider.canOpenProject(file)
final override suspend fun openProjectAsync(
virtualFile: VirtualFile,
projectToClose: Project?,
forceOpenInNewFrame: Boolean,
): Project? {
return importProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame)
}
final override fun canImportProjectAfterwards(): Boolean = true
// TODO Requires IJPL-180733
final override suspend fun importProjectAfterwardsAsync(project: Project, file: VirtualFile) {
importProvider.linkToExistingProjectAsync(file, project)
}
}

View File

@@ -1,9 +0,0 @@
// 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.poetry
import com.intellij.openapi.externalSystem.model.ProjectSystemId
object PoetryConstants {
const val POETRY_LOCK: String = "poetry.lock"
val SYSTEM_ID: ProjectSystemId = ProjectSystemId("Poetry")
}

View File

@@ -1,23 +0,0 @@
// 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.poetry
import com.intellij.platform.workspace.storage.EntitySource
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
/**
* Identifies workspace model entities managed by Poetry.
*/
class PoetryEntitySource(val projectPath: VirtualFileUrl) : EntitySource {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PoetryEntitySource
return projectPath == other.projectPath
}
override fun hashCode(): Int {
return projectPath.hashCode()
}
}

View File

@@ -1,52 +0,0 @@
// 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.poetry
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.platform.backend.observation.ActivityKey
import com.intellij.platform.backend.observation.launchTracked
import com.intellij.platform.backend.observation.trackActivityBlocking
import com.jetbrains.python.PyBundle
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.poetry.PoetryLinkAction.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.Nls
/**
* Discovers and links as managed by Poetry all relevant project roots and saves them in `.idea/poetry.xml`.
* For a tree of nested poetry projects, only the topmost directories are linked.
*/
internal class PoetryLinkAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val basePath = project.basePath ?: return
project.trackActivityBlocking(PoetryLinkActivityKey) {
project.coroutineScope.launchTracked {
PoetryProjectModelService.linkAllProjectModelRoots(project, basePath)
}
}
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = enablePyProjectToml
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
object PoetryLinkActivityKey : ActivityKey {
override val presentableName: @Nls String
get() = PyBundle.message("python.project.model.activity.key.poetry.link")
}
@Service(Service.Level.PROJECT)
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
companion object {
val Project.coroutineScope: CoroutineScope
get() = service<CoroutineScopeService>().coroutineScope
}
}
}

View File

@@ -1,112 +0,0 @@
// 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.poetry
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.externalSystem.autoimport.*
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.util.io.toCanonicalPath
import com.intellij.platform.backend.observation.launchTracked
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.workspace.jps.entities.ContentRootEntity
import com.intellij.platform.workspace.storage.entities
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.workspaceModel.ide.toPath
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.poetry.PoetryProjectAware.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import java.nio.file.Path
/**
* Tracks changes in pyproject.toml files and suggests syncing their changes with the project model
* according to the `Settings | Build, Execution, Deployment | Build Tools` settings.
*/
internal class PoetryProjectAware(
private val project: Project,
override val projectId: ExternalSystemProjectId,
) : ExternalSystemProjectAware {
override val settingsFiles: Set<String>
get() = collectSettingFiles()
override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) {
project.messageBus.connect(parentDisposable).subscribe(PoetrySyncListener.TOPIC, object : PoetrySyncListener {
override fun onStart(projectRoot: Path) = listener.onProjectReloadStart()
override fun onFinish(projectRoot: Path) = listener.onProjectReloadFinish(status = ExternalSystemRefreshStatus.SUCCESS)
})
}
override fun reloadProject(context: ExternalSystemProjectReloadContext) {
project.coroutineScope.launchTracked {
PoetryProjectModelService.syncProjectModelRoot(project, Path.of(projectId.externalProjectPath))
}
}
// Called after sync
private fun collectSettingFiles(): Set<String> {
val source = PoetryEntitySource(projectId.externalProjectPath.toVirtualFileUrl(project))
return project.workspaceModel.currentSnapshot
.entities<ContentRootEntity>()
.filter { it.entitySource == source }
.map { it.url.toPath() }
.map { it.resolve(PY_PROJECT_TOML) }
.map { it.toCanonicalPath() }
.toSet()
}
private fun String.toVirtualFileUrl(project: Project): VirtualFileUrl {
return Path.of(this).toVirtualFileUrl(project.workspaceModel.getVirtualFileUrlManager())
}
@Service(Service.Level.PROJECT)
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
companion object {
val Project.coroutineScope: CoroutineScope
get() = service<CoroutineScopeService>().coroutineScope
}
}
internal class PoetrySyncStartupActivity : ProjectActivity {
init {
if (!enablePyProjectToml) {
throw ExtensionNotApplicableException.create()
}
}
override suspend fun execute(project: Project) {
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
project.service<PoetrySettings>().getLinkedProjects().forEach { projectRoot ->
val projectId = ExternalSystemProjectId(PoetryConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
val projectAware = PoetryProjectAware(project, projectId)
projectTracker.register(projectAware)
projectTracker.activate(projectId)
}
}
}
internal class PoetryListener(private val project: Project) : PoetrySettingsListener {
init {
if (!enablePyProjectToml) {
throw ExtensionNotApplicableException.create()
}
}
override fun onLinkedProjectAdded(projectRoot: Path) {
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
val projectId = ExternalSystemProjectId(PoetryConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
val projectAware = PoetryProjectAware(project, projectId)
projectTracker.register(projectAware)
projectTracker.activate(projectId)
}
override fun onLinkedProjectRemoved(projectRoot: Path) {
val projectId = ExternalSystemProjectId(PoetryConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
ExternalSystemProjectTracker.getInstance(project).remove(projectId)
}
}
}

View File

@@ -1,107 +0,0 @@
// 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.poetry
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.jetbrains.python.projectModel.ExternalProject
import com.jetbrains.python.projectModel.ExternalProjectDependency
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.net.URI
import java.nio.file.Path
import kotlin.io.path.*
// e.g. "lib @ file:///home/user/projects/main/lib"
private val PEP_621_PATH_DEPENDENCY = """([\w-]+) @ (file:.*)""".toRegex()
data class PoetryProject(
override val name: String,
override val root: Path,
override val dependencies: List<ExternalProjectDependency>,
) : ExternalProject {
override val sourceRoots: List<Path>
get() = listOfNotNull((root / "src").takeIf { it.isDirectory() })
override val excludedRoots: List<Path>
get() = emptyList()
// Poetry projects don't have any declarative hierarchical structure
override val fullName: String?
get() = name
}
@OptIn(ExperimentalPathApi::class)
internal object PoetryProjectModelResolver : PythonProjectModelResolver<PoetryProject> {
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<PoetryProject>? {
if (!root.resolve(PY_PROJECT_TOML).exists()) {
return null
}
val poetryProjects = root.walk()
.filter { it.name == PY_PROJECT_TOML }
.mapNotNull(::readPoetryPyProjectToml)
.toList()
if (poetryProjects.isNotEmpty()) {
val modules = poetryProjects
.map {
PoetryProject(
name = it.projectName,
root = it.root,
dependencies = it.editablePathDependencies.map { entry ->
ExternalProjectDependency(entry.key, entry.value)
})
}
return ExternalProjectGraph(
root = root,
projects = modules
)
}
return null
}
private fun readPoetryPyProjectToml(pyprojectTomlPath: Path): PoetryPyProjectToml? {
val pyprojectToml = Toml.parse(pyprojectTomlPath)
val projectName = pyprojectToml.getString("tool.poetry.name") ?: pyprojectToml.getString("project.name")
if (projectName == null) {
return null
}
val moduleDependencies = pyprojectToml.getArrayOrEmpty("project.dependencies")
.toList()
.filterIsInstance<String>()
.mapNotNull { depSpec ->
val match = PEP_621_PATH_DEPENDENCY.matchEntire(depSpec)
if (match == null) return@mapNotNull null
val (depName, depUri) = match.destructured
val depPath = runCatching { Path.of(URI(depUri)) }.getOrNull() ?: return@mapNotNull null
if (!depPath.isDirectory() || !depPath.resolve(PY_PROJECT_TOML).exists()) {
return@mapNotNull null
}
return@mapNotNull depName to depPath
}
.toMap()
val oldStyleModuleDependencies = pyprojectToml.getTableOrEmpty("tool.poetry.dependencies")
.toMap().entries
.mapNotNull { (depName, depSpec) ->
if (depSpec !is TomlTable || depSpec.getBoolean("develop") != true) return@mapNotNull null
val depPath = depSpec.getString("path")?.let { pyprojectTomlPath.parent.resolve(it).normalize() }
if (depPath == null || !depPath.isDirectory() || !depPath.resolve(PY_PROJECT_TOML).exists()) {
return@mapNotNull null
}
return@mapNotNull depName to depPath
}
.toMap()
return PoetryPyProjectToml(
projectName = projectName,
root = pyprojectTomlPath.parent,
editablePathDependencies = moduleDependencies.ifEmpty { oldStyleModuleDependencies }
)
}
private data class PoetryPyProjectToml(
val projectName: String,
val root: Path,
val editablePathDependencies: Map<String, Path>,
)
}

View File

@@ -1,37 +0,0 @@
// 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.poetry
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.workspace.storage.EntitySource
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.jetbrains.python.projectModel.BaseProjectModelService
import com.jetbrains.python.projectModel.ProjectModelSettings
import com.jetbrains.python.projectModel.ProjectModelSyncListener
import com.jetbrains.python.projectModel.PythonProjectModelResolver
import java.nio.file.Path
import kotlin.reflect.KClass
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
object PoetryProjectModelService : BaseProjectModelService<PoetryEntitySource, PoetryProject>() {
override val projectModelResolver: PythonProjectModelResolver<PoetryProject>
get() = PoetryProjectModelResolver
override val systemName: @NlsSafe String
get() = "Poetry"
override fun getSettings(project: Project): ProjectModelSettings = project.service<PoetrySettings>()
override fun getSyncListener(project: Project): ProjectModelSyncListener = project.messageBus.syncPublisher(PoetrySyncListener.TOPIC)
override fun createEntitySource(project: Project, singleProjectRoot: Path): EntitySource {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
return PoetryEntitySource(singleProjectRoot.toVirtualFileUrl(fileUrlManager))
}
override fun getEntitySourceClass(): KClass<out EntitySource> = PoetryEntitySource::class
}

View File

@@ -1,15 +0,0 @@
// 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.poetry
import com.jetbrains.python.PyBundle
import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.projectModel.PyProjectTomlOpenProcessorBase
import org.jetbrains.annotations.Nls
import javax.swing.Icon
internal class PoetryProjectOpenProcessor : PyProjectTomlOpenProcessorBase() {
override val importProvider = PoetryProjectOpenProvider()
override val name: @Nls String = PyBundle.message("python.project.model.poetry")
override val icon: Icon = PythonIcons.Python.Origami
}

View File

@@ -1,40 +0,0 @@
// 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.poetry
import com.intellij.openapi.components.service
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.readText
import com.intellij.openapi.vfs.toNioPathOrNull
import com.intellij.python.pyproject.PY_PROJECT_TOML
import java.nio.file.Path
internal class PoetryProjectOpenProvider() : AbstractOpenProjectProvider() {
override val systemId: ProjectSystemId = PoetryConstants.SYSTEM_ID
override fun isProjectFile(file: VirtualFile): Boolean {
return file.name == PoetryConstants.POETRY_LOCK || isPoetrySpecificPyProjectToml(file)
}
private fun isPoetrySpecificPyProjectToml(file: VirtualFile): Boolean {
return file.name == PY_PROJECT_TOML && POETRY_TOOL_TABLE_HEADER.find(file.readText()) != null
}
override suspend fun linkProject(projectFile: VirtualFile, project: Project) {
val projectDirectory = getProjectDirectory(projectFile)
val projectRootPath = projectDirectory.toNioPathOrNull() ?: Path.of(projectDirectory.path)
project.service<PoetrySettings>().addLinkedProject(projectRootPath)
PoetryProjectModelService.syncProjectModelRoot(project, projectRootPath)
}
suspend fun unlinkProject(project: Project, externalProjectPath: String) {
PoetryProjectModelService.forgetProjectModelRoot(project, Path.of(externalProjectPath))
}
private companion object {
val POETRY_TOOL_TABLE_HEADER: Regex = """\[tool\.poetry[.\w-]*]""".toRegex()
}
}

View File

@@ -1,49 +0,0 @@
// 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.poetry
import com.intellij.openapi.components.*
import com.intellij.openapi.project.Project
import com.jetbrains.python.projectModel.ProjectModelSettings
import java.net.URI
import java.nio.file.Path
import kotlin.io.path.toPath
// TODO SerializablePersistentStateComponent
@Service(Service.Level.PROJECT)
@State(name = "PoetrySettings", storages = [Storage("poetry.xml")])
class PoetrySettings(private val project: Project)
: SimplePersistentStateComponent<PoetrySettings.State>(State()), ProjectModelSettings {
class State() : BaseState() {
var linkedProjects: MutableList<String> by list()
}
override fun setLinkedProjects(projects: List<Path>) {
val oldLinkedProjects = getLinkedProjects()
val removedLinkedProjects = oldLinkedProjects - projects
val addedLinkedProjects = projects - oldLinkedProjects
val listener = project.messageBus.syncPublisher(PoetrySettingsListener.Companion.TOPIC)
removedLinkedProjects.forEach { listener.onLinkedProjectRemoved(it) }
addedLinkedProjects.forEach { listener.onLinkedProjectAdded(it) }
state.linkedProjects = projects.map { it.toUri().toString() }.toMutableList()
}
override fun getLinkedProjects(): List<Path> {
return state.linkedProjects.map { URI(it).toPath() }
}
override fun addLinkedProject(projectRoot: Path) {
val existing = getLinkedProjects()
if (projectRoot !in existing) {
setLinkedProjects(existing + listOf(projectRoot))
}
}
override fun removeLinkedProject(projectRoot: Path) {
val existing = getLinkedProjects()
if (projectRoot in existing) {
setLinkedProjects(existing - listOf(projectRoot))
}
}
}

View File

@@ -1,16 +0,0 @@
// 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.poetry
import com.intellij.util.messages.Topic
import java.nio.file.Path
// TODO Actions for linking/unlinking pyproject.toml files
internal interface PoetrySettingsListener {
companion object {
@Topic.ProjectLevel
val TOPIC: Topic<PoetrySettingsListener> = Topic(PoetrySettingsListener::class.java, Topic.BroadcastDirection.NONE)
}
fun onLinkedProjectAdded(projectRoot: Path): Unit = Unit
fun onLinkedProjectRemoved(projectRoot: Path): Unit = Unit
}

View File

@@ -1,50 +0,0 @@
// 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.poetry
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.platform.backend.observation.ActivityKey
import com.intellij.platform.backend.observation.launchTracked
import com.intellij.platform.backend.observation.trackActivityBlocking
import com.jetbrains.python.PyBundle
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.poetry.PoetrySyncAction.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.Nls
/**
* Forcibly syncs all *already linked* Poetry projects, overriding their workspace models.
*/
internal class PoetrySyncAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
project.trackActivityBlocking(PoetryActivityKey) {
project.coroutineScope.launchTracked {
PoetryProjectModelService.syncAllProjectModelRoots(project = project)
}
}
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = enablePyProjectToml
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
object PoetryActivityKey : ActivityKey {
override val presentableName: @Nls String
get() = PyBundle.message("python.project.model.activity.key.poetry.sync")
}
@Service(Service.Level.PROJECT)
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
companion object {
val Project.coroutineScope: CoroutineScope
get() = service<CoroutineScopeService>().coroutineScope
}
}
}

View File

@@ -1,18 +0,0 @@
// 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.poetry
import com.intellij.util.messages.Topic
import com.jetbrains.python.projectModel.ProjectModelSyncListener
import java.nio.file.Path
internal interface PoetrySyncListener : ProjectModelSyncListener {
companion object {
@Topic.ProjectLevel
val TOPIC: Topic<PoetrySyncListener> = Topic(PoetrySyncListener::class.java, Topic.BroadcastDirection.NONE)
}
// Add onFailure
// Add onCancel
override fun onStart(projectRoot: Path): Unit = Unit
override fun onFinish(projectRoot: Path): Unit = Unit
}

View File

@@ -0,0 +1,78 @@
// 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.poetry
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.util.NlsSafe
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.model.spi.*
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.venvReader.Directory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.tuweni.toml.TomlTable
import java.net.URI
import java.net.URISyntaxException
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.toPath
internal class PoetryTool : Tool {
override val id: ToolId = ToolId("poetry")
override suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory> = emptySet()
override suspend fun getProjectName(projectToml: TomlTable): @NlsSafe String? =
projectToml.getString("tool.poetry.name")
override suspend fun getProjectStructure(entries: Map<ProjectName, PyProjectTomlProject>, rootIndex: Map<Directory, ProjectName>): ProjectStructureInfo = withContext(Dispatchers.Default) {
val deps = entries.asSequence().map { (name, entry) ->
val deps = getDependencies(entry.root, entry.pyProjectToml).mapNotNull { dir ->
rootIndex[dir] ?: run {
logger.warn("Can't find project for dir $dir")
null
}
}.toSet()
Pair(name, deps)
}.toMap()
return@withContext ProjectStructureInfo(dependencies = deps, membersToWorkspace = emptyMap()) // No workspace info (yet)
}
@RequiresBackgroundThread
private fun getDependencies(rootDir: Directory, projectToml: PyProjectToml): Set<Directory> {
val depsFromFile = projectToml.project?.dependencies?.project ?: emptyList()
val moduleDependencies = depsFromFile
.mapNotNull { depSpec ->
val match = PEP_621_PATH_DEPENDENCY.matchEntire(depSpec) ?: return@mapNotNull null
val (_, depUri) = match.destructured
return@mapNotNull parseDepUri(depUri)
}
val oldStyleModuleDependencies = projectToml.toml.getTableOrEmpty("tool.poetry.dependencies")
.toMap().entries
.mapNotNull { (_, depSpec) ->
if (depSpec !is TomlTable || depSpec.getBoolean("develop") != true) return@mapNotNull null
depSpec.getString("path")?.let { rootDir.resolve(it).normalize() }
}
return moduleDependencies.toSet() + oldStyleModuleDependencies.toSet()
}
}
// e.g. "lib @ file:///home/user/projects/main/lib"
private val PEP_621_PATH_DEPENDENCY = """([\w-]+) @ (file:.*)""".toRegex()
private val logger = fileLogger()
private fun parseDepUri(depUri: String): Path? =
try {
URI(depUri).toPath()
}
catch (e: InvalidPathException) {
logger.info("Dep $depUri points to wrong path", e)
null
}
catch (e: URISyntaxException) {
logger.info("Dep $depUri can't be parsed", e)
null
}

View File

@@ -1,43 +0,0 @@
// 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.poetry
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
import com.intellij.openapi.externalSystem.autolink.ExternalSystemUnlinkedProjectAware
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.io.toCanonicalPath
import com.intellij.openapi.vfs.VirtualFile
import com.jetbrains.python.projectModel.enablePyProjectToml
import java.nio.file.Path
internal class PoetryUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
private val openProvider = PoetryProjectOpenProvider()
override val systemId: ProjectSystemId = PoetryConstants.SYSTEM_ID
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean {
return enablePyProjectToml && openProvider.canOpenProject(buildFile)
}
override fun isLinkedProject(project: Project, externalProjectPath: String): Boolean {
val projectPath = Path.of(externalProjectPath)
return project.service<PoetrySettings>().getLinkedProjects().any { it == projectPath }
}
override fun subscribe(project: Project, listener: ExternalSystemProjectLinkListener, parentDisposable: Disposable) {
project.messageBus.connect(parentDisposable).subscribe(PoetrySettingsListener.TOPIC, object : PoetrySettingsListener {
override fun onLinkedProjectAdded(projectRoot: Path) = listener.onProjectLinked(projectRoot.toCanonicalPath())
override fun onLinkedProjectRemoved(projectRoot: Path) = listener.onProjectUnlinked(projectRoot.toCanonicalPath())
})
}
override suspend fun linkAndLoadProjectAsync(project: Project, externalProjectPath: String) {
openProvider.linkToExistingProjectAsync(externalProjectPath, project)
}
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
openProvider.unlinkProject(project, externalProjectPath)
}
}

View File

@@ -1,149 +0,0 @@
// 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
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.FileVisitResult
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.visitFileTree
/**
* Convert `pyproject.toml` to modules
*/
val enablePyProjectToml: Boolean get() = Registry.`is`("python.pyproject.model")
/**
* Represents a graph of modules residing under a common root directory.
* 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<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).
*/
interface ExternalProject {
val name: String
val root: Path
val dependencies: List<ExternalProjectDependency>
val sourceRoots: List<Path>
val excludedRoots: List<Path>
/**
* The colon separated fully qualified name of the project in the form `root:subproject:subsubproject`
* if there is the given project system supports hierarchical organization of projects (e.g., uv workspaces).
*/
val fullName: String?
}
data class ExternalProjectDependency(val name: String, val path: Path)
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
* declared inside this root.
* All these module roots should reside withing the `root` directory but their dependencies might be outside it.
* The root directory of the graph should be `root`.
*
* For instance, in the following layout (assuming that pyproject.toml indicates a valid project root)
* ```
* libs/
* project1/
* pyproject.toml
* project2/
* pyproject.toml
* ```
* 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.
*/
@RequiresBackgroundThread
fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<P>?
/**
* Find all project model graphs within the given directory (presumably the root directory of an IJ project).
* All these graphs are supposed to be independent components, i.e., they don't depend on each other's modules.
* The roots of these modules might not themselves be valid modules, but just plain directories.
*
* For instance, in the following layout
* ```
* libs/
* project1/
* pyproject.toml
* project2/
* pyproject.toml
* ```
* 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)
suspend fun discoverIndependentProjectGraphs(root: Path): List<ExternalProjectGraph<P>> = withContext(Dispatchers.IO) {
val graphs = mutableListOf<ExternalProjectGraph<P>>()
root.visitFileTree {
onPreVisitDirectory { dir, _ ->
val buildSystemRoot = discoverProjectRootSubgraph(dir)
if (buildSystemRoot != null) {
graphs.add(buildSystemRoot)
return@onPreVisitDirectory FileVisitResult.SKIP_SUBTREE
}
return@onPreVisitDirectory FileVisitResult.CONTINUE
}
}
// TODO make sure that roots doesn't leave ijProjectRoot boundaries
return@withContext mergeRootsReferringToEachOther(graphs)
}
}
interface ProjectModelSettings {
fun getLinkedProjects(): List<Path>
fun setLinkedProjects(projects: List<Path>)
fun removeLinkedProject(projectRoot: Path)
fun addLinkedProject(projectRoot: Path)
}
interface ProjectModelSyncListener {
fun onStart(projectRoot: Path): Unit = Unit
fun onFinish(projectRoot: Path): Unit = Unit
}
@RequiresBackgroundThread
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)!! }
}
val expandedProjectRoots = roots.map { root ->
val allModuleRootsAndDependencies = root.projects.asSequence()
.flatMap { module -> listOf(module.root) + module.dependencies.map { it.path } }
.distinct()
.toList()
root.copy(root = commonAncestorPath(allModuleRootsAndDependencies))
}
val expandedProjectRootsByRootPath = expandedProjectRoots.sortedBy { it.root }
val mergedProjectRoots = mutableListOf<ExternalProjectGraph<P>>()
for (root in expandedProjectRootsByRootPath) {
if (mergedProjectRoots.isEmpty()) {
mergedProjectRoots.add(root)
}
else {
val lastCluster = mergedProjectRoots.last()
if (root.root.startsWith(lastCluster.root)) {
mergedProjectRoots[mergedProjectRoots.lastIndex] = lastCluster.copy(projects = lastCluster.projects + root.projects)
}
else {
mergedProjectRoots.add(root)
}
}
}
return mergedProjectRoots
}

View File

@@ -1,9 +0,0 @@
// 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.openapi.externalSystem.model.ProjectSystemId
object UvConstants {
const val UV_LOCK: String = "uv.lock"
val SYSTEM_ID: ProjectSystemId = ProjectSystemId("uv")
}

View File

@@ -1,23 +0,0 @@
// 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.platform.workspace.storage.EntitySource
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
/**
* Identifies workspace model entities managed by uv.
*/
class UvEntitySource(val projectPath: VirtualFileUrl) : EntitySource {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UvEntitySource
return projectPath == other.projectPath
}
override fun hashCode(): Int {
return projectPath.hashCode()
}
}

View File

@@ -1,53 +0,0 @@
// 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.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.platform.backend.observation.ActivityKey
import com.intellij.platform.backend.observation.launchTracked
import com.intellij.platform.backend.observation.trackActivityBlocking
import com.jetbrains.python.PyBundle
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.uv.UvLinkAction.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.Nls
/**
* Discovers and links as managed by uv all relevant project roots and saves them in `.idea/uv.xml`.
* For a tree of nested uv projects, only the topmost directories are linked.
*/
internal class UvLinkAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val basePath = project.basePath ?: return
project.trackActivityBlocking(UvLinkActivityKey) {
project.coroutineScope.launchTracked {
UvProjectModelService.linkAllProjectModelRoots(project, basePath)
}
}
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = enablePyProjectToml
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
object UvLinkActivityKey : ActivityKey {
override val presentableName: @Nls String
get() = PyBundle.message("python.project.model.activity.key.uv.link")
}
@Service(Service.Level.PROJECT)
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
companion object {
val Project.coroutineScope: CoroutineScope
get() = service<CoroutineScopeService>().coroutineScope
}
}
}

View File

@@ -1,112 +0,0 @@
// 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.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.externalSystem.autoimport.*
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.util.io.toCanonicalPath
import com.intellij.platform.backend.observation.launchTracked
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.workspace.jps.entities.ContentRootEntity
import com.intellij.platform.workspace.storage.entities
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.intellij.platform.workspace.storage.url.VirtualFileUrl
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.workspaceModel.ide.toPath
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.uv.UvProjectAware.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import java.nio.file.Path
/**
* Tracks changes in pyproject.toml files and suggests syncing their changes with the project model
* according to the `Settings | Build, Execution, Deployment | Build Tools` settings.
*/
internal class UvProjectAware(
private val project: Project,
override val projectId: ExternalSystemProjectId,
) : ExternalSystemProjectAware {
override val settingsFiles: Set<String>
get() = collectSettingFiles()
override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) {
project.messageBus.connect(parentDisposable).subscribe(UvSyncListener.Companion.TOPIC, object : UvSyncListener {
override fun onStart(projectRoot: Path) = listener.onProjectReloadStart()
override fun onFinish(projectRoot: Path) = listener.onProjectReloadFinish(status = ExternalSystemRefreshStatus.SUCCESS)
})
}
override fun reloadProject(context: ExternalSystemProjectReloadContext) {
project.coroutineScope.launchTracked {
UvProjectModelService.syncProjectModelRoot(project, Path.of(projectId.externalProjectPath))
}
}
// Called after sync
private fun collectSettingFiles(): Set<String> {
val source = UvEntitySource(projectId.externalProjectPath.toVirtualFileUrl(project))
return project.workspaceModel.currentSnapshot
.entities<ContentRootEntity>()
.filter { it.entitySource == source }
.map { it.url.toPath() }
.map { it.resolve(PY_PROJECT_TOML) }
.map { it.toCanonicalPath() }
.toSet()
}
private fun String.toVirtualFileUrl(project: Project): VirtualFileUrl {
return Path.of(this).toVirtualFileUrl(project.workspaceModel.getVirtualFileUrlManager())
}
@Service(Service.Level.PROJECT)
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
companion object {
val Project.coroutineScope: CoroutineScope
get() = service<CoroutineScopeService>().coroutineScope
}
}
internal class UvSyncStartupActivity : ProjectActivity {
init {
if (!enablePyProjectToml) {
throw ExtensionNotApplicableException.create()
}
}
override suspend fun execute(project: Project) {
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
project.service<UvSettings>().getLinkedProjects().forEach { projectRoot ->
val projectId = ExternalSystemProjectId(UvConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
val projectAware = UvProjectAware(project, projectId)
projectTracker.register(projectAware)
projectTracker.activate(projectId)
}
}
}
internal class UvListener(private val project: Project) : UvSettingsListener {
init {
if (!enablePyProjectToml) {
throw ExtensionNotApplicableException.create()
}
}
override fun onLinkedProjectAdded(projectRoot: Path) {
val projectTracker = ExternalSystemProjectTracker.getInstance(project)
val projectId = ExternalSystemProjectId(UvConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
val projectAware = UvProjectAware(project, projectId)
projectTracker.register(projectAware)
projectTracker.activate(projectId)
}
override fun onLinkedProjectRemoved(projectRoot: Path) {
val projectId = ExternalSystemProjectId(UvConstants.SYSTEM_ID, projectRoot.toCanonicalPath())
ExternalSystemProjectTracker.getInstance(project).remove(projectId)
}
}
}

View File

@@ -1,181 +0,0 @@
// 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.openapi.util.getPathMatcher
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.jetbrains.python.projectModel.ExternalProject
import com.jetbrains.python.projectModel.ExternalProjectDependency
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>,
override val fullName: String?,
val isWorkspace: Boolean,
val parentWorkspace: UvProject?,
) : ExternalProject {
override val sourceRoots: List<Path>
get() = listOfNotNull((root / "src").takeIf { it.isDirectory() })
override val excludedRoots: List<Path>
get() = listOfNotNull((root / DEFAULT_VENV_DIR).takeIf { it.isDirectory() })
}
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<UvProject> {
override fun discoverProjectRootSubgraph(root: Path): ExternalProjectGraph<UvProject>? {
if (!root.resolve(PY_PROJECT_TOML).exists()) {
return null
}
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 / PY_PROJECT_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,
fullName = it.projectName,
)
}
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,
fullName = wsRootToml.projectName,
)
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,
fullName = "${wsRootProject.name}:${wsMemberToml.projectName}"
))
}
}
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: List<PathMatcher>
val excludeGlobs: List<PathMatcher>
if (workspaceTable != null) {
includeGlobs = workspaceTable.getArrayOrEmpty("members")
.toList()
.filterIsInstance<String>()
.map { getPathMatcher(it) }
excludeGlobs = workspaceTable.getArrayOrEmpty("exclude")
.toList()
.filterIsInstance<String>()
.map { getPathMatcher(it) }
}
else {
includeGlobs = emptyList()
excludeGlobs = emptyList()
}
val workspaceDependencies = mutableListOf<String>()
val editablePathDependencies = mutableMapOf<String, Path>()
pyprojectToml.getTableOrEmpty("tool.uv.sources")
.toMap().entries
.forEach { (depName, depSpec) ->
if (depSpec is TomlTable) {
if (depSpec.getBoolean("workspace") == true) {
workspaceDependencies.add(depName)
}
else if (depSpec.getBoolean("editable") == true) {
val depPath = depSpec.getString("path")?.let { pyprojectTomlPath.parent.resolve(it).normalize() }
if (depPath != null && depPath.isDirectory() && (depPath / PY_PROJECT_TOML).exists()) {
editablePathDependencies[depName] = depPath
}
}
}
}
return UvPyProjectToml(
projectName = projectName,
root = pyprojectTomlPath.parent,
workspaceDependencies = workspaceDependencies,
pathDependencies = editablePathDependencies,
workspaceMemberPathMatchers = includeGlobs,
workspaceExcludePathMatchers = excludeGlobs,
)
}
}

View File

@@ -1,75 +0,0 @@
// 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.openapi.components.service
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.jps.entities.ModuleId
import com.intellij.platform.workspace.jps.entities.exModuleOptions
import com.intellij.platform.workspace.storage.EntitySource
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.intellij.workspaceModel.ide.legacyBridge.findModule
import com.jetbrains.python.projectModel.BaseProjectModelService
import com.jetbrains.python.projectModel.ProjectModelSettings
import com.jetbrains.python.projectModel.ProjectModelSyncListener
import com.jetbrains.python.projectModel.PythonProjectModelResolver
import java.nio.file.Path
import kotlin.reflect.KClass
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
object UvProjectModelService : BaseProjectModelService<UvEntitySource, UvProject>() {
override val projectModelResolver: PythonProjectModelResolver<UvProject>
get() = UvProjectModelResolver
override val systemName: @NlsSafe String
get() = "Uv"
override fun getSettings(project: Project): ProjectModelSettings = project.service<UvSettings>()
override fun getSyncListener(project: Project): ProjectModelSyncListener = project.messageBus.syncPublisher(UvSyncListener.TOPIC)
override fun createEntitySource(project: Project, singleProjectRoot: Path): EntitySource {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
return UvEntitySource(singleProjectRoot.toVirtualFileUrl(fileUrlManager))
}
override fun getEntitySourceClass(): KClass<out EntitySource> = UvEntitySource::class
fun findWorkspace(module: Module): UvWorkspace<Module>? {
val wsmSnapshot = module.project.workspaceModel.currentSnapshot
val moduleEntity = wsmSnapshot.resolve(ModuleId(module.name))!!
val workspace = findWorkspace(module.project, moduleEntity)
if (workspace == null) {
return null
}
return UvWorkspace(
root = workspace.root.findModule(wsmSnapshot)!!,
members = workspace.members.mapNotNull { it.findModule(wsmSnapshot) }.toSet(),
)
}
fun findWorkspace(project: Project, module: ModuleEntity): UvWorkspace<ModuleEntity>? {
val fullName = module.exModuleOptions?.linkedProjectId
if (fullName == null) return null
val workspaceName = fullName.split(":")[0]
val currentSnapshot = project.workspaceModel.currentSnapshot
val rootModule = currentSnapshot.resolve(ModuleId(workspaceName))
return UvWorkspace(
root = rootModule!!,
members = currentSnapshot.entitiesBySource { it is UvEntitySource }
.filterIsInstance<ModuleEntity>()
.filter {
val externalId = it.exModuleOptions?.linkedProjectId
externalId != null && externalId.startsWith("$workspaceName:")
}
.toSet(),
)
}
data class UvWorkspace<T>(val root: T, val members: Set<T>)
}

View File

@@ -1,14 +0,0 @@
// 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.jetbrains.python.PyBundle
import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.projectModel.PyProjectTomlOpenProcessorBase
import org.jetbrains.annotations.Nls
import javax.swing.Icon
internal class UvProjectOpenProcessor : PyProjectTomlOpenProcessorBase() {
override val importProvider = UvProjectOpenProvider()
override val name: @Nls String = PyBundle.message("python.project.model.uv")
override val icon: Icon = PythonIcons.UV
}

View File

@@ -1,39 +0,0 @@
// 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.openapi.components.service
import com.intellij.openapi.externalSystem.importing.AbstractOpenProjectProvider
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.readText
import com.intellij.openapi.vfs.toNioPathOrNull
import com.intellij.python.pyproject.PY_PROJECT_TOML
import java.nio.file.Path
internal class UvProjectOpenProvider() : AbstractOpenProjectProvider() {
override val systemId: ProjectSystemId = UvConstants.SYSTEM_ID
override fun isProjectFile(file: VirtualFile): Boolean {
return file.name == UvConstants.UV_LOCK || isUvSpecificPyProjectToml(file)
}
private fun isUvSpecificPyProjectToml(file: VirtualFile): Boolean {
return file.name == PY_PROJECT_TOML && UV_TOOL_TABLE_HEADER.find(file.readText()) != null
}
override suspend fun linkProject(projectFile: VirtualFile, project: Project) {
val projectDirectory = getProjectDirectory(projectFile)
val projectRootPath = projectDirectory.toNioPathOrNull() ?: Path.of(projectDirectory.path)
project.service<UvSettings>().addLinkedProject(projectRootPath)
UvProjectModelService.syncProjectModelRoot(project, projectRootPath)
}
suspend fun unlinkProject(project: Project, externalProjectPath: String) {
UvProjectModelService.forgetProjectModelRoot(project, Path.of(externalProjectPath))
}
private companion object {
val UV_TOOL_TABLE_HEADER: Regex = """\[tool\.uv[.\w-]*]""".toRegex()
}
}

View File

@@ -1,49 +0,0 @@
// 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.openapi.components.*
import com.intellij.openapi.project.Project
import com.jetbrains.python.projectModel.ProjectModelSettings
import java.net.URI
import java.nio.file.Path
import kotlin.io.path.toPath
// TODO SerializablePersistentStateComponent
@Service(Service.Level.PROJECT)
@State(name = "UvSettings", storages = [Storage("uv.xml")])
class UvSettings(private val project: Project) :
SimplePersistentStateComponent<UvSettings.State>(State()), ProjectModelSettings {
class State() : BaseState() {
var linkedProjects: MutableList<String> by list()
}
override fun setLinkedProjects(projects: List<Path>) {
val oldLinkedProjects = getLinkedProjects()
val removedLinkedProjects = oldLinkedProjects - projects
val addedLinkedProjects = projects - oldLinkedProjects
val listener = project.messageBus.syncPublisher(UvSettingsListener.Companion.TOPIC)
removedLinkedProjects.forEach { listener.onLinkedProjectRemoved(it) }
addedLinkedProjects.forEach { listener.onLinkedProjectAdded(it) }
state.linkedProjects = projects.map { it.toUri().toString() }.toMutableList()
}
override fun getLinkedProjects(): List<Path> {
return state.linkedProjects.map { URI(it).toPath() }
}
override fun addLinkedProject(projectRoot: Path) {
val existing = getLinkedProjects()
if (projectRoot !in existing) {
setLinkedProjects(existing + listOf(projectRoot))
}
}
override fun removeLinkedProject(projectRoot: Path) {
val existing = getLinkedProjects()
if (projectRoot in existing) {
setLinkedProjects(existing - listOf(projectRoot))
}
}
}

View File

@@ -1,16 +0,0 @@
// 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.util.messages.Topic
import java.nio.file.Path
// TODO Actions for linking/unlinking pyproject.toml files
internal interface UvSettingsListener {
companion object {
@Topic.ProjectLevel
val TOPIC: Topic<UvSettingsListener> = Topic(UvSettingsListener::class.java, Topic.BroadcastDirection.NONE)
}
fun onLinkedProjectAdded(projectRoot: Path): Unit = Unit
fun onLinkedProjectRemoved(projectRoot: Path): Unit = Unit
}

View File

@@ -1,50 +0,0 @@
// 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.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.platform.backend.observation.ActivityKey
import com.intellij.platform.backend.observation.launchTracked
import com.intellij.platform.backend.observation.trackActivityBlocking
import com.jetbrains.python.PyBundle
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.uv.UvSyncAction.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.Nls
/**
* Forcibly syncs all *already linked* uv projects, overriding their workspace models.
*/
internal class UvSyncAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
project.trackActivityBlocking(UvActivityKey) {
project.coroutineScope.launchTracked {
UvProjectModelService.syncAllProjectModelRoots(project = project)
}
}
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = enablePyProjectToml
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
object UvActivityKey : ActivityKey {
override val presentableName: @Nls String
get() = PyBundle.message("python.project.model.activity.key.uv.sync")
}
@Service(Service.Level.PROJECT)
private class CoroutineScopeService(private val coroutineScope: CoroutineScope) {
companion object {
val Project.coroutineScope: CoroutineScope
get() = service<CoroutineScopeService>().coroutineScope
}
}
}

View File

@@ -1,18 +0,0 @@
// 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.util.messages.Topic
import com.jetbrains.python.projectModel.ProjectModelSyncListener
import java.nio.file.Path
internal interface UvSyncListener : ProjectModelSyncListener {
companion object {
@Topic.ProjectLevel
val TOPIC: Topic<UvSyncListener> = Topic(UvSyncListener::class.java, Topic.BroadcastDirection.NONE)
}
// Add onFailure
// Add onCancel
override fun onStart(projectRoot: Path): Unit = Unit
override fun onFinish(projectRoot: Path): Unit = Unit
}

View File

@@ -0,0 +1,134 @@
// 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.openapi.diagnostic.fileLogger
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.getPathMatcher
import com.intellij.python.pyproject.PY_PROJECT_TOML_BUILD_SYSTEM
import com.intellij.python.pyproject.model.spi.*
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.venvReader.Directory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.tuweni.toml.TomlArray
import org.apache.tuweni.toml.TomlTable
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.PathMatcher
import kotlin.io.path.relativeTo
internal class UvTool : Tool {
override val id: ToolId = ToolId("uv")
override suspend fun getProjectName(projectToml: TomlTable): @NlsSafe String? = null
override suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory> = withContext(Dispatchers.Default) {
if (toml.getString("${PY_PROJECT_TOML_BUILD_SYSTEM}.build-backend") == "uv_build") {
setOf(projectRoot.resolve("src"))
}
else {
emptySet()
}
}
override suspend fun getProjectStructure(entries: Map<ProjectName, PyProjectTomlProject>, rootIndex: Map<Directory, ProjectName>): ProjectStructureInfo = withContext(Dispatchers.Default) {
val workspaces = entries.mapNotNull { (name, entry) ->
val matchers = getWorkspaceMembers(entry.pyProjectToml.toml) ?: return@mapNotNull null
Pair(entry.root, Pair(matchers, name))
}.toMap()
val dirToProjectName = rootIndex.entries.toList()
val workspaceToMembers = HashMap<ProjectName, MutableSet<ProjectName>>()
val memberToWorkspace = HashMap<ProjectName, MutableSet<ProjectName>>()
for ((workspaceRoot, matchersAndName) in workspaces) {
val (matchers, workspaceName) = matchersAndName
for ((memberRoot, memberName) in dirToProjectName) {
if (!memberRoot.startsWith(workspaceRoot)) continue
if (matchers.match(memberRoot.relativeTo(workspaceRoot).normalize())) {
workspaceToMembers.getOrPut(workspaceName) { HashSet() }.add(memberName)
memberToWorkspace.getOrPut(memberName) { HashSet() }.add(workspaceName)
}
}
}
val dependencies = HashMap<ProjectName, Set<ProjectName>>()
for ((name, projectToml) in entries) {
val siblings = memberToWorkspace[name]?.mapNotNull { workspaceToMembers[it] }?.flatten()?.toSet() ?: continue
val (workspaceDeps, pathDeps) = getUvDependencies(projectToml) ?: continue
val brokenDeps = workspaceDeps - siblings
if (brokenDeps.isNotEmpty()) {
logger.info("Deps are broken: ${brokenDeps.joinToString(", ")}")
}
val pathDepsWithName = pathDeps.mapNotNull {
val name = rootIndex[it]
if (name == null) {
logger.info("No module at ${it}")
}
name
}
dependencies[name] = (workspaceDeps intersect siblings) + pathDepsWithName
}
return@withContext ProjectStructureInfo(
dependencies = dependencies,
membersToWorkspace = memberToWorkspace.map { (member, workspaces) ->
val workspaceCount = workspaces.size
assert(workspaceCount != 0) { "Workspace can't be empty for $member" }
if (workspaceCount > 1) {
logger.warn("more than one workspace for member $member, will use the first one")
}
Pair(member, workspaces.first())
}.toMap()
)
}
@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
if (members.isEmpty()) return null
return WorkspaceInfo(members = members, exclude = exclude)
}
@RequiresBackgroundThread
private fun getUvDependencies(pyProject: PyProjectTomlProject): DependencyInfo? {
val sources = pyProject.pyProjectToml.toml.getTable("tool.uv.sources") ?: return null
val deps = pyProject.pyProjectToml.project?.dependencies?.project?.toSet() ?: return null
val workspaceDeps = mutableListOf<ProjectName>()
val pathDeps = hashSetOf<Path>()
for ((depName, depTable) in sources.toMap().entries) {
if (depName !in deps) continue
val table = depTable as? TomlTable ?: continue
if (table.getBoolean("workspace") == true) {
workspaceDeps.add(ProjectName(depName))
}
else {
val path = table.getString("path") ?: continue
try {
pathDeps.add(pyProject.root.resolve(path).normalize())
}
catch (e: InvalidPathException) {
logger.info("Can't resolve $path against ${pyProject.root}", e)
}
}
}
return DependencyInfo(workspaceDeps = workspaceDeps.toSet(), pathDeps = pathDeps)
}
}
private data class WorkspaceInfo(val members: List<PathMatcher>, val exclude: List<PathMatcher>) {
fun match(path: Path): Boolean =
members.any { it.matches(path) } && exclude.none { it.matches(path) }
}
private val TomlArray.asMatchers: List<PathMatcher> get() = toList().filterIsInstance<String>().map { getPathMatcher(it) }
private val logger = fileLogger()
private data class DependencyInfo(val workspaceDeps: Set<ProjectName>, val pathDeps: Set<Directory>)

View File

@@ -1,43 +0,0 @@
// 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.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.externalSystem.autolink.ExternalSystemProjectLinkListener
import com.intellij.openapi.externalSystem.autolink.ExternalSystemUnlinkedProjectAware
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.io.toCanonicalPath
import com.intellij.openapi.vfs.VirtualFile
import com.jetbrains.python.projectModel.enablePyProjectToml
import java.nio.file.Path
internal class UvUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
private val openProvider = UvProjectOpenProvider()
override val systemId: ProjectSystemId = UvConstants.SYSTEM_ID
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean {
return enablePyProjectToml && openProvider.canOpenProject(buildFile)
}
override fun isLinkedProject(project: Project, externalProjectPath: String): Boolean {
val projectPath = Path.of(externalProjectPath)
return project.service<UvSettings>().getLinkedProjects().any { it == projectPath }
}
override fun subscribe(project: Project, listener: ExternalSystemProjectLinkListener, parentDisposable: Disposable) {
project.messageBus.connect(parentDisposable).subscribe(UvSettingsListener.Companion.TOPIC, object : UvSettingsListener {
override fun onLinkedProjectAdded(projectRoot: Path) = listener.onProjectLinked(projectRoot.toCanonicalPath())
override fun onLinkedProjectRemoved(projectRoot: Path) = listener.onProjectUnlinked(projectRoot.toCanonicalPath())
})
}
override suspend fun linkAndLoadProjectAsync(project: Project, externalProjectPath: String) {
openProvider.linkToExistingProjectAsync(externalProjectPath, project)
}
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
openProvider.unlinkProject(project, externalProjectPath)
}
}

View File

@@ -20,14 +20,11 @@ import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.PythonPluginDisposable
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import com.jetbrains.python.projectModel.enablePyProjectToml
import com.jetbrains.python.projectModel.uv.UvProjectModelService
import com.jetbrains.python.sdk.PySdkPopupFactory
import com.jetbrains.python.sdk.configuration.suppressors.PyInterpreterInspectionSuppressor
import com.jetbrains.python.sdk.configuration.suppressors.PyPackageRequirementsInspectionSuppressor
import com.jetbrains.python.sdk.configuration.suppressors.TipOfTheDaySuppressor
import com.jetbrains.python.sdk.configurePythonSdk
import com.jetbrains.python.sdk.uv.isUv
import com.jetbrains.python.statistics.ConfiguredPythonInterpreterIdsHolder.Companion.SDK_HAS_BEEN_CONFIGURED_AS_THE_PROJECT_INTERPRETER
import com.jetbrains.python.util.ShowingMessageErrorSync
import kotlinx.coroutines.Dispatchers
@@ -61,15 +58,6 @@ object PyProjectSdkConfiguration {
} ?: return false
// TODO Move this to PyUvSdkConfiguration, show better notification
if (sdk.isUv && enablePyProjectToml) {
val ws = UvProjectModelService.findWorkspace(module)
if (ws != null) {
for (wsModule in ws.members + ws.root) {
setReadyToUseSdk(wsModule.project, wsModule, sdk)
}
return true
}
}
setReadyToUseSdk(module.project, module, sdk)
return true
}

View File

@@ -1,136 +0,0 @@
// 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.poetry
import com.intellij.openapi.components.service
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.RegistryKey
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.intellij.testFramework.useProjectAsync
import com.intellij.testFramework.utils.io.createFile
import org.junit.jupiter.api.Test
import kotlin.io.path.writeText
import kotlin.time.Duration.Companion.seconds
@RegistryKey("python.pyproject.model", "true")
@TestApplication
class PoetryProjectOpenIntegrationTest {
private val testRoot by tempPathFixture()
private val multiprojectFixture by multiProjectFixture()
@Test
fun `project with top-level legacy pyproject-toml is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[tool.poetry]
name = "project"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(listOf(projectPath),
project.service<PoetrySettings>().getLinkedProjects())
}
}
@Test
fun `project with top-level PEP-621 pyproject-toml without tool-poetry table is not automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "project"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(emptyList(), project.service<PoetrySettings>().getLinkedProjects())
}
}
@Test
fun `project with top-level PEP-621 pyproject-toml containing tool-poetry table is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "project"
[tool.poetry]
requires-poetry = ">=2.0"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(listOf(projectPath),
project.service<PoetrySettings>().getLinkedProjects())
}
}
@Test
fun `project with top-level poetry-lock is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile("poetry.lock").writeText("""""")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "project"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(listOf(projectPath),
project.service<PoetrySettings>().getLinkedProjects())
}
}
@Test
fun `test monorepo without top-level pyproject-toml and with sibling path dependency`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile("libs/project1/pyproject.toml").writeText("""
[tool.poetry]
name = "project1"
[tool.poetry.dependencies]
project2 = { path = "../project2", develop = true }
""".trimIndent())
projectPath.createFile("libs/project2/pyproject.toml").writeText("""
[tool.poetry]
name = "project2"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
val poetrySettings = project.service<PoetrySettings>()
// Such projects without pyproject.toml or poetry.lock in the root cannot be recognized automatically
CollectionAssertions.assertEmpty(poetrySettings.getLinkedProjects())
ModuleAssertions.assertModules(project, "project")
multiprojectFixture.awaitProjectConfiguration(project) {
PoetryProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
PoetryProjectModelService.syncAllProjectModelRoots(project)
}
ModuleAssertions.assertModules(project, "project", "project1", "project2")
CollectionAssertions.assertEqualsUnordered(
listOf(projectPath.resolve("libs")),
poetrySettings.getLinkedProjects()
)
ModuleAssertions.assertModuleEntity(project, "project1") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "project2")
}
ModuleAssertions.assertModuleEntity(project, "project2") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
}
}
}
}

View File

@@ -1,134 +0,0 @@
// 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.poetry
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
import com.intellij.openapi.project.Project
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.testFramework.assertion.moduleAssertion.ContentRootAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions.INHERITED_SDK
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions.MODULE_SOURCE
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.SourceRootAssertions
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.RegistryKey
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.projectFixture
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.intellij.testFramework.utils.io.createFile
import com.jetbrains.python.projectModel.BaseProjectModelService.Companion.PYTHON_SOURCE_ROOT_TYPE
import com.jetbrains.python.projectModel.uv.UvConstants
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import kotlin.io.path.writeText
@RegistryKey("python.pyproject.model", "true")
@TestApplication
class PoetryProjectSyncIntegrationTest {
private val testRootFixture = tempPathFixture()
val testRoot by testRootFixture
private val project by projectFixture(testRootFixture, openAfterCreation = true)
private val multiprojectFixture by multiProjectFixture()
@Test
fun `src directory is mapped to module source root`() = timeoutRunBlocking {
testRoot.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "main"
dependencies = [
]
[tool.poetry]
packages = [{include = "main", from = "src"}]
""".trimIndent())
testRoot.createFile("src/main/__init__.py")
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
syncAllProjects(project)
ModuleAssertions.assertModules(project, "main")
SourceRootAssertions.assertSourceRoots(project, "main", { it.rootTypeId == PYTHON_SOURCE_ROOT_TYPE }, testRoot.resolve("src"))
}
@Test
fun `project with new-style PEP-621 path dependencies`() = timeoutRunBlocking {
testRoot.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "main"
dependencies = [
"lib @ ${testRoot.toUri()}/lib"
]
""".trimIndent())
testRoot.createFile("lib/pyproject.toml").writeText("""
[project]
name = "lib"
dependencies = [
]
""".trimIndent())
multiprojectFixture.linkProject(project, testRoot, PoetryConstants.SYSTEM_ID)
syncAllProjects(project)
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
ModuleAssertions.assertModules(project, "main", "lib")
ModuleAssertions.assertModuleEntity(project, "main") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot)
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE, "lib")
DependencyAssertions.assertModuleDependency(module, "lib") { dependency ->
Assertions.assertTrue(dependency.exported)
}
}
ModuleAssertions.assertModuleEntity(project, "lib") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib"))
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE)
}
}
// This format of path dependencies was used before Poetry 2.0
// https://python-poetry.org/history/#added-2
@Test
fun `project with old-style path dependencies`() = timeoutRunBlocking {
testRoot.createFile(
PY_PROJECT_TOML).writeText("""
[tool.poetry]
name = "main"
[tool.poetry.dependencies]
lib = {path = "./lib", develop = true}
""".trimIndent())
testRoot.createFile("lib/pyproject.toml").writeText("""
[tool.poetry]
name = "lib"
""".trimIndent())
multiprojectFixture.linkProject(project, testRoot, PoetryConstants.SYSTEM_ID)
syncAllProjects(project)
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
ModuleAssertions.assertModules(project, "main", "lib")
ModuleAssertions.assertModuleEntity(project, "main") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot)
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE, "lib")
DependencyAssertions.assertModuleDependency(module, "lib") { dependency ->
Assertions.assertTrue(dependency.exported)
}
}
ModuleAssertions.assertModuleEntity(project, "lib") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib"))
DependencyAssertions.assertDependencies(module, INHERITED_SDK, MODULE_SOURCE)
}
}
suspend fun syncAllProjects(project: Project) {
multiprojectFixture.awaitProjectConfiguration(project) {
PoetryProjectModelService.syncAllProjectModelRoots(project)
}
}
}

View File

@@ -1,145 +0,0 @@
// 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 }
}

View File

@@ -1,228 +0,0 @@
// 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.openapi.components.service
import com.intellij.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.ContentRootAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.RegistryKey
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.intellij.testFramework.useProjectAsync
import com.intellij.testFramework.utils.io.createFile
import org.junit.jupiter.api.Test
import kotlin.io.path.writeText
import kotlin.time.Duration.Companion.seconds
@RegistryKey("python.pyproject.model", "true")
@TestApplication
class UvProjectOpenIntegrationTest {
private val testRoot by tempPathFixture()
private val multiprojectFixture by multiProjectFixture()
@Test
fun `project with top-level uv-lock is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile("uv.lock").writeText("""
# This file is automatically generated by uv.
""".trimIndent())
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "project"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync {
ModuleAssertions.assertModules(it, "project")
CollectionAssertions.assertEqualsUnordered(listOf(projectPath), it.service<UvSettings>().getLinkedProjects())
}
}
@Test
fun `project with top-level pyproject-toml containing uv-tool table is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "project"
[tool.uv.workspace]
members = ["packages/*"]
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(listOf(projectPath), project.service<UvSettings>().getLinkedProjects())
}
}
@Test
fun `project with top-level pyproject-toml without uv-tool table is not automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "project"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(emptyList(), project.service<UvSettings>().getLinkedProjects())
}
}
@Test
fun `manually syncing a not recognized project removes its fake top-level module`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "myproject"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
// Nothing was linked automatically.
// There is only the module created by com.intellij.openapi.project.impl.CreateModuleKt.getOrInitializeModule
ModuleAssertions.assertModules(project, "project")
ContentRootAssertions.assertContentRoots(project, "project", listOf(projectPath))
CollectionAssertions.assertEqualsUnordered(emptyList(), project.service<UvSettings>().getLinkedProjects())
multiprojectFixture.awaitProjectConfiguration(project) {
UvProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
UvProjectModelService.syncAllProjectModelRoots(project)
}
// Now there is only the module owned by Uv
ModuleAssertions.assertModules(project, "myproject")
CollectionAssertions.assertEqualsUnordered(listOf(projectPath), project.service<UvSettings>().getLinkedProjects())
ContentRootAssertions.assertContentRoots(project, "myproject", listOf(projectPath))
}
}
@Test
fun `test monorepo without top-level pyproject-toml and with sibling path dependency`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile("libs/project1/pyproject.toml").writeText("""
[project]
name = "project1"
[tool.uv.sources]
project2 = { path = "../project2", editable = true }
""".trimIndent())
projectPath.createFile("libs/project2/pyproject.toml").writeText("""
[project]
name = "project2"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
val uvSettings = project.service<UvSettings>()
// Such projects without uv.lock or pyproject.toml with tool.uv in the root cannot be recognized automatically
CollectionAssertions.assertEmpty(uvSettings.getLinkedProjects())
ModuleAssertions.assertModules(project, "project")
multiprojectFixture.awaitProjectConfiguration(project) {
UvProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
UvProjectModelService.syncAllProjectModelRoots(project)
}
ModuleAssertions.assertModules(project, "project", "project1", "project2")
CollectionAssertions.assertEqualsUnordered(
listOf(projectPath.resolve("libs/")),
uvSettings.getLinkedProjects()
)
ModuleAssertions.assertModuleEntity(project, "project1") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "project2")
}
ModuleAssertions.assertModuleEntity(project, "project2") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
}
}
}
@Test
fun `test monorepo with two independent workspaces`() = timeoutRunBlocking(timeout = 20.seconds) {
val projectPath = testRoot.resolve("project")
projectPath.createFile("workspace1/pyproject.toml").writeText("""
[project]
name = "workspace1"
[tool.uv.workspace]
members = [
"lib1",
]
[tool.uv.sources]
lib1 = { workspace = true }
""".trimIndent())
projectPath.createFile("workspace1/lib1/pyproject.toml").writeText("""
[project]
name = "lib1"
""".trimIndent())
projectPath.createFile("workspace2/pyproject.toml").writeText("""
[project]
name = "workspace2"
[tool.uv.workspace]
members = [
"lib2",
]
[tool.uv.sources]
lib2 = { workspace = true }
""".trimIndent())
projectPath.createFile("workspace2/lib2/pyproject.toml").writeText("""
[project]
name = "lib2"
""".trimIndent())
multiprojectFixture.openProject(projectPath).useProjectAsync { project ->
val uvSettings = project.service<UvSettings>()
// Such projects without uv.lock or pyproject.toml with tool.uv in the root cannot be recognized automatically
CollectionAssertions.assertEmpty(uvSettings.getLinkedProjects())
ModuleAssertions.assertModules(project, "project")
multiprojectFixture.awaitProjectConfiguration(project) {
UvProjectModelService.linkAllProjectModelRoots(project, project.basePath!!)
UvProjectModelService.syncAllProjectModelRoots(project)
}
ModuleAssertions.assertModules(project, "project", "workspace1", "workspace2", "lib1", "lib2")
CollectionAssertions.assertEqualsUnordered(
listOf(
projectPath.resolve("workspace1"),
projectPath.resolve("workspace2")
),
uvSettings.getLinkedProjects()
)
ModuleAssertions.assertModuleEntity(project, "workspace1") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "lib1")
}
ModuleAssertions.assertModuleEntity(project, "workspace2") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "lib2")
}
ModuleAssertions.assertModuleEntity(project, "lib1") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
}
ModuleAssertions.assertModuleEntity(project, "lib2") { module ->
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
}
}
}
}

View File

@@ -1,224 +0,0 @@
// 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.openapi.externalSystem.testFramework.fixtures.multiProjectFixture
import com.intellij.openapi.project.Project
import com.intellij.platform.backend.workspace.workspaceModel
import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions.assertEqualsUnordered
import com.intellij.platform.testFramework.assertion.moduleAssertion.ContentRootAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.DependencyAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.ModuleAssertions
import com.intellij.platform.testFramework.assertion.moduleAssertion.SourceRootAssertions
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.jps.entities.ModuleId
import com.intellij.platform.workspace.jps.entities.exModuleOptions
import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.RegistryKey
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.projectFixture
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.intellij.testFramework.utils.io.createFile
import com.jetbrains.python.projectModel.BaseProjectModelService.Companion.PYTHON_SOURCE_ROOT_TYPE
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import java.nio.file.Path
import kotlin.io.path.writeText
@RegistryKey("python.pyproject.model", "true")
@TestApplication
class UvProjectSyncIntegrationTest {
private val testRootFixture = tempPathFixture()
val testRoot by testRootFixture
private val project by projectFixture(testRootFixture, openAfterCreation = true)
private val multiprojectFixture by multiProjectFixture()
@Test
fun `src directory is mapped to module source root`() = timeoutRunBlocking {
testRoot.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "main"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
""".trimIndent())
testRoot.createFile("src/main/__init__.py")
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
syncAllProjects(project)
ModuleAssertions.assertModules(project, "main")
SourceRootAssertions.assertSourceRoots(project, "main", { it.rootTypeId == PYTHON_SOURCE_ROOT_TYPE }, testRoot.resolve("src"))
}
@Test
fun `root dot venv directory is automatically excluded`() = timeoutRunBlocking {
testRoot.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "main"
dependencies = []
[tool.uv.workspace]
members = [
"lib",
]
""".trimIndent())
testRoot.createFile(".venv/pyvenv.cfg")
testRoot.createFile("lib/pyproject.toml").writeText("""
[project]
name = "lib"
dependencies = []
""".trimIndent())
testRoot.createFile("lib/.venv/pyvenv.cfg")
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
syncAllProjects(project)
ModuleAssertions.assertModules(project, "main", "lib")
assertExcludedRoots(project, "main", listOf(testRoot.resolve(".venv")))
assertExcludedRoots(project, "lib", listOf(testRoot.resolve("lib/.venv")))
}
@Test
fun `projects inside dot venv are skipped`() = timeoutRunBlocking {
testRoot.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "main"
""".trimIndent())
testRoot.createFile(".venv/lib/python3.13/site-packages/pandas/pyproject.toml").writeText("""
[project]
name = 'pandas'
""".trimIndent())
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
syncAllProjects(project)
ModuleAssertions.assertModules(project, "main")
}
@Test
fun `workspace project with a path dependency`() = timeoutRunBlocking {
testRoot.createFile(PY_PROJECT_TOML).writeText("""
[project]
name = "main"
dependencies = [
"lib1",
"lib2",
]
[tool.uv.workspace]
members = ["lib/lib1", "lib/lib2"]
[tool.uv.sources]
lib1 = { workspace = true }
lib2 = { workspace = true }
""".trimIndent())
testRoot.createFile("lib/lib1/pyproject.toml").writeText("""
[project]
name = "lib1"
dependencies = [
"pkg",
]
[tool.uv.sources]
pkg = { path = "../../packages/pkg", editable = true }
""".trimIndent())
testRoot.createFile("lib/lib2/pyproject.toml").writeText("""
[project]
name = "lib2"
dependencies = []
""".trimIndent())
testRoot.createFile("packages/pkg/pyproject.toml").writeText("""
[project]
name = "pkg"
dependencies = []
""".trimIndent())
multiprojectFixture.linkProject(project, testRoot, UvConstants.SYSTEM_ID)
syncAllProjects(project)
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
ModuleAssertions.assertModules(project, "main", "lib1", "lib2", "pkg")
val workspace = UvProjectModelService.UvWorkspace<ModuleEntity>(
root = project.findModule("main")!!,
members = setOf(
project.findModule("lib1")!!,
project.findModule("lib2")!!,
)
)
ModuleAssertions.assertModuleEntity(project, "main") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot)
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "lib1", "lib2")
DependencyAssertions.assertModuleDependency(module, "lib1") { dependency ->
Assertions.assertTrue(dependency.exported)
}
DependencyAssertions.assertModuleDependency(module, "lib2") { dependency ->
Assertions.assertTrue(dependency.exported)
}
Assertions.assertEquals("main", module.exModuleOptions?.linkedProjectId)
Assertions.assertEquals(workspace, UvProjectModelService.findWorkspace(project, module)
)
}
ModuleAssertions.assertModuleEntity(project, "lib1") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib/lib1"))
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE, "pkg")
DependencyAssertions.assertModuleDependency(module, "pkg") { dependency ->
Assertions.assertTrue(dependency.exported)
}
Assertions.assertEquals("main:lib1", module.exModuleOptions?.linkedProjectId)
Assertions.assertEquals(workspace, UvProjectModelService.findWorkspace(project, module)
)
}
ModuleAssertions.assertModuleEntity(project, "lib2") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("lib/lib2"))
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
Assertions.assertEquals("main:lib2", module.exModuleOptions?.linkedProjectId)
Assertions.assertEquals(workspace, UvProjectModelService.findWorkspace(project, module)
)
}
ModuleAssertions.assertModuleEntity(project, "pkg") { module ->
ContentRootAssertions.assertContentRoots(virtualFileUrlManager, module, testRoot.resolve("packages/pkg"))
DependencyAssertions.assertDependencies(module, DependencyAssertions.INHERITED_SDK, DependencyAssertions.MODULE_SOURCE)
Assertions.assertEquals("pkg", module.exModuleOptions?.linkedProjectId)
Assertions.assertEquals(
UvProjectModelService.UvWorkspace(module, emptySet()),
UvProjectModelService.findWorkspace(project, module)
)
}
}
private fun assertExcludedRoots(project: Project, moduleName: String, expectedRoots: List<Path>) {
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val expectedUrls = expectedRoots.map { it.normalize().toVirtualFileUrl(virtualFileUrlManager) }
ModuleAssertions.assertModuleEntity(project, moduleName) { moduleEntity ->
val actualRoots = moduleEntity.contentRoots
.flatMap { it.excludedUrls }
.map { it.url }
assertEqualsUnordered(expectedUrls, actualRoots)
}
}
private fun Project.findModule(name: String): ModuleEntity? {
return workspaceModel.currentSnapshot.resolve(ModuleId(name))
}
private suspend fun syncAllProjects(project: Project) {
multiprojectFixture.awaitProjectConfiguration(project) {
UvProjectModelService.syncAllProjectModelRoots(project)
}
}
}