PY-87445: Obey PyProject enable/disable feature.

`PyProjectAutoImportService` is only automatically started by `startAutoImportIfNeeded` if `PyProjectModelSettings.getUsePyprojectToml` is enabled.

Once enabled/disabled, `PyProjectModelSettings` starts/stops `PyProjectAutoImportService`.

Also:
1. Move `PyProjectModelStartupActivity` to `askUserIfPyProjectMustBeEnabled` to make sure it is called __after__ project creation. Otherwise, it might start project import process in the middle of project creation and delete modules.

2. Make import non-suspend (no need to access IO anyway) to make sure we can call it from `PyProjectModelSettings`

GitOrigin-RevId: e9328721ad90c76d1a473cffa15c7fec2e365c9c
This commit is contained in:
Ilya.Kazakevich
2026-02-25 01:20:01 +00:00
committed by intellij-monorepo-bot
parent 52d2828b6d
commit 46cda23bf1
9 changed files with 136 additions and 134 deletions
@@ -10,36 +10,17 @@ import com.intellij.openapi.options.SearchableConfigurable
import com.intellij.openapi.roots.ModifiableRootModel
import com.intellij.openapi.roots.ModuleRootManager
import com.intellij.openapi.roots.ui.configuration.DefaultModulesProvider
import com.intellij.openapi.ui.Splitter
import com.intellij.python.pyproject.model.PyProjectModelSettings
import com.intellij.ui.AncestorListenerAdapter
import com.intellij.ui.roots.IconActionComponent
import com.intellij.ui.roots.ToolbarPanel
import com.intellij.util.ui.UIUtil
import com.jetbrains.python.PyBundle
import com.jetbrains.python.module.PyContentEntriesEditor
import org.jetbrains.jps.model.java.JavaSourceRootType
import java.awt.BorderLayout
import java.awt.Component
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JPopupMenu
import javax.swing.JScrollPane
import javax.swing.JTextField
import javax.swing.JTree
import javax.swing.event.AncestorEvent
internal class PyContentEntriesModuleConfigurable(private val module: Module) : SearchableConfigurable.Parent.Abstract() {
private val topPanel = JPanel(BorderLayout())
//.apply {
// addAncestorListener(object : AncestorListenerAdapter() {
// override fun ancestorAdded(event: AncestorEvent) {
// updateEnabledState()
// }
// })
//}
private var modifiableModel: ModifiableRootModel? = null
private var editor: PyContentEntriesEditor? = null
private val isPyProjectTomlManaged: Boolean
@@ -87,7 +68,6 @@ internal class PyContentEntriesModuleConfigurable(private val module: Module) :
ApplicationManager.getApplication().runWriteAction { modifiableModel?.commit() }
resetEditor()
}
//updateEnabledState()
}
override fun reset() {
@@ -104,38 +84,6 @@ internal class PyContentEntriesModuleConfigurable(private val module: Module) :
createEditor()
}
private fun updateEnabledState() {
val visible = !isPyProjectTomlManaged
UIUtil.findComponentOfType(topPanel, Splitter::class.java)?.let { splitter ->
// Hide the "Mark as" label and action toolbar (siblings of the splitter in contentPanel)
splitter.parent?.let { contentPanel ->
contentPanel.components.filter { it != splitter }.forEach { it.isVisible = visible }
}
// In the right panel (ToolbarPanel): hide the "Add Content Root" toolbar and remove-root icons
val contentRootsPanel = splitter.secondComponent
(contentRootsPanel as? ToolbarPanel)?.let {
it.getComponent(0).isVisible = visible
}
UIUtil.findComponentsOfType(contentRootsPanel, IconActionComponent::class.java).forEach {
it.isVisible = visible
}
}
// Suppress tree context menu without removing PopupHandler listeners
UIUtil.findComponentOfType(topPanel, JTree::class.java)?.let { tree ->
tree.componentPopupMenu = if (visible) null else EMPTY_POPUP
}
// Hide "Exclude patterns" field, its label, and comment (all are siblings of the tree JScrollPane)
UIUtil.findComponentOfType(topPanel, JTextField::class.java)?.let { excludeField ->
excludeField.parent?.let { dialogPanel ->
dialogPanel.components.filter { it !is JScrollPane }.forEach { it.isVisible = visible }
}
}
}
override fun disposeUIResources() {
editor?.let {
it.disposeUIResources()
@@ -149,10 +97,4 @@ internal class PyContentEntriesModuleConfigurable(private val module: Module) :
override fun buildConfigurables(): Array<Configurable> = emptyArray()
override fun getId(): String = "python.project.structure"
companion object {
private val EMPTY_POPUP = object : JPopupMenu() {
override fun show(invoker: Component?, x: Int, y: Int) {}
}
}
}
@@ -0,0 +1,5 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@Internal
package com.intellij.pycharm.community.ide.impl.configuration;
import org.jetbrains.annotations.ApiStatus.Internal;
@@ -14,7 +14,6 @@
<notificationGroup id="PyProject.toml" displayType="STICKY_BALLOON" isLogByDefault="true"
bundle="messages.PyProjectTomlBundle" key="pyproject.notification.title"/>
<backgroundPostStartupActivity implementation="com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity"/>
<backgroundPostStartupActivity implementation="com.intellij.python.pyproject.model.internal.PyProjectModelStartupActivity"/>
<statistics.projectUsagesCollector implementation="com.intellij.python.pyproject.statistics.PythonTomlStatsUsagesCollector"/>
</extensions>
@@ -10,11 +10,13 @@ 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
import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService
@Service(Service.Level.PROJECT)
@State(name = "PyProjectModelSettings", storages = [Storage("pyProjectModel.xml")])
class PyProjectModelSettings : PersistentStateComponent<PyProjectModelSettings.State>, Disposable {
class PyProjectModelSettings(private val project: Project) :
PersistentStateComponent<PyProjectModelSettings.State>, Disposable {
override fun dispose() {}
class State : BaseState() {
var usePyprojectToml: Boolean by property(false)
@@ -23,14 +25,20 @@ class PyProjectModelSettings : PersistentStateComponent<PyProjectModelSettings.S
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()
project.service<PyProjectAutoImportService>().apply {
if (value) {
start()
}
else {
stop()
}
}
}
}
@@ -50,6 +58,11 @@ class PyProjectModelSettings : PersistentStateComponent<PyProjectModelSettings.S
@JvmStatic
fun getInstance(project: Project): PyProjectModelSettings = project.service()
/**
* Hard setting: if disabled -> feature is disabled on the Registry.
* For user-defined setting, check [PyProjectModelSettings.usePyprojectToml].
* Be sure to check **both** except for UI for the aforementioned service.
*/
val isFeatureEnabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model")
}
}
@@ -9,16 +9,18 @@ import org.jetbrains.annotations.ApiStatus
/**
* Starts autoimport process if [enabled] or simply "skips" to the next step: [notifyModelRebuilt].
* Starts autoimport process if [PyProjectModelSettings.isFeatureEnabled] 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.
* In this case, it is postponed till project generation (see usages).
*
* This method can only be called once (see [PyProjectAutoImportService.start])
* This method can only be called **once** (call [PyProjectAutoImportService.start] to enable/disable import logic)
*/
@ApiStatus.Internal
suspend fun startAutoImportIfNeeded(project: Project) {
startVenvExclusion(project)
if (PyProjectModelSettings.isFeatureEnabled) {
askUserIfPyProjectMustBeEnabled(project)
// Only start autoImport if both: registry and user flags are enabled
if (PyProjectModelSettings.isFeatureEnabled && PyProjectModelSettings.getInstance(project).usePyprojectToml) {
project.service<PyProjectAutoImportService>().start()
}
else {
@@ -8,7 +8,6 @@ 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
@@ -18,50 +17,48 @@ 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
internal suspend fun askUserIfPyProjectMustBeEnabled(project: Project) {
if (!PyProjectModelSettings.isFeatureEnabled) return
val settings = PyProjectModelSettings.getInstance(project)
if (!settings.showConfigurationNotification) 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)
}
val hasPyprojectToml = readAction {
!project.isDisposed && FilenameIndex.getVirtualFilesByName(PY_PROJECT_TOML, GlobalSearchScope.projectScope(project)).isNotEmpty()
}
private fun listenForPyprojectToml(project: Project, settings: PyProjectModelSettings) {
val disposable = Disposer.newDisposable("PyProjectModelStartupActivity")
Disposer.register(settings, disposable)
if (hasPyprojectToml) {
showNotification(project, settings)
}
else {
listenForPyprojectToml(project, settings)
}
}
project.messageBus.connect(disposable).subscribe(DumbService.DUMB_MODE, object : DumbService.DumbModeListener {
override fun exitDumbMode() {
if (!settings.showConfigurationNotification) {
Disposer.dispose(disposable)
return
}
private fun listenForPyprojectToml(project: Project, settings: PyProjectModelSettings) {
val disposable = Disposer.newDisposable("PyProjectModelStartupActivity")
Disposer.register(settings, disposable)
val hasAnyPyprojectToml = FilenameIndex.hasVirtualFileWithName(
PY_PROJECT_TOML,
true,
GlobalSearchScope.projectScope(project),
null
)
if (hasAnyPyprojectToml) {
Disposer.dispose(disposable)
showNotification(project, settings)
}
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) {
@@ -82,5 +79,5 @@ private fun showNotification(project: Project, settings: PyProjectModelSettings)
.notify(project)
}
private class FullContentNotification(groupId: String, @Nls title: String, @Nls content: String, type: NotificationType)
: Notification(groupId, title, content, type), NotificationFullContent
private class FullContentNotification(groupId: String, @Nls title: String, @Nls content: String, type: NotificationType) :
Notification(groupId, title, content, type), NotificationFullContent
@@ -25,8 +25,6 @@ import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.messages.Topic
import com.intellij.util.ui.EDT
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.VisibleForTesting
import java.nio.file.Path
@@ -113,12 +111,10 @@ class PyExternalSystemProjectAware private constructor(
*/
@ApiStatus.Internal
@VisibleForTesting
suspend fun create(project: Project): PyExternalSystemProjectAware {
fun create(project: Project): PyExternalSystemProjectAware {
assert(!project.isDefault) { "Default project not supported" }
val baseDir = withContext(Dispatchers.IO) {
// guessPath doesn't work: it returns first module path
project.stateStore.projectBasePath
}
// guessPath doesn't work: it returns first module path
val baseDir = project.stateStore.projectBasePath
return PyExternalSystemProjectAware(project, baseDir)
}
}
@@ -2,44 +2,58 @@ package com.intellij.python.pyproject.model.internal.autoImportBridge
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId
import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectTracker
import com.intellij.openapi.project.Project
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.TestOnly
/**
* [project] can't be default, check for it
*/
@Service(Service.Level.PROJECT)
internal class PyProjectAutoImportService(private val project: Project) : Disposable {
@ApiStatus.Internal
class PyProjectAutoImportService(private val project: Project) : Disposable {
private val m = Any()
init {
assert(!project.isDefault) { "Default project not supported" }
}
@Volatile
private var projectId: ExternalSystemProjectId? = null
@get:TestOnly
internal val initialized: Boolean get() = projectId != null
internal val initialized: Boolean get() = synchronized(m) { projectId != null }
/**
* Starts auto-import (`builds project module on any pyproject.toml` change). To be called only once!
* Starts auto-import (`builds project module on any pyproject.toml` change). Does nothing if already started. Method is synchronized.
* You can always [stop] it, so does [dispose]
*/
internal suspend fun start() {
assert(projectId == null) { "Already started, do not call second time" }
val tracker = getTracker()
val projectAware = PyExternalSystemProjectAware.create(project)
val projectId = projectAware.projectId
this.projectId = projectId
tracker.register(projectAware)
tracker.activate(projectId)
tracker.markDirty(projectId)
tracker.scheduleProjectRefresh()
fun start(): Unit = synchronized(m) {
if (projectId != null) {
log.info("PyProject already started")
return@synchronized
}
else {
val tracker = getTracker()
val projectAware = PyExternalSystemProjectAware.create(project)
val projectId = projectAware.projectId
this.projectId = projectId
tracker.register(projectAware)
tracker.activate(projectId)
tracker.markDirty(projectId)
tracker.scheduleProjectRefresh()
log.info("PyProject started")
}
}
override fun dispose() {
/**
* Stop auto-import (started by [start]) does nothing if already stopped. Method is synchronized.
*/
fun stop(): Unit = synchronized(m) {
log.info("PyProject stopped")
projectId?.let {
getTracker().remove(it)
projectId = null
@@ -47,5 +61,14 @@ internal class PyProjectAutoImportService(private val project: Project) : Dispos
}
override fun dispose() {
stop()
}
private fun getTracker(): ExternalSystemProjectTracker = ExternalSystemProjectTracker.getInstance(project)
}
private companion object {
val log = fileLogger()
}
}
@@ -2,13 +2,17 @@ package com.intellij.python.junit5Tests.unit.pyproject
import com.intellij.ide.impl.OpenProjectTask
import com.intellij.openapi.components.service
import com.intellij.python.pyproject.model.PyProjectModelSettings
import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService
import com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity
import com.intellij.python.pyproject.model.internal.startAutoImportIfNeeded
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.projectFixture
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@TestApplication
internal class PyProjectSyncActivityTest {
@@ -16,9 +20,30 @@ internal class PyProjectSyncActivityTest {
projectFixture(openProjectTask = OpenProjectTask().copy(isProjectCreatedWithWizard = true), openAfterCreation = true)
@Test
fun testNoAutoRebuildForWizardBasedProject(): Unit = timeoutRunBlocking {
PyProjectSyncActivity().execute(project)
assertFalse(project.service<PyProjectAutoImportService>().initialized, "Newly opened project shouldn't lead to autoimport ")
@ParameterizedTest
@ValueSource(booleans = [true, false])
fun testNoAutoRebuildForWizardBasedProject(enableAutoImport: Boolean): Unit = timeoutRunBlocking {
val settings = project.service<PyProjectModelSettings>()
val oldValue = settings.usePyprojectToml
settings.usePyprojectToml = enableAutoImport
// Setting this var starts import automatically, so we stop it check that code starts it in tests
project.service<PyProjectAutoImportService>().stop()
try {
val sut = project.service<PyProjectAutoImportService>()
PyProjectSyncActivity().execute(project)
assertFalse(sut.initialized, "Newly opened project shouldn't lead to autoimport ")
startAutoImportIfNeeded(project)
if (enableAutoImport) {
assertTrue(sut.initialized, "Auto import must be started when called manually")
}
else {
assertFalse(sut.initialized, "Auto import started, even though was disabled")
}
sut.stop()
assertFalse(sut.initialized, "Autoimport must be stopped")
}
finally {
settings.usePyprojectToml = oldValue
}
}
}