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