From 8b9a94a3010ae9ab052ef3e1e33f25c3dcb12946 Mon Sep 17 00:00:00 2001 From: Alexey Katsman Date: Wed, 22 Jul 2026 14:36:10 +0200 Subject: [PATCH] PY-52688 fix legacy Python SDK metadata migration Track when effective SDK data comes from legacy fields so migration is committed even when the loaded values already match. Keep canonical Hatch metadata authoritative while using legacy attributes as fallbacks. Migrate a writable SDK copy and avoid using the current project directory as a fallback for unrelated global SDKs. (cherry picked from commit db1227249757deb32c45b817beadf18c1dd3db8e) IJ-MR-215198 GitOrigin-RevId: 38ff5b9c5d0cb6a5d4b0988519deb0f9d9519962 --- .../hatch/sdk/HatchSdkAdditionalData.kt | 37 +++++++++++--- .../python/sdk/PythonSdkAdditionalData.java | 23 ++++++++- .../env/core/CachingPyEnvironmentFactory.kt | 6 ++- .../python/sdk/uv/UvSdkAdditionalData.kt | 51 ++++++++++++++----- ...ythonSdkAdditionalDataMigrationActivity.kt | 13 ++--- .../jetbrains/python/sdk/add/v2/FileSystem.kt | 7 ++- .../python/sdk/add/v2/ValidatedPathField.kt | 6 ++- .../sdk/add/v2/hatch/HatchUIComponents.kt | 5 +- .../python/conda/PyCondaAdditionalDataTest.kt | 3 +- .../com/jetbrains/python/PythonMockSdk.java | 4 +- 10 files changed, 121 insertions(+), 34 deletions(-) diff --git a/python/python-hatch/src/com/jetbrains/python/hatch/sdk/HatchSdkAdditionalData.kt b/python/python-hatch/src/com/jetbrains/python/hatch/sdk/HatchSdkAdditionalData.kt index 00f32c5d5e33..aabe1733dd0b 100644 --- a/python/python-hatch/src/com/jetbrains/python/hatch/sdk/HatchSdkAdditionalData.kt +++ b/python/python-hatch/src/com/jetbrains/python/hatch/sdk/HatchSdkAdditionalData.kt @@ -33,6 +33,29 @@ class HatchSdkAdditionalData : PythonSdkAdditionalData { } } + override fun load(element: Element?) { + super.load(element) + if (element == null) return + + val legacyWorkingDirectory = getWorkingDirectory(element) + val legacyEnvironmentName = getEnvironmentName(element) + + val canonicalFlavorData = flavorAndData.data as? HatchSdkFlavorData + val effectiveFlavorData = HatchSdkFlavorData(canonicalFlavorData?.hatchEnvironmentName ?: legacyEnvironmentName) + if (flavorAndData.flavor != HatchSdkFlavor || canonicalFlavorData != effectiveFlavorData) { + setFlavorAndDataFromLegacy(PyFlavorAndData(effectiveFlavorData, HatchSdkFlavor)) + } + if (canonicalFlavorData?.hatchEnvironmentName != null && + legacyEnvironmentName != null && + canonicalFlavorData.hatchEnvironmentName != legacyEnvironmentName) { + markMigrationRequired() + } + + if (legacyWorkingDirectory != null && legacyWorkingDirectory != workingDirectory) { + markMigrationRequired() + } + } + companion object { private const val IS_HATCH = "IS_HATCH" private const val HATCH_WORKING_DIRECTORY = "HATCH_WORKING_DIR" @@ -41,14 +64,16 @@ class HatchSdkAdditionalData : PythonSdkAdditionalData { fun createIfHatch(element: Element): HatchSdkAdditionalData? { if (element.getAttributeValue(IS_HATCH) != "true") return null - val workingDirectory = element.getAttributeValue(HATCH_WORKING_DIRECTORY)?.takeIf { it.isNotBlank() }?.let { Path.of(it) } - ?: Path.of("") - val flavorData = HatchSdkFlavorData(element.getAttributeValue(HATCH_ENVIRONMENT_NAME)) - val data = HatchSdkAdditionalData(flavorData, workingDirectory).apply { + val workingDirectory = getWorkingDirectory(element) ?: Path.of("") + val flavorData = HatchSdkFlavorData(getEnvironmentName(element)) + return HatchSdkAdditionalData(flavorData, workingDirectory).apply { load(element) } - - return data } + + private fun getEnvironmentName(element: Element) = element.getAttributeValue(HATCH_ENVIRONMENT_NAME) + + private fun getWorkingDirectory(element: Element): Path? = + element.getAttributeValue(HATCH_WORKING_DIRECTORY)?.takeIf { it.isNotBlank() }?.let { Path.of(it) } } } diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java b/python/python-sdk/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java index 79303daf3ea4..fb0e71a73f45 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java +++ b/python/python-sdk/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java @@ -72,6 +72,7 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { private String myRequirementsFile; private Path myLegacyRequiredTxtPath; private @NotNull Path myWorkingDirectory = EMPTY_WORKING_DIRECTORY; + private boolean myMigrationRequired; private final Gson myGson = new GsonBuilder().registerTypeAdapter(Path.class, new PathSerializer()).create(); @@ -243,10 +244,23 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { return myFlavorAndData; } + @ApiStatus.Internal + protected final void setFlavorAndDataFromLegacy(@NotNull PyFlavorAndData flavorAndData) { + if (Objects.equals(myFlavorAndData, flavorAndData)) return; + myFlavorAndData = flavorAndData; + markMigrationRequired(); + } + + @ApiStatus.Internal + protected final void markMigrationRequired() { + myMigrationRequired = true; + } + @ApiStatus.Internal public final boolean migrateAdditionalData(@Nullable Path fallbackWorkingDirectory) { + boolean changed = myMigrationRequired; FlavorMigrationResult flavorMigration = migrateFlavorData(myFlavorAndData); - boolean changed = flavorMigration.changed(); + changed |= flavorMigration.changed(); Path requirementsWorkingDirectory = null; if (myRequirementsFile == null) { @@ -272,6 +286,7 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { changed |= synchronizeFlavorWorkingDirectory(); } + myMigrationRequired = false; return changed; } @@ -284,7 +299,9 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { } public void load(@Nullable Element element) { + Path legacyWorkingDirectory = myWorkingDirectory; myWorkingDirectory = EMPTY_WORKING_DIRECTORY; + myMigrationRequired = false; collectPaths(JDOMExternalizer.loadStringsList(element, PATHS_ADDED_BY_USER_ROOT, PATH_ADDED_BY_USER), myAddedPaths); collectPaths(JDOMExternalizer.loadStringsList(element, PATHS_REMOVED_BY_USER_ROOT, PATH_REMOVED_BY_USER), myExcludedPaths); collectPaths(JDOMExternalizer.loadStringsList(element, PATHS_TO_TRANSFER_ROOT, PATH_TO_TRANSFER), myPathsToTransfer); @@ -295,6 +312,10 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { if (storedWorkingDirectory != null && !storedWorkingDirectory.isBlank()) { myWorkingDirectory = Path.of(storedWorkingDirectory); } + else if (!Objects.equals(legacyWorkingDirectory, EMPTY_WORKING_DIRECTORY)) { + myWorkingDirectory = legacyWorkingDirectory; + myMigrationRequired = true; + } myRequirementsFile = element.getAttributeValue(REQUIREMENTS_FILE); String legacyRequiredTxtPath = element.getAttributeValue(ASSOCIATED_REQUIRED_TXT_PATH); myLegacyRequiredTxtPath = legacyRequiredTxtPath == null ? null : Path.of(legacyRequiredTxtPath); diff --git a/python/python-test-env/core/src/com/intellij/python/test/env/core/CachingPyEnvironmentFactory.kt b/python/python-test-env/core/src/com/intellij/python/test/env/core/CachingPyEnvironmentFactory.kt index ebff27daa71b..d1840e8b5b1d 100644 --- a/python/python-test-env/core/src/com/intellij/python/test/env/core/CachingPyEnvironmentFactory.kt +++ b/python/python-test-env/core/src/com/intellij/python/test/env/core/CachingPyEnvironmentFactory.kt @@ -2,14 +2,18 @@ package com.intellij.python.test.env.core import org.jetbrains.annotations.ApiStatus +import java.nio.file.Path @ApiStatus.Internal class CachingPyEnvironmentFactory( private val wrapper: PyEnvironmentFactory, -) : PyEnvironmentFactory by wrapper { +) : PyEnvironmentFactory { private val cache = PyEnvironmentCache() + override val workingDir: Path + get() = wrapper.workingDir + override suspend fun createEnvironment(factory: PyEnvironmentFactory, spec: PyEnvironmentSpec<*>): PyEnvironment { return cache.getOrCreate(spec.toCacheKey()) { wrapper.createEnvironment(factory, spec) diff --git a/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkAdditionalData.kt b/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkAdditionalData.kt index d915a0ce8c6a..8c38cb9df1f0 100644 --- a/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkAdditionalData.kt +++ b/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkAdditionalData.kt @@ -46,6 +46,26 @@ class UvSdkAdditionalData : PythonSdkAdditionalData { } } + override fun load(element: Element?) { + super.load(element) + if (element == null) return + + val legacyFlavorData = getLegacyFlavorData(element) + val canonicalFlavorData = flavorAndData.data as? UvSdkFlavorData + val effectiveFlavorData = UvSdkFlavorData( + uvWorkingDirectory = canonicalFlavorData?.uvWorkingDirectory ?: legacyFlavorData.uvWorkingDirectory, + usePip = canonicalFlavorData?.usePip ?: legacyFlavorData.usePip, + venvPath = canonicalFlavorData?.venvPath ?: legacyFlavorData.venvPath, + uvPath = canonicalFlavorData?.uvPath ?: legacyFlavorData.uvPath, + ) + if (flavorAndData.flavor != UvSdkFlavor || canonicalFlavorData != effectiveFlavorData) { + setFlavorAndDataFromLegacy(PyFlavorAndData(effectiveFlavorData, UvSdkFlavor)) + } + if (canonicalFlavorData?.hasConflictsWith(legacyFlavorData) == true) { + markMigrationRequired() + } + } + companion object { private const val IS_UV = "IS_UV" private const val UV_WORKING_DIR = "UV_WORKING_DIR" @@ -57,22 +77,29 @@ class UvSdkAdditionalData : PythonSdkAdditionalData { fun load(element: Element): UvSdkAdditionalData? { return when { element.getAttributeValue(IS_UV) == "true" -> { - val uvWorkingDirectory = if (element.getAttributeValue(UV_WORKING_DIR).isNullOrEmpty()) { - Path.of("") - } - else { - Path.of(element.getAttributeValue(UV_WORKING_DIR)) - } - val usePip = element.getAttributeValue(USE_PIP)?.toBoolean() - val venvPath = element.getAttributeValue(UV_VENV_PATH)?.takeIf { it.isNotBlank() } - val uvPath = if (element.getAttributeValue(UV_TOOL_PATH).isNullOrEmpty()) null else element.getAttributeValue(UV_TOOL_PATH) - val legacyFlavorData = UvSdkFlavorData(uvWorkingDirectory, usePip, venvPath, uvPath) - UvSdkAdditionalData(legacyFlavorData, uvWorkingDirectory).apply { + val legacyFlavorData = getLegacyFlavorData(element) + UvSdkAdditionalData(legacyFlavorData, legacyFlavorData.uvWorkingDirectory ?: Path.of("")).apply { load(element) } } else -> null } } + + private fun getLegacyFlavorData(element: Element): UvSdkFlavorData = UvSdkFlavorData( + uvWorkingDirectory = element.getAttributeValue(UV_WORKING_DIR)?.takeIf { it.isNotBlank() }?.let { Path.of(it) }, + usePip = element.getAttributeValue(USE_PIP)?.toBoolean(), + venvPath = element.getAttributeValue(UV_VENV_PATH)?.takeIf { it.isNotBlank() }, + uvPath = element.getAttributeValue(UV_TOOL_PATH)?.takeIf { it.isNotBlank() }, + ) + + private fun UvSdkFlavorData.hasConflictsWith(legacyData: UvSdkFlavorData): Boolean = + valuesConflict(uvWorkingDirectory, legacyData.uvWorkingDirectory) || + valuesConflict(usePip, legacyData.usePip) || + valuesConflict(venvPath, legacyData.venvPath) || + valuesConflict(uvPath, legacyData.uvPath) + + private fun valuesConflict(canonicalValue: T?, legacyValue: T?): Boolean = + canonicalValue != null && legacyValue != null && canonicalValue != legacyValue } -} \ No newline at end of file +} diff --git a/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalDataMigrationActivity.kt b/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalDataMigrationActivity.kt index d85613b27080..314bf22b1b32 100644 --- a/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalDataMigrationActivity.kt +++ b/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalDataMigrationActivity.kt @@ -8,6 +8,7 @@ import com.intellij.openapi.project.modules import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.startup.ProjectActivity import com.intellij.util.concurrency.annotations.RequiresWriteLock import org.jetbrains.annotations.ApiStatus @@ -27,18 +28,18 @@ class PythonSdkAdditionalDataMigrationActivity : ProjectActivity, DumbAware { @RequiresWriteLock private fun migratePythonSdkAdditionalData(project: Project, sdks: List) { + val projectSdk = ProjectRootManager.getInstance(project).projectSdk for (sdk in sdks) { - val additionalData = sdk.pySdkAdditionalData val fallbackWorkingDirectory = sdk.associatedModuleNioPath ?: project.modules.firstNotNullOfOrNull { module -> module.takeIf { ModuleRootManager.getInstance(it).sdk == sdk }?.baseDir?.path?.let { Path.of(it) } } - ?: project.basePath?.let { Path.of(it) } + ?: project.basePath?.takeIf { projectSdk == sdk }?.let { Path.of(it) } + val modificator = sdk.sdkModificator + val additionalData = modificator.sdkAdditionalData as? PythonSdkAdditionalData ?: continue if (!additionalData.migrateAdditionalData(fallbackWorkingDirectory)) continue - sdk.sdkModificator.apply { - sdkAdditionalData = additionalData - commitChanges() - } + modificator.sdkAdditionalData = additionalData + modificator.commitChanges() } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt b/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt index 538c29755a39..3f4bd69fc20d 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt @@ -282,7 +282,12 @@ data class EelFileSystem( } override fun resolvePythonHome(pythonHomeOrBinary: PathHolder.Eel): PathHolder.Eel { - return PathHolder.Eel(pythonHomeOrBinary.path.resolvePythonHome()) + val path = pythonHomeOrBinary.path + val fileName = path.fileName?.toString() + val parentName = path.parent?.fileName?.toString() + val isPythonBinary = fileName?.startsWith("python", ignoreCase = true) == true && + (parentName == "bin" || parentName.equals("scripts", ignoreCase = true)) + return if (isPythonBinary) PathHolder.Eel(path.resolvePythonHome()) else pythonHomeOrBinary } override fun getVenvName(pythonHome: PathHolder.Eel): String? { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt b/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt index e6cd84b46a9c..6cb3c6651cd2 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt @@ -105,6 +105,7 @@ private object ValidationInProgressExtension : ExtendableTextComponent.Extension internal class ValidatedPathField>( val fileSystem: FileSystem

, val pathValidator: PathValidator, + val canBeEdited: Boolean, browseFolderDialogTitle: @Nls String, isFileSelectionMode: Boolean, ) : TextFieldWithBrowseButton() { @@ -145,6 +146,7 @@ internal class ValidatedPathField>( } init { + setButtonVisible(canBeEdited) addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { textInputFlow.value = text @@ -190,7 +192,7 @@ internal class ValidatedPathField>( } else { editorMode.store(false) - isEnabled = true + isEnabled = canBeEdited pathValidator.backProperty.get()?.validationResult?.let { validationResult -> validationResult @@ -318,6 +320,7 @@ internal fun > Panel.validatablePath installAction: ActionLink? = null, isFileSelectionMode: Boolean = true, venvExistenceValidationState: ObservableProperty? = null, + canBeEdited: Boolean = true, ): ValidatedPathField { val validatedPathField = ValidatedPathField( @@ -325,6 +328,7 @@ internal fun > Panel.validatablePath pathValidator = pathValidator, browseFolderDialogTitle = labelText, isFileSelectionMode = isFileSelectionMode, + canBeEdited = canBeEdited, ) if (missingExecutableText != null && installAction != null && !fileSystem.isReadOnly) { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt index 30a33281e1e7..f41ae8bd0fce 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt @@ -200,11 +200,8 @@ private fun

Panel.addExecutableSelector( labelText = message("sdk.create.custom.venv.executable.path", "hatch"), missingExecutableText = message("sdk.create.custom.tool.not.detected", "hatch"), installAction = installHatchActionLink, + canBeEdited = model.fileSystem.toolPathCanBePersisted, ) - if (!model.fileSystem.toolPathCanBePersisted) { - executablePath.textField.isEditable = false - executablePath.setButtonVisible(false) - } return executablePath } diff --git a/python/testSrc/com/jetbrains/env/python/conda/PyCondaAdditionalDataTest.kt b/python/testSrc/com/jetbrains/env/python/conda/PyCondaAdditionalDataTest.kt index 455ec75bbcc3..02d7e771f8cf 100644 --- a/python/testSrc/com/jetbrains/env/python/conda/PyCondaAdditionalDataTest.kt +++ b/python/testSrc/com/jetbrains/env/python/conda/PyCondaAdditionalDataTest.kt @@ -2,6 +2,7 @@ package com.jetbrains.env.python.conda import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.util.io.toNioPathOrNull import com.intellij.testFramework.ProjectRule import com.jetbrains.python.sdk.PythonSdkAdditionalData import com.jetbrains.python.sdk.flavors.PyFlavorAndData @@ -32,7 +33,7 @@ class PyCondaAdditionalDataTest { ) ) val data = PythonSdkAdditionalData( - PyFlavorAndData(flavorData, CondaEnvSdkFlavor)) + PyFlavorAndData(flavorData, CondaEnvSdkFlavor), projectRule.project.basePath?.toNioPathOrNull()!!) val rootElement = Element("root") data.save(rootElement) diff --git a/python/testSrc/com/jetbrains/python/PythonMockSdk.java b/python/testSrc/com/jetbrains/python/PythonMockSdk.java index 57e2736902f7..9f0be55da9f9 100644 --- a/python/testSrc/com/jetbrains/python/PythonMockSdk.java +++ b/python/testSrc/com/jetbrains/python/PythonMockSdk.java @@ -25,6 +25,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.File; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -62,7 +63,8 @@ public final class PythonMockSdk { Sdk sdk = ProjectJdkTable.getInstance().createSdk(sdkName, sdkType); SdkModificator sdkModificator = sdk.getSdkModificator(); sdkModificator.setHomePath(sdkPath + "/bin/python"); - sdkModificator.setSdkAdditionalData(new PythonSdkAdditionalData(new PyFlavorAndData(PyFlavorData.Empty.INSTANCE, VirtualEnvSdkFlavor.getInstance()))); + sdkModificator.setSdkAdditionalData( + new PythonSdkAdditionalData(new PyFlavorAndData(PyFlavorData.Empty.INSTANCE, VirtualEnvSdkFlavor.getInstance()), Path.of(sdkPath))); sdkModificator.setVersionString(toVersionString(level)); createRoots(sdkPath, level).forEach(vFile -> {