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
This commit is contained in:
Vitaly Legchilkin
2026-03-04 18:44:08 +00:00
committed by intellij-monorepo-bot
parent 1a6543038b
commit 67235b83d2
11 changed files with 71 additions and 24 deletions
@@ -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()
@@ -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
@@ -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)
)
@@ -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() },
))
}
@@ -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))
@@ -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<TYPE_SPECIFIC_SETTINGS : PyV3ProjectType
@RequiresEdt
override fun generateProject(project: Project, baseDir: VirtualFile, settings: PyV3BaseProjectSettings, module: Module) {
val coroutineScope = project.service<MyService>().coroutineScope
coroutineScope.launch {
coroutineScope.launch(TraceContext(PyBundle.message("trace.context.new.project.wizard"), coroutineScope)) {
generateProjectImpl(settings, module, baseDir)
startAutoImportIfNeeded(project)
}
@@ -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<Path?>) {
/**
* 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 {
@@ -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<PyPackageRepositories>()
val allRepos = repositoryService.repositories.map { it.repositoryUrl }
@@ -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<P : PathHolder>(
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
@@ -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
@@ -51,9 +51,10 @@ private class UvLowLevelImpl<P : PathHolder>(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 }