diff --git a/python/gen/com/jetbrains/python/projectModel/poetry/impl/MetadataStorageImpl.kt b/python/gen/com/jetbrains/python/projectModel/poetry/impl/MetadataStorageImpl.kt
new file mode 100644
index 000000000000..6720eabf3f64
--- /dev/null
+++ b/python/gen/com/jetbrains/python/projectModel/poetry/impl/MetadataStorageImpl.kt
@@ -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)
+ }
+
+}
diff --git a/python/intellij.python.community.impl.iml b/python/intellij.python.community.impl.iml
index 18ed8f1b6000..63a77c4a39a7 100644
--- a/python/intellij.python.community.impl.iml
+++ b/python/intellij.python.community.impl.iml
@@ -145,5 +145,7 @@
+
+
\ No newline at end of file
diff --git a/python/intellij.python.community.tests.iml b/python/intellij.python.community.tests.iml
index d57251d2d26a..e70abb404e70 100644
--- a/python/intellij.python.community.tests.iml
+++ b/python/intellij.python.community.tests.iml
@@ -74,5 +74,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/python/pluginCore/resources/META-INF/plugin.xml b/python/pluginCore/resources/META-INF/plugin.xml
index 060e1dbb5f15..c7673ee5eb53 100644
--- a/python/pluginCore/resources/META-INF/plugin.xml
+++ b/python/pluginCore/resources/META-INF/plugin.xml
@@ -92,6 +92,9 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
+
+
+
@@ -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"/>
+
@@ -649,6 +654,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
+
@@ -1018,6 +1025,9 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
+
+
+
diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties
index 277333c845e3..2b953e01aceb 100644
--- a/python/pluginResources/messages/PyBundle.properties
+++ b/python/pluginResources/messages/PyBundle.properties
@@ -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
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryConstants.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryConstants.kt
new file mode 100644
index 000000000000..8eedcfa66bb1
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryConstants.kt
@@ -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")
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryEntitySource.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryEntitySource.kt
new file mode 100644
index 000000000000..cd9dc365aa49
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryEntitySource.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryLinkAction.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryLinkAction.kt
new file mode 100644
index 000000000000..c7a92c027215
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryLinkAction.kt
@@ -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()
+ 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().coroutineScope
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryOpenProcessor.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryOpenProcessor.kt
new file mode 100644
index 000000000000..0fd6baebeba7
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryOpenProcessor.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryOpenProvider.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryOpenProvider.kt
new file mode 100644
index 000000000000..18f3b2bc393d
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryOpenProvider.kt
@@ -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().addLinkedProject(projectRootPath)
+ PoetryProjectResolver.syncPoetryProject(project, projectRootPath)
+ }
+
+ override suspend fun unlinkProject(project: Project, externalProjectPath: String) {
+ PoetryProjectResolver.forgetPoetryProject(project, Path.of(externalProjectPath))
+ }
+}
+
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryProjectAware.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryProjectAware.kt
new file mode 100644
index 000000000000..b4acf0ca3319
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryProjectAware.kt
@@ -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
+ 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 {
+ val source = PoetryEntitySource(projectId.externalProjectPath.toVirtualFileUrl(project))
+ return project.workspaceModel.currentSnapshot
+ .entities()
+ .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().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().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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryProjectResolver.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryProjectResolver.kt
new file mode 100644
index 000000000000..d67ec9e13656
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryProjectResolver.kt
@@ -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().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().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,
+ 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()
+ .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()
+ .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) }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetrySettings.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySettings.kt
new file mode 100644
index 000000000000..0d6b693a10e7
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySettings.kt
@@ -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(State()) {
+
+ class State() : BaseState() {
+ var linkedProjects: MutableList by list()
+ }
+
+ fun setLinkedProjects(projects: List) {
+ 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 {
+ 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))
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetrySettingsListener.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySettingsListener.kt
new file mode 100644
index 000000000000..9d198eafe828
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySettingsListener.kt
@@ -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 = Topic(PoetrySettingsListener::class.java, Topic.BroadcastDirection.NONE)
+ }
+
+ fun onLinkedProjectAdded(projectRoot: Path): Unit = Unit
+ fun onLinkedProjectRemoved(projectRoot: Path): Unit = Unit
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetrySyncAction.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySyncAction.kt
new file mode 100644
index 000000000000..7beae980f51d
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySyncAction.kt
@@ -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().coroutineScope
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetrySyncListener.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySyncListener.kt
new file mode 100644
index 000000000000..129e1cddc32b
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetrySyncListener.kt
@@ -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 = Topic(PoetrySyncListener::class.java, Topic.BroadcastDirection.NONE)
+ }
+
+ // Add onFailure
+ // Add onCancel
+ fun onStart(projectRoot: Path): Unit = Unit
+ fun onFinish(projectRoot: Path): Unit = Unit
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/PoetryUnlinkedProjectAware.kt b/python/src/com/jetbrains/python/projectModel/poetry/PoetryUnlinkedProjectAware.kt
new file mode 100644
index 000000000000..15430857dbde
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/PoetryUnlinkedProjectAware.kt
@@ -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().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)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/projectModel/poetry/ProjectModelGraph.kt b/python/src/com/jetbrains/python/projectModel/poetry/ProjectModelGraph.kt
new file mode 100644
index 000000000000..75e3992179d6
--- /dev/null
+++ b/python/src/com/jetbrains/python/projectModel/poetry/ProjectModelGraph.kt
@@ -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)
+
+/**
+ * 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)
+
+/**
+ * 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)
+
+@OptIn(ExperimentalPathApi::class)
+fun readProjectModelGraph(ijProjectRoot: Path): ProjectModelGraph {
+ val roots = mutableListOf()
+ 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 = 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)
+}
diff --git a/python/testSrc/com/jetbrains/python/projectModel/poetry/PyPoetryOpenIntegrationTest.kt b/python/testSrc/com/jetbrains/python/projectModel/poetry/PyPoetryOpenIntegrationTest.kt
new file mode 100644
index 000000000000..d89f2c3b94fa
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/projectModel/poetry/PyPoetryOpenIntegrationTest.kt
@@ -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().getLinkedProjects())
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/testSrc/com/jetbrains/python/projectModel/poetry/PyPoetrySyncIntegrationTest.kt b/python/testSrc/com/jetbrains/python/projectModel/poetry/PyPoetrySyncIntegrationTest.kt
new file mode 100644
index 000000000000..3a1b169efb50
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/projectModel/poetry/PyPoetrySyncIntegrationTest.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file