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
This commit is contained in:
Alexey Katsman
2026-07-24 21:01:10 +00:00
committed by intellij-monorepo-bot
parent a18ab8dd28
commit 8b9a94a301
10 changed files with 121 additions and 34 deletions
@@ -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) }
}
}
@@ -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);
@@ -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)
@@ -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 <T : Any> valuesConflict(canonicalValue: T?, legacyValue: T?): Boolean =
canonicalValue != null && legacyValue != null && canonicalValue != legacyValue
}
}
}
@@ -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<Sdk>) {
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()
}
}
@@ -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? {
@@ -105,6 +105,7 @@ private object ValidationInProgressExtension : ExtendableTextComponent.Extension
internal class ValidatedPathField<T, P : PathHolder, VP : ValidatedPath<T, P>>(
val fileSystem: FileSystem<P>,
val pathValidator: PathValidator<T, P, VP>,
val canBeEdited: Boolean,
browseFolderDialogTitle: @Nls String,
isFileSelectionMode: Boolean,
) : TextFieldWithBrowseButton() {
@@ -145,6 +146,7 @@ internal class ValidatedPathField<T, P : PathHolder, VP : ValidatedPath<T, P>>(
}
init {
setButtonVisible(canBeEdited)
addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
textInputFlow.value = text
@@ -190,7 +192,7 @@ internal class ValidatedPathField<T, P : PathHolder, VP : ValidatedPath<T, P>>(
}
else {
editorMode.store(false)
isEnabled = true
isEnabled = canBeEdited
pathValidator.backProperty.get()?.validationResult?.let { validationResult ->
validationResult
@@ -318,6 +320,7 @@ internal fun <T, P : PathHolder, VP : ValidatedPath<T, P>> Panel.validatablePath
installAction: ActionLink? = null,
isFileSelectionMode: Boolean = true,
venvExistenceValidationState: ObservableProperty<VenvExistenceValidationState>? = null,
canBeEdited: Boolean = true,
): ValidatedPathField<T, P, VP> {
val validatedPathField = ValidatedPathField(
@@ -325,6 +328,7 @@ internal fun <T, P : PathHolder, VP : ValidatedPath<T, P>> Panel.validatablePath
pathValidator = pathValidator,
browseFolderDialogTitle = labelText,
isFileSelectionMode = isFileSelectionMode,
canBeEdited = canBeEdited,
)
if (missingExecutableText != null && installAction != null && !fileSystem.isReadOnly) {
@@ -200,11 +200,8 @@ private fun <P : PathHolder> 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
}
@@ -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)
@@ -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 -> {