PY-79488 Prototype multimodule Poetry project support

Whenever a pyproject.toml file is found in a new IJ project root
(without .idea) we register the corresponding root as managed by Poetry
in `.idea/poetry.xml` (so called "linking") and then read all
pyproject.toml in the project tree setting up the corresponding IJ
modules and dependencies between them (so called "syncing").
The changes are persisted in the workspace model cache, there are
no additional config files, besides `.idea/poetry.xml`.

If a new pyproject.xml module is added to the root of an existing
project, we ask user whether its project model should be applied.

When a pyproject.toml is then changed in the editor or externally,
we either automatically apply the changes (reloading) or ask
a user about it, depending on the settings in
"Settings | Build, Execution, Deployment | Build Tools".

If automatic linking and syncing doesn't work for some reason
(e.g. there is no top-level pyproject.toml as in grazie-ml),
there are manual actions "Link All Poetry Projects" and
"Sync All Poetry Projects".

GitOrigin-RevId: 77a480cdf56d45f22c943acbad3a7c20d5eb56a5
This commit is contained in:
Mikhail Golubev
2025-03-28 14:50:08 +00:00
committed by intellij-monorepo-bot
parent 5c8fdbc73c
commit 9131e29041
20 changed files with 879 additions and 1 deletions
@@ -0,0 +1,28 @@
// 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.impl
import com.intellij.platform.workspace.storage.WorkspaceEntityInternalApi
import com.intellij.platform.workspace.storage.metadata.impl.MetadataStorageBase
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() {
var typeMetadata: StorageTypeMetadata
typeMetadata = FinalClassMetadata.ClassMetadata(fqName = "com.jetbrains.python.projectModel.poetry.PoetryEntitySource", properties = listOf(OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "projectPath", valueType = ValueTypeMetadata.SimpleType.CustomType(isNullable = false, typeMetadata = FinalClassMetadata.KnownClass(fqName = "com.intellij.platform.workspace.storage.url.VirtualFileUrl")), withDefault = false),
OwnPropertyMetadata(isComputable = false, isKey = false, isOpen = false, name = "virtualFileUrl", valueType = ValueTypeMetadata.SimpleType.CustomType(isNullable = true, typeMetadata = FinalClassMetadata.KnownClass(fqName = "com.intellij.platform.workspace.storage.url.VirtualFileUrl")), withDefault = false)), supertypes = listOf("com.intellij.platform.workspace.storage.EntitySource"))
addMetadata(typeMetadata)
}
override fun initializeMetadataHash() {
addMetadataHash(typeFqn = "com.intellij.platform.workspace.storage.EntitySource", metadataHash = 371580623)
addMetadataHash(typeFqn = "com.jetbrains.python.projectModel.poetry.PoetryEntitySource", metadataHash = 1724807517)
}
}
@@ -145,5 +145,7 @@
<orderEntry type="library" name="io.github.z4kn4fein.semver.jvm" level="project" />
<orderEntry type="module" module-name="intellij.python.pyproject" />
<orderEntry type="module" module-name="intellij.python.hatch" />
<orderEntry type="module" module-name="intellij.platform.externalSystem.impl" />
<orderEntry type="module" module-name="intellij.platform.externalSystem" />
</component>
</module>
@@ -74,5 +74,11 @@
<orderEntry type="module" module-name="intellij.platform.todo" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.testFramework.testEnv" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.testFramework.testEnv.conda" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.externalSystem" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.externalSystem.impl" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.externalSystem.tests" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.backend.observation" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.workspace.jps" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.backend.workspace" scope="TEST" />
</component>
</module>
@@ -92,6 +92,9 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<pluginSuggestionProvider order="first" implementation="com.jetbrains.python.suggestions.PycharmProSuggestionProvider"/>
<postStartupActivity implementation="com.jetbrains.python.sdk.poetry.PoetryPyProjectTomlPostStartupActivity"/>
<projectOpenProcessor implementation="com.jetbrains.python.projectModel.poetry.PoetryOpenProcessor"/>
<externalSystemUnlinkedProjectAware implementation="com.jetbrains.python.projectModel.poetry.PoetryUnlinkedProjectAware"/>
<postStartupActivity implementation="com.jetbrains.python.projectModel.poetry.PoetryProjectAware$PoetrySyncStartupActivity"/>
</extensions>
<projectListeners>
@@ -105,6 +108,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
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"/>
</projectListeners>
<extensions defaultExtensionNs="com.intellij">
@@ -649,6 +654,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<!-- 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.project.model.poetry" defaultValue="false" restartRequired="true"
description="Automatically set up multi-module Poetry projects"/>
<feedback.idleFeedbackSurvey implementation="com.jetbrains.python.statistics.feedback.PythonJobSurvey"/>
<feedback.idleFeedbackSurvey implementation="com.jetbrains.python.statistics.feedback.PythonUvSupportSurvey"/>
@@ -1018,6 +1025,9 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<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"/>
<!--suppress PluginXmlI18n -->
<group id="Internal.Python" internal="true" popup="true" text="Python">
<!--suppress PluginXmlI18n -->
@@ -1626,4 +1626,14 @@ filter.install.package=Install Package
sdk.create.custom.override.warning=Existing environment at "{0}" will be overridden
sdk.create.custom.override.error=Environment at "{0}" already exists
sdk.create.custom.override.action=Override existing environment
sdk.create.custom.override.action=Override existing environment
python.project.model.progress.title.syncing.all.poetry.projects=Syncing all Poetry projects
python.project.model.progress.title.syncing.poetry.projects.at=Syncing Poetry projects at {0}
python.project.model.progress.title.unlinking.poetry.projects.at=Unlinking Poetry projects at {0}
python.project.model.activity.key.poetry.link=Poetry link
python.project.model.activity.key.poetry.sync=Poetry sync
python.project.model.progress.title.discovering.poetry.projects=Discovering Poetry projects
python.project.model.poetry=Poetry
action.Python.PoetrySync.text=Sync All Poetry Projects
action.Python.PoetryLink.text=Link All Poetry Projects
@@ -0,0 +1,9 @@
// 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 PYPROJECT_TOML: String = "pyproject.toml"
val SYSTEM_ID: ProjectSystemId = ProjectSystemId("Poetry")
}
@@ -0,0 +1,23 @@
// 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()
}
}
@@ -0,0 +1,59 @@
// 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.extensions.ExtensionNotApplicableException
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.intellij.platform.ide.progress.withBackgroundProgress
import com.jetbrains.python.PyBundle
import com.jetbrains.python.projectModel.poetry.PoetryLinkAction.CoroutineScopeService.Companion.coroutineScope
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.Nls
import java.nio.file.Path
/**
* 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.
*/
class PoetryLinkAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val poetrySettings = project.service<PoetrySettings>()
val basePath = project.basePath ?: return
project.trackActivityBlocking(PoetryLinkActivityKey) {
project.coroutineScope.launchTracked {
val allProjectRoots = withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.discovering.poetry.projects")) {
readProjectModelGraph(Path.of(basePath)).roots.map { it.root }
}
poetrySettings.setLinkedProjects(allProjectRoots)
}
}
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = Registry.`is`("python.project.model.poetry")
}
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
}
}
}
@@ -0,0 +1,50 @@
// 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.ide.impl.runUnderModalProgressIfIsEdt
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.projectImport.ProjectOpenProcessor
import com.jetbrains.python.PyBundle
import org.jetbrains.annotations.Nls
/**
* 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).
*/
class PoetryOpenProcessor: ProjectOpenProcessor() {
init {
if (!Registry.`is`("python.project.model.poetry")) {
throw ExtensionNotApplicableException.create()
}
}
private val importProvider = PoetryOpenProvider()
override val name: @Nls String = PyBundle.message("python.project.model.poetry")
override fun canOpenProject(file: VirtualFile): Boolean = importProvider.canOpenProject(file)
override fun doOpenProject(virtualFile: VirtualFile, projectToClose: Project?, forceOpenInNewFrame: Boolean): Project? {
return runUnderModalProgressIfIsEdt { importProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame) }
}
override suspend fun openProjectAsync(virtualFile: VirtualFile,
projectToClose: Project?,
forceOpenInNewFrame: Boolean): Project? {
return importProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame)
}
override fun canImportProjectAfterwards(): Boolean = true
// TODO Requires IJPL-180733
override suspend fun importProjectAfterwardsAsync(project: Project, file: VirtualFile) {
importProvider.linkToExistingProjectAsync(file, project)
}
}
@@ -0,0 +1,28 @@
// 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.toNioPathOrNull
import java.nio.file.Path
class PoetryOpenProvider() : AbstractOpenProjectProvider() {
override val systemId: ProjectSystemId = PoetryConstants.SYSTEM_ID
override fun isProjectFile(file: VirtualFile): Boolean = file.name == PoetryConstants.PYPROJECT_TOML
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)
PoetryProjectResolver.syncPoetryProject(project, projectRootPath)
}
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
PoetryProjectResolver.forgetPoetryProject(project, Path.of(externalProjectPath))
}
}
@@ -0,0 +1,117 @@
// 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.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.externalSystem.autoimport.ExternalSystemProjectTracker
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemRefreshStatus
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.util.io.toCanonicalPath
import com.intellij.openapi.util.registry.Registry
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.workspaceModel.ide.toPath
import com.jetbrains.python.projectModel.poetry.PoetryProjectAware.CoroutineScopeService.Companion.coroutineScope
import com.jetbrains.python.sdk.poetry.PY_PROJECT_TOML
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.
*/
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 {
PoetryProjectResolver.syncPoetryProject(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
}
}
private class PoetrySyncStartupActivity: ProjectActivity {
init {
if (!Registry.`is`("python.project.model.poetry")) {
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)
}
}
}
private class PoetryListener(private val project: Project): PoetrySettingsListener {
init {
if (!Registry.`is`("python.project.model.poetry")) {
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)
}
}
}
@@ -0,0 +1,133 @@
// 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.platform.backend.workspace.workspaceModel
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.workspace.jps.entities.ContentRootEntity
import com.intellij.platform.workspace.jps.entities.DependencyScope
import com.intellij.platform.workspace.jps.entities.InheritedSdkDependency
import com.intellij.platform.workspace.jps.entities.ModuleDependency
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.jps.entities.ModuleId
import com.intellij.platform.workspace.jps.entities.ModuleSourceDependency
import com.intellij.platform.workspace.jps.entities.SdkDependency
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 java.nio.file.Path
import kotlin.collections.plusAssign
/**
* Syncs the project model described in pyproject.toml files with the IntelliJ project model.
*/
object PoetryProjectResolver {
suspend fun syncAllPoetryProjects(project: Project) {
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.syncing.all.poetry.projects")) {
// TODO progress bar, listener with events
project.service<PoetrySettings>().getLinkedProjects().forEach {
syncPoetryProjectImpl(project, it)
}
}
}
suspend fun syncPoetryProject(project: Project, projectRoot: Path) {
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.syncing.poetry.projects.at", projectRoot)) {
syncPoetryProjectImpl(project, projectRoot)
}
}
suspend fun forgetPoetryProject(project: Project, projectRoot: Path) {
withBackgroundProgress(project = project, title = PyBundle.message("python.project.model.progress.title.unlinking.poetry.projects.at", projectRoot)) {
project.service<PoetrySettings>().removeLinkedProject(projectRoot)
forgetPoetryProjectImpl(project, projectRoot)
}
}
private suspend fun forgetPoetryProjectImpl(project: Project, projectRoot: Path) {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val source = PoetryEntitySource(projectRoot.toVirtualFileUrl(fileUrlManager))
project.workspaceModel.update("Forgetting a Poetry project at $projectRoot") { storage ->
storage.replaceBySource({ it == source }, MutableEntityStorage.Companion.create())
}
}
/**
* Synchronizes the poetry project by creating and updating module entities in the workspace model of the given project.
*
* @param project The IntelliJ IDEA project that needs synchronization.
* @param projectRoot The root path of the poetry project tree to be synchronized.
*/
private suspend fun syncPoetryProjectImpl(project: Project, projectRoot: Path) {
val listener = project.messageBus.syncPublisher(PoetrySyncListener.TOPIC)
listener.onStart(projectRoot)
try {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val source = PoetryEntitySource(projectRoot.toVirtualFileUrl(fileUrlManager))
val graph = readProjectModelRoot(projectRoot)
val storage = createProjectModel(project, graph?.modules.orEmpty(), source)
project.workspaceModel.update("Poetry sync at ${projectRoot}") { mutableStorage ->
// Fake module entity is added by default if nothing was discovered
if (projectRoot == project.baseNioPath) {
removeFakeModuleEntity(project, mutableStorage)
}
mutableStorage.replaceBySource({ it == source }, storage)
}
}
finally {
listener.onFinish(projectRoot)
}
}
private fun createProjectModel(
project: Project,
graph: List<ModuleDescriptor>,
source: PoetryEntitySource,
): EntityStorage {
val fileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val storage = MutableEntityStorage.create()
for (module in graph) {
val existingModuleEntity = project.workspaceModel.currentSnapshot
.entitiesBySource { it == source }
.filterIsInstance<ModuleEntity>()
.find { it.name == module.name }
val existingSdkEntity = existingModuleEntity
?.dependencies
?.find { it is SdkDependency } as? SdkDependency
val sdkDependency = existingSdkEntity ?: InheritedSdkDependency
storage addEntity ModuleEntity(module.name, emptyList(), source) {
dependencies += sdkDependency
dependencies += ModuleSourceDependency
for (moduleName in module.moduleDependencies) {
dependencies += ModuleDependency(ModuleId(moduleName), true, DependencyScope.COMPILE, false)
}
contentRoots = listOf(ContentRootEntity(module.root.toVirtualFileUrl(fileUrlManager), emptyList(), source))
}
}
return storage
}
/**
* Removes the default IJ module created for the root of the project
* (that's going to be replaced with another module managed by Poetry).
*/
fun removeFakeModuleEntity(project: Project, storage: MutableEntityStorage) {
val virtualFileUrlManager = project.workspaceModel.getVirtualFileUrlManager()
val basePathUrl = project.baseNioPath?.toVirtualFileUrl(virtualFileUrlManager) ?: return
val contentRoots = storage
.entitiesBySource { it !is PoetryEntitySource }
.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) }
}
@@ -0,0 +1,51 @@
// 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.BaseState
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.SimplePersistentStateComponent
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.State
import com.intellij.openapi.project.Project
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()) {
class State() : BaseState() {
var linkedProjects: MutableList<String> by list()
}
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()
}
fun getLinkedProjects(): List<Path> {
return state.linkedProjects.map { URI(it).toPath() }
}
fun addLinkedProject(projectRoot: Path) {
val existing = getLinkedProjects()
if (projectRoot !in existing) {
setLinkedProjects(existing + listOf(projectRoot))
}
}
fun removeLinkedProject(projectRoot: Path) {
val existing = getLinkedProjects()
if (projectRoot in existing) {
setLinkedProjects(existing - listOf(projectRoot))
}
}
}
@@ -0,0 +1,16 @@
// 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
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
}
@@ -0,0 +1,51 @@
// 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.extensions.ExtensionNotApplicableException
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.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.
*/
class PoetrySyncAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
project.trackActivityBlocking(PoetryActivityKey) {
project.coroutineScope.launchTracked {
PoetryProjectResolver.syncAllPoetryProjects(project = project)
}
}
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = Registry.`is`("python.project.model.poetry")
}
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
}
}
}
@@ -0,0 +1,17 @@
// 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
interface PoetrySyncListener {
companion object {
@Topic.ProjectLevel
val TOPIC: Topic<PoetrySyncListener> = Topic(PoetrySyncListener::class.java, Topic.BroadcastDirection.NONE)
}
// Add onFailure
// Add onCancel
fun onStart(projectRoot: Path): Unit = Unit
fun onFinish(projectRoot: Path): Unit = Unit
}
@@ -0,0 +1,48 @@
// 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.extensions.ExtensionNotApplicableException
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.util.registry.Registry
import com.intellij.openapi.vfs.VirtualFile
import java.nio.file.Path
class PoetryUnlinkedProjectAware : ExternalSystemUnlinkedProjectAware {
init {
if (!Registry.`is`("python.project.model.poetry")) {
throw ExtensionNotApplicableException.create()
}
}
override val systemId: ProjectSystemId = PoetryConstants.SYSTEM_ID
override fun isBuildFile(project: Project, buildFile: VirtualFile): Boolean {
return buildFile.name == PoetryConstants.PYPROJECT_TOML
}
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) {
PoetryOpenProvider().linkToExistingProjectAsync(externalProjectPath, project)
}
override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
PoetryOpenProvider().unlinkProject(project, externalProjectPath)
}
}
@@ -0,0 +1,112 @@
// 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.sdk.poetry.PY_PROJECT_TOML
import org.apache.tuweni.toml.Toml
import org.apache.tuweni.toml.TomlTable
import java.nio.file.FileVisitResult
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.name
import kotlin.io.path.visitFileTree
import kotlin.io.path.walk
/**
* Represents a "forest" of non-overlapping project roots managed by a particular build-system, such as Poetry.
*
* For instance, in the following structure
* ```
* root/
* project1/
* pyproject.toml
* lib1/
* pyproject.toml
* lib2/
* pyproject.toml
* project2/
* pyproject.toml
* ```
* `./project1` and `./project2` are considered project model roots, but not `./project1/lib1` or `./project1/lib2`
* because they are already under `project1`.
*/
data class ProjectModelGraph(val roots: List<ProjectModelRoot>)
/**
* Represents a tree of project modules residing under a single detectable project root (e.g. containing a root pyproject.toml).
* These modules might optionally depend on each other, but it's not a requirement.
*
* In the following structure:
*
* ```
* root/
* project1/
* pyproject.toml
* lib1/
* pyproject.toml
* lib2/
* pyproject.toml
* project2/
* pyproject.toml
* ```
*
* the project model root for `./project1` contains module descriptors for `./project1/pyproject.toml`,
* `./project1/lib1/pyproject.toml` and `./project1/lib2/pyproject.toml`.
*/
data class ProjectModelRoot(val root: Path, val modules: List<ModuleDescriptor>)
/**
* Defines a project module in a particular directory with its unique name, and a set of module dependencies
* (usually editable Python path dependencies to other modules in the same IJ project).
*/
data class ModuleDescriptor(val name: String, val root: Path, val moduleDependencies: List<String>)
@OptIn(ExperimentalPathApi::class)
fun readProjectModelGraph(ijProjectRoot: Path): ProjectModelGraph {
val roots = mutableListOf<ProjectModelRoot>()
ijProjectRoot.visitFileTree {
onPreVisitDirectory { dir, _ ->
if (dir.resolve(PY_PROJECT_TOML).exists()) {
val projectRoot = readProjectModelRoot(dir)
if (projectRoot != null) {
roots.add(projectRoot)
}
return@onPreVisitDirectory FileVisitResult.SKIP_SUBTREE
}
return@onPreVisitDirectory FileVisitResult.CONTINUE
}
}
return ProjectModelGraph(roots)
}
@OptIn(ExperimentalPathApi::class)
fun readProjectModelRoot(projectRoot: Path): ProjectModelRoot? {
val modules = projectRoot.walk()
.filter { it.name == PoetryConstants.PYPROJECT_TOML }
.map(::readPoetryPyProjectToml)
.toList()
if (modules.isNotEmpty()) {
return ProjectModelRoot(
root = projectRoot,
modules = modules
)
}
return null
}
private fun readPoetryPyProjectToml(pyprojectTomlPath: Path): ModuleDescriptor {
val pyprojectToml = Toml.parse(pyprojectTomlPath)
val moduleDependencies: List<String> = pyprojectToml.getTableOrEmpty("tool.poetry.dependencies")
.toMap().entries
.mapNotNull { (depName, depSpec) ->
if (depSpec is TomlTable && depSpec.getBoolean("develop") == true) {
val depPath = depSpec.getString("path")?.let { pyprojectTomlPath.parent.resolve(it) }
if (depPath != null && depPath.isDirectory() && depPath.resolve(PoetryConstants.PYPROJECT_TOML).exists()) {
return@mapNotNull depName
}
}
return@mapNotNull null
}
return ModuleDescriptor(pyprojectToml.getString("tool.poetry.name")!!, pyprojectTomlPath.parent, moduleDependencies)
}
@@ -0,0 +1,38 @@
// 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.ModuleAssertions
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.project.model.poetry", "true")
@TestApplication
class PyPoetryOpenIntegrationTest {
private val testRootFixture = tempPathFixture()
private val testRoot by testRootFixture
private val multiprojectFixture by multiProjectFixture(testRootFixture)
@Test
fun `project without dot-idea with pyproject-toml is automatically linked`() = timeoutRunBlocking(timeout = 20.seconds) {
testRoot.createFile("project/pyproject.toml").writeText("""
[tool.poetry]
name = "project"
""".trimIndent())
multiprojectFixture.openProject("project").useProjectAsync { project ->
ModuleAssertions.assertModules(project, "project")
CollectionAssertions.assertEqualsUnordered(listOf(testRoot.resolve("project")),
project.service<PoetrySettings>().getLinkedProjects())
}
}
}
@@ -0,0 +1,70 @@
// 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.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 org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import kotlin.io.path.writeText
@RegistryKey("python.project.model.poetry", "true")
@TestApplication
class PyPoetrySyncIntegrationTest {
private val testRootFixture = tempPathFixture()
val testRoot by testRootFixture
private val project by projectFixture(testRootFixture, openAfterCreation = true)
private val multiprojectFixture by multiProjectFixture(testRootFixture)
@Test
fun `project with path dependencies is properly mapped to IJ modules`() = timeoutRunBlocking {
testRoot.createFile("pyproject.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, ".", 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) {
PoetryProjectResolver.syncAllPoetryProjects(project)
}
}
}