IJPL-222854 support notebook documents in Python LSP

Route Jupyter notebooks through the LSP notebook protocol so Python language servers work with cell subdocuments instead of the whole .ipynb file. The adapter maps host positions
  and ranges to cell URIs, sends notebook open/change/close/save notifications, and keeps python-lsp-core decoupled from Jupyter through a notebook language resolver EP.

  Register the Jupyter resolver from the Python/Jupyter plugin and let Python LSP descriptors opt into notebook-backed files. Skip Ty pull diagnostics for notebooks until Ty
  supports notebook diagnostics, while keeping diagnostics enabled for regular Python files.

  Add Ty and Pyrefly notebook LSP environment coverage for protocol notifications, code insight requests, range mapping, type inference, and the Ty diagnostics regression. Fix the
  Python test-env UV bootstrap used by that coverage by handling flat Windows zip archives separately from Unix archives with a root directory.

GitOrigin-RevId: f958d48142cc376473a2be47b618e00366643f18
This commit is contained in:
Anton Efimchuk
2026-06-10 11:12:26 +00:00
committed by intellij-monorepo-bot
parent d9fea38eb3
commit 76a5a5e7fd
8 changed files with 85 additions and 20 deletions
@@ -23,6 +23,10 @@ jvm_library(
),
kotlinc_opts = ":custom_testFramework",
module_name = "intellij.notebooks.visualization.testFramework",
resource_strip_prefix = "resources",
resources = glob(
["resources/**/*"],
),
visibility = ["//visibility:public"],
deps = [
# do not sort,
@@ -24,6 +24,7 @@
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
@@ -0,0 +1,26 @@
<idea-plugin visibility="internal">
<dependencies>
<!-- region Generated dependencies - run `Generate Product Layouts` to regenerate -->
<module name="intellij.libraries.assertj.core"/>
<module name="intellij.libraries.gson"/>
<module name="intellij.libraries.kotlinx.serialization.core"/>
<module name="intellij.markdown"/>
<module name="intellij.notebooks.jupyter.core"/>
<module name="intellij.notebooks.ui"/>
<module name="intellij.notebooks.visualization"/>
<module name="intellij.platform.analysis"/>
<module name="intellij.platform.codeStyle"/>
<module name="intellij.platform.core"/>
<module name="intellij.platform.core.impl"/>
<module name="intellij.platform.core.ui"/>
<module name="intellij.platform.editor.ex"/>
<module name="intellij.platform.editor.ui"/>
<module name="intellij.platform.ide.impl"/>
<module name="intellij.platform.lang"/>
<module name="intellij.platform.lang.impl"/>
<module name="intellij.platform.projectModel"/>
<module name="intellij.platform.testFramework"/>
<module name="intellij.platform.util.coroutines"/>
<!-- endregion -->
</dependencies>
</idea-plugin>
@@ -97,16 +97,15 @@ abstract class PyEnvironmentProvider<S : PyEnvironmentSpec<S>>(
abstract suspend fun setupEnvironment(context: Context, spec: S): PyEnvironment
/**
* Extract archive using Decompressor framework.
* Extract archive using the root directory used by Python archives.
* Supports tar.gz, .tgz, and .zip formats.
*
*
* @param archiveFile Path to the archive file
* @param targetDir Directory where archive contents will be extracted
* @param prefixToStrip Path prefix to strip during extraction (e.g., "uv-x86_64-unknown-linux-gnu" for UV archives)
* @throws IllegalArgumentException if archive format is not supported
*/
protected fun unpackArchive(archiveFile: Path, targetDir: Path, prefixToStrip: String? = null) {
com.intellij.python.test.env.core.unpackArchive(archiveFile, targetDir, prefixToStrip)
protected fun unpackArchive(archiveFile: Path, targetDir: Path) {
unpackArchive(archiveFile, targetDir, prefixToStrip = "python")
}
/**
@@ -119,13 +118,13 @@ abstract class PyEnvironmentProvider<S : PyEnvironmentSpec<S>>(
* Can be used to create nested or dependent environments.
*/
val factory: PyEnvironmentFactory
/**
* Working directory where environments are created.
* Each environment typically gets its own subdirectory.
*/
val workingDir: Path
/**
* Cache directory for storing downloaded artifacts.
* Used to avoid re-downloading Python distributions and other resources.
@@ -2,6 +2,7 @@ package com.intellij.python.test.env.core
import com.intellij.openapi.diagnostic.Logger
import com.intellij.util.io.Decompressor
import com.intellij.util.system.LowLevelLocalMachineAccess
import com.intellij.util.system.OS
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -91,6 +92,7 @@ suspend fun installPipPackages(
executeProcess(command, logger, "pip")
}
@OptIn(LowLevelLocalMachineAccess::class)
fun markExecutable(logger: Logger, executable: Path) {
if (OS.CURRENT != OS.Windows) {
logger.info("Setting executable permissions")
@@ -111,16 +113,15 @@ fun markExecutable(logger: Logger, executable: Path) {
*
* @param archiveFile Path to the archive file
* @param targetDir Directory where archive contents will be extracted
* @param prefixToStrip Path prefix to strip during extraction (e.g., "uv-x86_64-unknown-linux-gnu" for UV archives)
* @param prefixToStrip Path prefix to strip during extraction. If null, archive paths are extracted as-is.
* @throws IllegalStateException if archive format is not supported
*/
@ApiStatus.Internal
fun unpackArchive(archiveFile: Path, targetDir: Path, prefixToStrip: String? = null) {
fun unpackArchive(archiveFile: Path, targetDir: Path, prefixToStrip: String?) {
val fileName = archiveFile.fileName.toString()
when {
fileName.endsWith(".tar.gz", ignoreCase = true) || fileName.endsWith(".tgz", ignoreCase = true) -> {
val decompressor = Decompressor.Tar(archiveFile)
.removePrefixPath("python")
if (prefixToStrip != null) {
decompressor.removePrefixPath(prefixToStrip)
}
@@ -128,7 +129,6 @@ fun unpackArchive(archiveFile: Path, targetDir: Path, prefixToStrip: String? = n
}
fileName.endsWith(".zip", ignoreCase = true) -> {
val decompressor = Decompressor.Zip(archiveFile).withZipExtensions()
.removePrefixPath("python")
if (prefixToStrip != null) {
decompressor.removePrefixPath(prefixToStrip)
}
@@ -6,9 +6,9 @@ import com.intellij.openapi.application.writeAction
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.test.env.common.PredefinedPyEnvironments
import com.intellij.python.test.env.common.createEnvironment
import com.intellij.python.test.env.core.PyEnvironment
import com.intellij.python.test.env.core.PyEnvironmentFactory
import com.intellij.python.test.env.core.PyEnvironmentSpec
import com.intellij.python.test.env.junit5.RunOnEnvironmentsExtension
import com.intellij.python.test.env.junit5.getOrCreatePyEnvironmentFactory
import com.intellij.testFramework.junit5.fixture.TestFixture
@@ -31,16 +31,20 @@ fun pySdkFixture(): TestFixture<SdkFixture<PyEnvironment>> = testFixture { conte
*/
fun pySdkFixture(
env: PredefinedPyEnvironments,
): TestFixture<SdkFixture<PyEnvironment>> = pySdkFixture(env.spec)
fun pySdkFixture(
envSpec: PyEnvironmentSpec<*>,
): TestFixture<SdkFixture<PyEnvironment>> = testFixture { context ->
val factory = getOrCreatePyEnvironmentFactory(context.extensionContext)
initializedPySdkFixture(factory, env)
initializedPySdkFixture(factory, envSpec)
}
private suspend fun TestFixtureInitializer.R<SdkFixture<PyEnvironment>>.initializedPySdkFixture(
factory: PyEnvironmentFactory,
env: PredefinedPyEnvironments,
envSpec: PyEnvironmentSpec<*>,
): TestFixtureInitializer.InitializedTestFixture<SdkFixture<PyEnvironment>> {
val env = factory.createEnvironment(env)
val env = factory.createEnvironment(envSpec)
return initializedTestFixture(env)
}
@@ -0,0 +1,20 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.test.env.junit5
import com.intellij.python.test.env.uv.getUvArchivePrefixToStrip
import com.intellij.util.system.OS
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
internal class UvExecutableProviderTest {
@Test
fun `Windows uv archive is extracted without prefix stripping`() {
assertNull(getUvArchivePrefixToStrip(OS.Windows, "uv-x86_64-pc-windows-msvc.zip"))
}
@Test
fun `Unix uv archive is extracted from its root directory`() {
assertEquals("uv-x86_64-unknown-linux-gnu", getUvArchivePrefixToStrip(OS.Linux, "uv-x86_64-unknown-linux-gnu.tar.gz"))
}
}
@@ -7,6 +7,7 @@ import com.intellij.python.test.env.core.extractIfNecessary
import com.intellij.python.test.env.core.markExecutable
import com.intellij.python.test.env.core.unpackArchive
import com.intellij.util.system.CpuArch
import com.intellij.util.system.LowLevelLocalMachineAccess
import com.intellij.util.system.OS
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
@@ -22,12 +23,14 @@ private val LOG = Logger.getInstance("com.intellij.python.test.env.uv")
* @return Path to the UV executable
*/
@ApiStatus.Internal
@OptIn(LowLevelLocalMachineAccess::class)
suspend fun getOrDownloadUvExecutable(uvVersion: String): Path {
val logger = LOG
val downloadUrl = getUvDownloadUrl(uvVersion)
val archiveFileName = downloadUrl.substringAfterLast('/')
val archiveBaseName = archiveFileName.substringBeforeLast(".tar.gz").substringBeforeLast(".zip")
val prefixToStrip = getUvArchivePrefixToStrip(OS.CURRENT, archiveFileName)
val uvDir = PyEnvDownloadCache.cacheDirectory().resolve("uv").resolve(archiveBaseName)
val uvExecutable = uvDir.resolve(if (OS.CURRENT == OS.Windows) "uv.exe" else "uv")
@@ -35,14 +38,20 @@ suspend fun getOrDownloadUvExecutable(uvVersion: String): Path {
logger.info("Downloading UV archive: $archiveFileName")
val cachedArchive = PyEnvDownloadCache.getOrDownload(downloadUrl, archiveFileName)
logger.info("UV archive downloaded: $cachedArchive")
logger.info("Extracting UV binary (stripping prefix: $archiveBaseName)")
unpackArchive(cachedArchive, target, prefixToStrip = archiveBaseName)
logger.info("Extracting UV binary (stripping prefix: $prefixToStrip)")
unpackArchive(cachedArchive, target, prefixToStrip = prefixToStrip)
markExecutable(logger, uvExecutable)
}
return uvExecutable
}
@ApiStatus.Internal
fun getUvArchivePrefixToStrip(os: OS, archiveFileName: String): String? {
return if (os == OS.Windows) null else archiveFileName.substringBeforeLast(".tar.gz").substringBeforeLast(".zip")
}
@OptIn(LowLevelLocalMachineAccess::class)
private fun getUvDownloadUrl(version: String): String {
val arch = when {
CpuArch.isArm64() -> "aarch64"
@@ -50,12 +59,14 @@ private fun getUvDownloadUrl(version: String): String {
else -> "x86_64"
}
val platform = when (OS.CURRENT) {
val os = OS.CURRENT
val platform = when (os) {
OS.Windows -> "$arch-pc-windows-msvc"
OS.macOS -> "$arch-apple-darwin"
OS.Linux -> "$arch-unknown-linux-gnu"
else -> throw IllegalStateException("Unsupported OS: ${OS.CURRENT}")
else -> throw IllegalStateException("Unsupported OS: $os")
}
val extension = if (os == OS.Windows) "zip" else "tar.gz"
return "$UV_RELEASES_URL/$version/uv-$platform.tar.gz"
return "$UV_RELEASES_URL/$version/uv-$platform.$extension"
}