PY-57566 Support PEP 660 editable installs.

GitOrigin-RevId: cca68d9504ae4f10a774cfbdd03c38f5176f88fd
This commit is contained in:
Marcus Mews
2025-11-14 21:59:55 +00:00
committed by intellij-monorepo-bot
parent 9d1b9721f4
commit 33673afd98
7 changed files with 86 additions and 19 deletions
+45 -5
View File
@@ -4,8 +4,48 @@ import sys
_helpers_root = os.path.dirname(os.path.abspath(__file__))
_working_dir = os.getcwd()
for root in sys.path:
# The current working dir is not automatically included but can be if PYTHONPATH
# contains an empty entry.
if root != _helpers_root and root != os.curdir and root != _working_dir:
print(root)
def get_pep660_editable_roots_from_metadata():
try:
# importlib.metadata was added in 3.8
from importlib.metadata import distributions, Distribution
except ImportError:
return []
def read_from_direct_url_json(dist):
# type: (Distribution) -> str | None
import json
direct_url_file = dist._path / 'direct_url.json'
if direct_url_file.exists():
with direct_url_file.open() as f:
data = json.load(f)
if data.get('dir_info', {}).get('editable'):
return data.get('url', '')
return None
editable_roots = []
for dist in distributions():
url = None # type: str | None
try:
# 'origin' was added in Python 3.13
if dist.origin.dir_info.editable:
url = dist.origin.url
except AttributeError:
url = read_from_direct_url_json(dist)
FILE_PREFIX = "file://"
if url and url.startswith(FILE_PREFIX):
editable_roots.append(url[len(FILE_PREFIX):])
return editable_roots
if __name__ == "__main__":
for root in sys.path:
# The current working dir is not automatically included but can be if PYTHONPATH
# contains an empty entry.
if root != _helpers_root and root != os.curdir and root != _working_dir:
print(root)
for editable_root in get_pep660_editable_roots_from_metadata():
print(editable_root)
@@ -237,8 +237,10 @@ class Args(vararg initialArgs: String) {
return this
}
fun addArgs(args: List<String>): Args = addArgs(*args.toTypedArray())
/**
* This file will be copied to remote machine and its remote name will be added to the list of arguments.
* This file will be copied to remote machine, and its remote name will be added to the list of arguments.
* Use [argGenerator] to modify name
*/
fun addLocalFile(localFile: Path, argGenerator: FileArgGenerator = FileArgGenerator { it }): Args {
@@ -267,5 +269,3 @@ class Args(vararg initialArgs: String) {
}
}
}
fun Args.addArgs(args: List<String>): Args = addArgs(*args.toTypedArray())
@@ -0,0 +1,22 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.AdditionalLibraryRootsProvider
import com.intellij.openapi.roots.SyntheticLibrary
import com.intellij.openapi.vfs.VirtualFile
import org.jetbrains.annotations.Unmodifiable
// Notes (please do not remove):
// - If you think you want to add library roots here: Consider extending file community/python/helpers/syspath.py instead.
// - If used, this file needs to be added to community/python/python-psi-impl/resources/intellij.python.psi.impl.xml.
@Suppress("unused")
internal class PyAdditionalLibraryRootsProvider : AdditionalLibraryRootsProvider() {
override fun getRootsToWatch(project: Project): @Unmodifiable Collection<VirtualFile> {
return emptyList()
}
override fun getAdditionalProjectLibraries(project: Project): Collection<SyntheticLibrary> {
return emptyList()
}
}
@@ -29,6 +29,7 @@ public interface RootVisitor {
* @param root what we're visiting.
* @param module the module to which the root belongs, or null
* @param sdk the SDK to which the root belongs, or null
* @param isModuleSource true iff the root belongs to the module in case both module and sdk are present
*
* @return false when visiting must stop.
*/
@@ -1,12 +1,13 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.management
import com.intellij.openapi.util.NlsSafe
import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification
import org.jetbrains.annotations.ApiStatus
import java.net.URI
@ApiStatus.Internal
sealed class PythonPackageInstallRequest(val title: String) {
sealed class PythonPackageInstallRequest(val title: @NlsSafe String) {
data class ByLocation(val location: URI) : PythonPackageInstallRequest(location.toString())
data class ByRepositoryPythonPackageSpecifications(val specifications: List<PythonRepositoryPackageSpecification>) : PythonPackageInstallRequest(
specifications.joinToString(", ") { it.nameWithVersionSpec })
@@ -103,10 +103,10 @@ class PipPackageManagerEngine(
runPackagingTool(operation, Args(*arguments.toTypedArray()))
private fun partitionPackagesBySource(installRequest: PythonPackageInstallRequest): List<List<String>> {
private fun partitionPackagesBySource(installRequest: PythonPackageInstallRequest): List<Args> {
when (installRequest) {
is PythonPackageInstallRequest.ByLocation -> {
return listOf(listOf(installRequest.location.toString()))
return listOf(Args(installRequest.location.toString()))
}
is PythonPackageInstallRequest.ByRepositoryPythonPackageSpecifications -> {
return partitionPackagesBySource(installRequest.specifications)
@@ -114,7 +114,7 @@ class PipPackageManagerEngine(
}
}
private fun partitionPackagesBySource(specifications: List<PythonRepositoryPackageSpecification>): List<List<String>> {
private fun partitionPackagesBySource(specifications: List<PythonRepositoryPackageSpecification>): List<Args> {
val (pypiSpecs, nonPypi) = specifications.partition {
val url = it.repository.urlForInstallation?.toString()
url == null || url == PyPIPackageUtil.PYPI_LIST_URL
@@ -127,25 +127,27 @@ class PipPackageManagerEngine(
return@mapNotNull null
}
listOf(
val argsStr = listOf(
"--index-url",
url
) + specs.map { it.nameWithVersionSpec }
Args().addArgs(argsStr)
}
val pypi = mutableListOf<List<String>>()
val pypi = mutableListOf<Args>()
if (pypiSpecs.isNotEmpty()) {
pypi.add(pypiSpecs.map { it.nameWithVersionsSpec })
pypi.add(Args().addArgs(pypiSpecs.map { it.nameWithVersionsSpec }))
}
return pypi + byRepository
}
suspend fun performInstall(argumentsGroups: List<List<String>>, options: List<String>): PyResult<Unit> {
suspend fun performInstall(argumentsGroups: List<Args>, options: List<String>): PyResult<Unit> {
for (argumentsGroup in argumentsGroups) {
val result = runPackagingTool(
operation = "install",
arguments = argumentsGroup + options
arguments = argumentsGroup.addArgs(options)
)
result.onFailure {
@@ -10,6 +10,7 @@ import com.intellij.openapi.projectRoots.Sdk;
import com.intellij.openapi.roots.ModuleRootManager;
import com.intellij.openapi.roots.OrderRootType;
import com.intellij.openapi.vfs.VirtualFile;
import com.jetbrains.python.PyNames;
import com.jetbrains.python.sdk.legacy.PythonSdkUtil;
import org.jetbrains.annotations.NotNull;
@@ -38,9 +39,9 @@ public class PythonSpellcheckerGenerateDictionariesAction extends AnAction {
if (root.getName().equals("Lib")) {
generator.addFolder("python", root);
generator.excludeFolder(root.findChild("test"));
generator.excludeFolder(root.findChild("site-packages"));
generator.excludeFolder(root.findChild(PyNames.SITE_PACKAGES));
}
else if (root.getName().equals("site-packages")) {
else if (root.getName().equals(PyNames.SITE_PACKAGES)) {
VirtualFile djangoRoot = root.findChild("django");
if (djangoRoot != null) {
generator.addFolder("django", djangoRoot);