From 830d38cead21ba50a22b14e61343e718357e575e Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Tue, 24 Feb 2026 15:33:29 +0100 Subject: [PATCH] [python] PY-87445 Add pyproject.toml-based project model settings and configuration notification Add PyProjectModelSettings with persistent state for pyproject.toml workspace configuration. Show a notification when pyproject.toml is detected, allowing users to enable the feature or dismiss it. Convert PythonContentEntriesConfigurable to Kotlin and guard the checkbox with the registry flag. # Conflicts: # community/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt GitOrigin-RevId: 4f07afe0ad1179f99c8a05f596379e83c7b299fc --- .../intellij.pycharm.community.ide.impl.xml | 3 +- .../PythonContentEntriesConfigurable.java | 27 ------ .../PythonContentEntriesConfigurable.kt | 69 +++++++++++++++ .../messages/PyBundle.properties | 2 + .../resources/intellij.python.pyproject.xml | 3 + .../messages/PyProjectTomlBundle.properties | 4 + .../pyproject/model/PyProjectModelSettings.kt | 55 ++++++++++++ .../model/internal/AutoImportstarter.kt | 6 +- .../internal/PyProjectModelStartupActivity.kt | 86 +++++++++++++++++++ 9 files changed, 223 insertions(+), 32 deletions(-) delete mode 100644 python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/PythonContentEntriesConfigurable.java create mode 100644 python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/PythonContentEntriesConfigurable.kt create mode 100644 python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt create mode 100644 python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt diff --git a/python/ide/impl/resources/intellij.pycharm.community.ide.impl.xml b/python/ide/impl/resources/intellij.pycharm.community.ide.impl.xml index 3bbd25ec4ce8..ed01cc83a2ca 100644 --- a/python/ide/impl/resources/intellij.pycharm.community.ide.impl.xml +++ b/python/ide/impl/resources/intellij.pycharm.community.ide.impl.xml @@ -60,7 +60,8 @@ bundle="messages.PyBundle" key="configurable.PythonContentEntriesConfigurable.display.name" provider="com.intellij.pycharm.community.ide.impl.configuration.PythonContentEntriesConfigurableProvider" - groupWeight="15"/> + groupWeight="15" + nonDefaultProject="true"/> ( + project, + PyBundle.message("configurable.PythonContentEntriesConfigurable.display.name"), + "reference.settingsdialog.project.structure" +) { + + private var pyprojectPanel: DialogPanel? = null + + override fun createModuleConfigurable(module: Module): Configurable { + if (PlatformUtils.isPyCharmCommunity()) { + return PlatformContentEntriesConfigurable(module, JavaSourceRootType.SOURCE) + } + return PyContentEntriesModuleConfigurable(module) + } + + override fun createComponent(): JComponent? { + if (!PyProjectModelSettings.isFeatureEnabled) { + return super.createComponent() + } + + val settings = PyProjectModelSettings.getInstance(project) + pyprojectPanel = panel { + row { + checkBox(PyBundle.message("python.pyproject.toml.based.project.model")) + .bindSelected(settings::usePyprojectToml) + .contextHelp(PyBundle.message("python.pyproject.toml.based.project.model.comment")) + } + super.createComponent()?.let { parentComponent -> + row { + cell(parentComponent).align(Align.FILL) + }.resizableRow() + } + } + + return pyprojectPanel + } + + override fun isModified(): Boolean { + return (pyprojectPanel?.isModified() == true) || super.isModified() + } + + override fun apply() { + pyprojectPanel?.apply() + super.apply() + } + + override fun reset() { + pyprojectPanel?.reset() + super.reset() + } +} diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 542b9d4b50de..07fcc06e1be9 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1382,6 +1382,8 @@ python.pyproject.install.package=Install package {0} python.pyproject.install.self.as.editable=Run 'pip install -e .' python.pyproject.install.self.as.editable.progress=Running 'pip install -e .' python.pyproject.install.self.error=Error Running 'pip install -e .' +python.pyproject.toml.based.project.model=Use pyproject.toml-based project model +python.pyproject.toml.based.project.model.comment=When enabled, the project structure is derived from pyproject.toml instead of manual configuration # Python Packages toolwindow python.toolwindow.packages.installed.label=Installed diff --git a/python/python-pyproject/resources/intellij.python.pyproject.xml b/python/python-pyproject/resources/intellij.python.pyproject.xml index 617d010cb57a..d141629aa121 100644 --- a/python/python-pyproject/resources/intellij.python.pyproject.xml +++ b/python/python-pyproject/resources/intellij.python.pyproject.xml @@ -11,7 +11,10 @@ + + diff --git a/python/python-pyproject/resources/messages/PyProjectTomlBundle.properties b/python/python-pyproject/resources/messages/PyProjectTomlBundle.properties index cc5bdd535748..c6ece7045216 100644 --- a/python/python-pyproject/resources/messages/PyProjectTomlBundle.properties +++ b/python/python-pyproject/resources/messages/PyProjectTomlBundle.properties @@ -2,3 +2,7 @@ intellij.python.pyproject.name=Generate project from pyproject.toml action.PyProjectTomlSyncAction.text=Sync Project with `pyproject.toml` action.PyProjectTomlSyncAction.description=Rebuild project model based on pyproject.toml intellij.python.pyproject.system.name=`pyproject.toml`-based Project +pyproject.notification.title=Pyproject.toml workspace detected +pyproject.notification.content=Automatically configure workspace structure and dependencies from pyproject.toml.\nMay override manual project settings. Back up your .idea directory if needed. +pyproject.notification.configure=Configure +pyproject.notification.dont.show.again=Don\u2019t show again diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt new file mode 100644 index 000000000000..0dd4af267841 --- /dev/null +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt @@ -0,0 +1,55 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyproject.model + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry + + +@Service(Service.Level.PROJECT) +@State(name = "PyProjectModelSettings", storages = [Storage("pyProjectModel.xml")]) +class PyProjectModelSettings : PersistentStateComponent, Disposable { + override fun dispose() {} + class State : BaseState() { + var usePyprojectToml: Boolean by property(false) + var showConfigurationNotification: Boolean by property(true) + } + + private var myState = State() + + var onChanged: Runnable? = null // TODO trigger logic here + + var usePyprojectToml: Boolean + get() = isFeatureEnabled && myState.usePyprojectToml + set(value) { + if (myState.usePyprojectToml != value) { + myState.usePyprojectToml = value + onChanged?.run() + } + } + + var showConfigurationNotification: Boolean + get() = myState.showConfigurationNotification + set(value) { + myState.showConfigurationNotification = value + } + + override fun getState(): State = myState + + override fun loadState(state: State) { + myState = state + } + + companion object { + @JvmStatic + fun getInstance(project: Project): PyProjectModelSettings = project.service() + + val isFeatureEnabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model") + } +} diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt index 1ec94bfb74d6..0797796fa8e1 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt @@ -2,14 +2,12 @@ package com.intellij.python.pyproject.model.internal import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.openapi.util.registry.Registry +import com.intellij.python.pyproject.model.PyProjectModelSettings import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService import com.intellij.python.pyproject.model.internal.platformBridge.startVenvExclusion import org.jetbrains.annotations.ApiStatus -private val enabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model") - /** * Starts autoimport process if [enabled] or simply "skips" to the next step: [notifyModelRebuilt]. * This method usually called by [com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity] except for new projects. @@ -20,7 +18,7 @@ private val enabled: Boolean get() = Registry.`is`("intellij.python.pyproject.mo @ApiStatus.Internal suspend fun startAutoImportIfNeeded(project: Project) { startVenvExclusion(project) - if (enabled) { + if (PyProjectModelSettings.isFeatureEnabled) { project.service().start() } else { diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt new file mode 100644 index 000000000000..e7a5b77bc5c6 --- /dev/null +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt @@ -0,0 +1,86 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.pyproject.model.internal + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.notification.impl.NotificationFullContent +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.util.Disposer +import com.intellij.psi.search.FilenameIndex +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.pyproject.model.PyProjectModelSettings +import org.jetbrains.annotations.Nls + +private const val NOTIFICATION_GROUP_ID = "PyProject.toml" + +internal class PyProjectModelStartupActivity : ProjectActivity { + override suspend fun execute(project: Project) { + if (!PyProjectModelSettings.isFeatureEnabled) return + + val settings = PyProjectModelSettings.getInstance(project) + if (!settings.showConfigurationNotification) return + + val hasPyprojectToml = readAction { + !project.isDisposed && FilenameIndex.getVirtualFilesByName(PY_PROJECT_TOML, GlobalSearchScope.projectScope(project)).isNotEmpty() + } + + if (hasPyprojectToml) { + showNotification(project, settings) + } + else { + listenForPyprojectToml(project, settings) + } + } + + private fun listenForPyprojectToml(project: Project, settings: PyProjectModelSettings) { + val disposable = Disposer.newDisposable("PyProjectModelStartupActivity") + Disposer.register(settings, disposable) + + project.messageBus.connect(disposable).subscribe(DumbService.DUMB_MODE, object : DumbService.DumbModeListener { + override fun exitDumbMode() { + if (!settings.showConfigurationNotification) { + Disposer.dispose(disposable) + return + } + + val hasAnyPyprojectToml = FilenameIndex.hasVirtualFileWithName( + PY_PROJECT_TOML, + true, + GlobalSearchScope.projectScope(project), + null + ) + + if (hasAnyPyprojectToml) { + Disposer.dispose(disposable) + showNotification(project, settings) + } + } + }) + } +} + +private fun showNotification(project: Project, settings: PyProjectModelSettings) { + FullContentNotification( + NOTIFICATION_GROUP_ID, + PyProjectTomlBundle.message("pyproject.notification.title"), + PyProjectTomlBundle.message("pyproject.notification.content"), + NotificationType.INFORMATION, + ) + .setSuggestionType(true) + .addAction(NotificationAction.createSimpleExpiring(PyProjectTomlBundle.message("pyproject.notification.configure")) { + settings.usePyprojectToml = true + settings.showConfigurationNotification = false + }) + .addAction(NotificationAction.createSimpleExpiring(PyProjectTomlBundle.message("pyproject.notification.dont.show.again")) { + settings.showConfigurationNotification = false + }) + .notify(project) +} + +private class FullContentNotification(groupId: String, @Nls title: String, @Nls content: String, type: NotificationType) + : Notification(groupId, title, content, type), NotificationFullContent