From 67235b83d21a708e9f0917e9d2ed07407a4ae58d Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Tue, 3 Mar 2026 23:11:46 +0100 Subject: [PATCH] PY-88099 Normalize project names and improve new project wizard UX - Add PyPackageName.normalizeProjectName() per PEP 503 name normalization spec - Normalize project names in uv, poetry, and hatch project generators - Fix hatch createNewProject to use NioFiles.copyRecursively instead of broken EEL move - Add background progress indicators for module structure creation and SDK setup - Refresh VFS with markDirtyAndRefresh after module structure creation - Add TraceContext to project generation and packaging tool window coroutines (cherry picked from commit b64dfb4ae541e4a220698d804aec3fe95103bdf2) IJ-MR-194426 GitOrigin-RevId: 0a1212d70d8136e1ba8821b736d09e00d78f310e --- .../python/packaging/PyPackageName.kt | 16 ++++++++++++++ .../messages/PyBundle.properties | 1 + .../src/com/intellij/python/hatch/hatch.kt | 4 ++-- .../hatch/service/CliBasedHatchService.kt | 22 ++++++++++++------- .../PyV3BaseProjectSettings.kt | 17 +++++++++++--- .../PyV3ProjectBaseGenerator.kt | 3 ++- .../projectPath/ProjectPathFlows.kt | 3 ++- .../PyPackagingToolWindowService.kt | 13 ++++++----- .../v2/hatch/HatchNewEnvironmentCreator.kt | 3 ++- .../sdk/poetry/PoetryCommandExecutor.kt | 8 +++++++ .../python/sdk/uv/impl/UvLowLevel.kt | 5 +++-- 11 files changed, 71 insertions(+), 24 deletions(-) diff --git a/python/openapi/src/com/jetbrains/python/packaging/PyPackageName.kt b/python/openapi/src/com/jetbrains/python/packaging/PyPackageName.kt index eee63be13716..fa4e185dfbdb 100644 --- a/python/openapi/src/com/jetbrains/python/packaging/PyPackageName.kt +++ b/python/openapi/src/com/jetbrains/python/packaging/PyPackageName.kt @@ -10,6 +10,22 @@ value class PyPackageName private constructor(val name: String) { fun from(name: String): PyPackageName = PyPackageName(normalizePackageName(name)) + /** + * Normalizes a project name according to + * https://packaging.python.org/en/latest/specifications/name-normalization/#name-format + * + * Keeps only lowercase ASCII letters, digits, and hyphens. + * Replaces everything else with a hyphen, collapses consecutive hyphens, and strips leading/trailing hyphens. + */ + @JvmStatic + fun normalizeProjectName(name: String): String { + return name + .lowercase() + .replace(Regex("[^a-z0-9-]"), "-") + .replace(Regex("-{2,}"), "-") + .trim('-') + } + @JvmStatic fun normalizePackageName(packageName: String): String { var name = packageName.trim() diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index abe098ca05ac..33d4d3d6d125 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1729,6 +1729,7 @@ read.only.python.sdk.system.wide.read.only.message=System Python packages are re trace.context.detecting.executable=Detecting {0} Executable trace.context.generating.git=Generating git trace.context.packaging.tool.window=Packaging Tool Window +trace.context.packaging.tool.window.sdk.reload={0}: reload trace.context.packages.sdk.controller=Packages SDK Controller trace.context.add.local.python.sdk.dialog=Add Local Python SDK Dialog trace.context.add.remote.python.sdk.dialog=Add {0} Python SDK Dialog diff --git a/python/python-hatch/src/com/intellij/python/hatch/hatch.kt b/python/python-hatch/src/com/intellij/python/hatch/hatch.kt index 1b05b3601bdf..207bf89138ef 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/hatch.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/hatch.kt @@ -40,8 +40,8 @@ class EnvironmentCreationHatchError(details: @NlsSafe String) : HatchError( PyHatchBundle.message("python.hatch.error.environment.creation", details) ) -class FileSystemOperationHatchError(eelFsError: EelFsError) : HatchError( - PyHatchBundle.message("python.hatch.error.filesystem.operation", eelFsError) +class FileSystemOperationHatchError(details: @NlsSafe Any) : HatchError( + PyHatchBundle.message("python.hatch.error.filesystem.operation", details) ) diff --git a/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt b/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt index 4644ccbf6c80..7df110493f66 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt @@ -1,10 +1,9 @@ package com.intellij.python.hatch.service +import com.intellij.openapi.util.io.NioFiles import com.intellij.platform.eel.fs.EelFileSystemApi -import com.intellij.platform.eel.fs.EelFileSystemApi.ReplaceExistingDuringMove.DO_NOT_REPLACE_DIRECTORIES -import com.intellij.platform.eel.fs.move +import com.intellij.platform.eel.fs.EelFileUtils import com.intellij.platform.eel.getOr -import com.intellij.platform.eel.provider.asEelPath import com.intellij.platform.eel.provider.asNioPath import com.intellij.platform.eel.provider.getEelDescriptor import com.intellij.platform.eel.provider.toEelApi @@ -31,6 +30,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import java.io.IOException import java.nio.file.Path import kotlin.io.path.exists import kotlin.io.path.isDirectory @@ -114,14 +114,20 @@ internal class CliBasedHatchService private constructor( } hatchRuntime.hatchCli().new(projectName, tempDir.asNioPath()).getOr { return it } - val target = workingDirectoryPath.asEelPath() - eelApi.fs.move(tempDir, target).replaceExisting(DO_NOT_REPLACE_DIRECTORIES).eelIt().getOr { failure -> - return Result.failure(FileSystemOperationHatchError(failure.error)) + try { + withContext(Dispatchers.IO) { + val source = tempDir.asNioPath() + NioFiles.copyRecursively(source, workingDirectoryPath) + EelFileUtils.deleteRecursively(source) + } + } + catch (e: IOException) { + return Result.failure(FileSystemOperationHatchError(e.localizedMessage ?: e.toString())) } return Result.success(ProjectStructure( - sourceRoot = target.asNioPath().resolve("src").takeIf { it.isDirectory() }, - testRoot = target.asNioPath().resolve("tests").takeIf { it.isDirectory() }, + sourceRoot = workingDirectoryPath.resolve("src").takeIf { it.isDirectory() }, + testRoot = workingDirectoryPath.resolve("tests").takeIf { it.isDirectory() }, )) } diff --git a/python/src/com/jetbrains/python/newProjectWizard/PyV3BaseProjectSettings.kt b/python/src/com/jetbrains/python/newProjectWizard/PyV3BaseProjectSettings.kt index be4c55b91e11..79ec3be8bcbb 100644 --- a/python/src/com/jetbrains/python/newProjectWizard/PyV3BaseProjectSettings.kt +++ b/python/src/com/jetbrains/python/newProjectWizard/PyV3BaseProjectSettings.kt @@ -4,6 +4,7 @@ package com.jetbrains.python.newProjectWizard import com.intellij.openapi.GitRepositoryInitializer import com.intellij.openapi.module.Module import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.platform.ide.progress.withBackgroundProgress import com.jetbrains.python.PyBundle @@ -17,6 +18,7 @@ import com.jetbrains.python.sdk.configurePythonSdk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Settings each Python project has: [sdkCreator] and [createGitRepository] @@ -33,11 +35,20 @@ class PyV3BaseProjectSettings(var createGitRepository: Boolean = false) { } } } + if (supportsNotEmptyModuleStructure) { - sdkCreator.createPythonModuleStructure(module).getOr { return@coroutineScope it } + withBackgroundProgress(project, PyBundle.message("python.sdk.creating.python.module.structure")) { + withContext(Dispatchers.IO) { + sdkCreator.createPythonModuleStructure(module).also { + VfsUtil.markDirtyAndRefresh(false, true, true, baseDir) + } + } + }.getOr { return@coroutineScope it } } - val (sdk: Sdk, interpreterStatistics: InterpreterStatisticsInfo) = getSdkAndInterpreter(module) - .getOr { return@coroutineScope it } + + val (sdk: Sdk, interpreterStatistics: InterpreterStatisticsInfo) = withBackgroundProgress(project, PyBundle.message("python.sdk.creating.python.sdk")) { + getSdkAndInterpreter(module) + }.getOr { return@coroutineScope it } configurePythonSdk(project, module, sdk) return@coroutineScope Result.success(Pair(sdk, interpreterStatistics)) diff --git a/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt b/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt index b84d4b8e0d68..07638f0aeba5 100644 --- a/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt +++ b/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt @@ -18,6 +18,7 @@ import com.intellij.python.pyproject.model.internal.startAutoImportIfNeeded import com.intellij.util.concurrency.annotations.RequiresEdt import com.jetbrains.python.PyBundle import com.jetbrains.python.Result +import com.jetbrains.python.TraceContext import com.jetbrains.python.errorProcessing.emit import com.jetbrains.python.newProjectWizard.collector.PyProjectTypeGenerator import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector.logPythonNewProjectGenerated @@ -70,7 +71,7 @@ abstract class PyV3ProjectBaseGenerator().coroutineScope - coroutineScope.launch { + coroutineScope.launch(TraceContext(PyBundle.message("trace.context.new.project.wizard"), coroutineScope)) { generateProjectImpl(settings, module, baseDir) startAutoImportIfNeeded(project) } diff --git a/python/src/com/jetbrains/python/newProjectWizard/projectPath/ProjectPathFlows.kt b/python/src/com/jetbrains/python/newProjectWizard/projectPath/ProjectPathFlows.kt index 54a7250f78a6..0eab8a45328b 100644 --- a/python/src/com/jetbrains/python/newProjectWizard/projectPath/ProjectPathFlows.kt +++ b/python/src/com/jetbrains/python/newProjectWizard/projectPath/ProjectPathFlows.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.ui.validation.CHECK_NO_RESERVED_WORDS import com.intellij.openapi.util.NlsSafe import com.intellij.util.SystemProperties import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.MessageError import com.jetbrains.python.errorProcessing.PyResult @@ -36,7 +37,7 @@ class ProjectPathFlows private constructor(val projectPath: Flow) { /** * Flow emits project file name only when project path is valid */ - val projectName: Flow<@NlsSafe String> = projectPath.filterNotNull().map { it.name.replace(" ", "_") } + val projectName: Flow<@NlsSafe String> = projectPath.filterNotNull().map { PyPackageName.normalizeProjectName(it.name) } companion object { diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 8f38c11675cd..96a2d240b464 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -28,8 +28,8 @@ import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.packaging.PyPackageService import com.jetbrains.python.packaging.PyPackageVersionNormalizer import com.jetbrains.python.packaging.cache.PythonSimpleRepositoryCache -import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.common.PythonOutdatedPackage +import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.common.PythonPackageDetails import com.jetbrains.python.packaging.common.PythonPackageManagementListener import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification @@ -587,10 +587,11 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou showNoInterpreterMessage() return } - - serviceScope.launch(Dispatchers.Default) { - context.managerUI.reloadPackagesBackground() - refreshInstalledPackages() + serviceScope.launch(Dispatchers.Default + TraceContext(message("trace.context.packaging.tool.window"), serviceScope)) { + withContext(TraceContext(message("trace.context.packaging.tool.window.sdk.reload", context.sdk.name))) { + context.managerUI.reloadPackagesBackground() + refreshInstalledPackages() + } } } @@ -598,7 +599,7 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou val updated = SingleConfigurableEditor(project, PyRepositoriesList(project)).showAndGet() if (updated) { PythonPackagesToolwindowStatisticsCollector.repositoriesChangedEvent.log(project) - serviceScope.launch(Dispatchers.IO) { + serviceScope.launch(Dispatchers.IO + TraceContext(message("trace.context.packaging.tool.window"), serviceScope)) { val packageService = PyPackageService.getInstance() val repositoryService = service() val allRepos = repositoryService.repositories.map { it.repositoryUrl } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt index 8c939b6dc580..a5803160acbb 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt @@ -12,6 +12,7 @@ import com.intellij.python.hatch.HatchVirtualEnvironment import com.intellij.python.hatch.getHatchService import com.intellij.ui.dsl.builder.Panel import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.ErrorSink import com.jetbrains.python.errorProcessing.PyResult @@ -61,7 +62,7 @@ internal class HatchNewEnvironmentCreator

( val hatchService = module.getHatchService(hatchExecutablePath).getOr { return it } - val projectStructure = hatchService.createNewProject(module.project.name).getOr { return it } + val projectStructure = hatchService.createNewProject(PyPackageName.normalizeProjectName(module.project.name)).getOr { return it } ModuleRootModificationUtil.updateModel(module) { moduleRootModel -> val contentEntry = moduleRootModel.contentEntries.firstOrNull() ?: return@updateModel diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt index e4e7457bbaa7..b00b9069bc42 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt @@ -18,6 +18,7 @@ import com.jetbrains.python.getOrNull import com.jetbrains.python.isSuccess import com.jetbrains.python.onFailure import com.jetbrains.python.packaging.PyPackage +import com.jetbrains.python.packaging.PyPackageName import com.jetbrains.python.packaging.PyRequirement import com.jetbrains.python.packaging.PyRequirementParser import com.jetbrains.python.packaging.common.PythonOutdatedPackage @@ -33,6 +34,7 @@ import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus.Internal import org.jetbrains.annotations.Nls import java.nio.file.Path +import kotlin.io.path.name import kotlin.io.path.pathString /** @@ -96,6 +98,12 @@ suspend fun setupPoetry( // Build poetry init command with Python version constraint if available val initArgs = mutableListOf("init", "-n") + val projectName = PyPackageName.normalizeProjectName(projectPath.name) + if (projectName.isNotBlank()) { + initArgs.add("--name") + initArgs.add(projectName) + } + // Validate Python and get version info val pythonInfo = basePythonBinaryPath.validatePythonAndGetInfo().getOr { return it } val major = pythonInfo.languageLevel.majorVersion diff --git a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt index 5b9d51935319..60992caad600 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -51,9 +51,10 @@ private class UvLowLevelImpl

(private val cwd: Path, private val val initArgs = mutableListOf("init") addPythonArg(initArgs) initArgs.add("--bare") - if (cwd.name.isNotBlank()) { + val projectName = PyPackageName.normalizeProjectName(cwd.name) + if (projectName.isNotBlank()) { initArgs.add("--name") - initArgs.add(cwd.name) + initArgs.add(projectName) } initArgs.add("--no-project") uvCli.runUv(cwd, null, true, *initArgs.toTypedArray()).getOr { return it }