PY-86867: fix authentication credentials for pip/uv custom repository installs

Pip's `partitionPackagesBySource` used `repositoryUrl` (plain URL) instead
of `urlForInstallation` (URL with embedded credentials) for `--index-url`.

UV's `installPackage` didn't pass `--index-url` at all for custom repos.
Added `partitionPackagesBySource` that groups packages by repository and
passes authenticated URLs.

Also added reusable MockPyPIServer test infrastructure and tests for both
pip and uv authenticated installs.


(cherry picked from commit 17cd0bed09e485604fb257d2a988fd1f739dc99b)

IJ-MR-194996

GitOrigin-RevId: 4b54200b957108574a04bba4437890ed0bc9b4a1
This commit is contained in:
Vitaly Legchilkin
2026-03-06 13:11:50 +01:00
committed by intellij-monorepo-bot
parent 07bd2a69f7
commit dbee2722fa
4 changed files with 202 additions and 4 deletions

View File

@@ -0,0 +1,142 @@
// 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.junit5Tests.framework.pypi
import com.jetbrains.python.packaging.PyPackage
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import org.jetbrains.annotations.TestOnly
import java.net.InetSocketAddress
import java.nio.file.Path
import java.util.Base64
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.io.path.createDirectories
import kotlin.io.path.outputStream
import kotlin.io.path.readBytes
@TestOnly
data class MockPyPICredentials(val login: String, val password: String)
/**
* Minimal PEP 503 (Simple Repository API) mock server backed by JDK [HttpServer].
*
* Serves wheels for [packages] from [wheelDir]. If [credentials] are provided,
* the server requires HTTP Basic authentication and responds with 401 for unauthenticated requests.
*/
@TestOnly
class MockPyPIServer internal constructor(
wheelDir: Path,
packages: List<PyPackage>,
val credentials: MockPyPICredentials?,
) {
private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val port: Int get() = server.address.port
val simpleUrl: String get() = "http://127.0.0.1:$port/simple/"
init {
val expectedCredentials = credentials?.let {
Base64.getEncoder().encodeToString("${it.login}:${it.password}".toByteArray())
}
fun authenticateOrReject(exchange: HttpExchange): Boolean {
if (expectedCredentials == null) return true
val authHeader = exchange.requestHeaders.getFirst("Authorization")
if (authHeader != null && authHeader == "Basic $expectedCredentials") return true
exchange.responseHeaders.set("WWW-Authenticate", "Basic realm=\"mock-pypi\"")
exchange.sendResponseHeaders(401, -1)
exchange.close()
return false
}
// package name -> wheel bytes
val wheelMap = packages.associate { pkg ->
val wheelFileName = createMinimalWheel(wheelDir, pkg.name, pkg.version)
pkg.name to (wheelFileName to wheelDir.resolve(wheelFileName).readBytes())
}
server.createContext("/simple/") { exchange ->
if (!authenticateOrReject(exchange)) return@createContext
val path = exchange.requestURI.path.trimEnd('/')
when {
path == "/simple" -> {
val links = wheelMap.keys.joinToString("\n") { name ->
"<a href=\"/simple/$name/\">$name</a>"
}
respondHtml(exchange, "<html><body>$links</body></html>")
}
else -> {
val packageName = path.removePrefix("/simple/")
val entry = wheelMap[packageName]
if (entry != null) {
val (wheelFileName, _) = entry
respondHtml(exchange, "<html><body><a href=\"/packages/$wheelFileName\">$wheelFileName</a></body></html>")
}
else {
exchange.sendResponseHeaders(404, -1)
exchange.close()
}
}
}
}
server.createContext("/packages/") { exchange ->
if (!authenticateOrReject(exchange)) return@createContext
val fileName = exchange.requestURI.path.removePrefix("/packages/")
val wheelBytes = wheelMap.values.firstOrNull { it.first == fileName }?.second
if (wheelBytes != null) {
exchange.responseHeaders.set("Content-Type", "application/octet-stream")
exchange.sendResponseHeaders(200, wheelBytes.size.toLong())
exchange.responseBody.use { it.write(wheelBytes) }
}
else {
exchange.sendResponseHeaders(404, -1)
exchange.close()
}
}
server.start()
}
fun stop() {
server.stop(0)
}
}
private fun respondHtml(exchange: HttpExchange, html: String) {
val bytes = html.toByteArray()
exchange.responseHeaders.set("Content-Type", "text/html")
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
/**
* Creates a minimal PEP 427 wheel file in [outputDir] and returns the wheel file name.
*/
@TestOnly
internal fun createMinimalWheel(outputDir: Path, packageName: String, version: String): String {
val normalizedName = packageName.replace("-", "_")
val wheelFileName = "${normalizedName}-${version}-py3-none-any.whl"
val distInfoDir = "$normalizedName-$version.dist-info"
outputDir.createDirectories()
val wheelPath = outputDir.resolve(wheelFileName)
ZipOutputStream(wheelPath.outputStream()).use { zos ->
zos.putNextEntry(ZipEntry("$normalizedName/__init__.py"))
zos.write("# $packageName $version\n".toByteArray())
zos.closeEntry()
zos.putNextEntry(ZipEntry("$distInfoDir/METADATA"))
zos.write("Metadata-Version: 2.1\nName: $packageName\nVersion: $version\n".toByteArray())
zos.closeEntry()
zos.putNextEntry(ZipEntry("$distInfoDir/WHEEL"))
zos.write("Wheel-Version: 1.0\nGenerator: mock\nRoot-Is-Purelib: true\nTag: py3-none-any\n".toByteArray())
zos.closeEntry()
zos.putNextEntry(ZipEntry("$distInfoDir/RECORD"))
zos.closeEntry()
}
return wheelFileName
}

View File

@@ -0,0 +1,29 @@
// 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.junit5Tests.framework.pypi
import com.jetbrains.python.packaging.PyPackage
import com.intellij.testFramework.junit5.fixture.TestFixture
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.intellij.testFramework.junit5.fixture.testFixture
import org.jetbrains.annotations.TestOnly
import java.nio.file.Path
/**
* JUnit5 fixture that starts a [MockPyPIServer] serving the given [packages] and stops it on tear-down.
*
* @param packages packages to serve (each gets a minimal wheel generated automatically)
* @param credentials if provided, the server requires HTTP Basic authentication
* @param wheelDir directory for generated wheel files; defaults to a temporary directory
*/
@TestOnly
fun mockPyPIServerFixture(
vararg packages: PyPackage,
credentials: MockPyPICredentials? = null,
wheelDir: TestFixture<Path> = tempPathFixture(),
): TestFixture<MockPyPIServer> = testFixture {
val dir = wheelDir.init()
val server = MockPyPIServer(dir, packages.toList(), credentials)
initialized(server) {
server.stop()
}
}

View File

@@ -135,7 +135,7 @@ class PipPackageManagerEngine(
}
val byRepository = nonPypi
.groupBy { it.repository.repositoryUrl }
.groupBy { it.repository.urlForInstallation?.toString() }
.mapNotNull { (url, specs) ->
if (url == null || specs.isEmpty()) {
return@mapNotNull null

View File

@@ -18,6 +18,7 @@ import com.jetbrains.python.errorProcessing.PyExecResult
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.onFailure
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.packaging.PyPIPackageUtil
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PyWorkspaceMember
@@ -201,9 +202,9 @@ private class UvLowLevelImpl<P : PathHolder>(private val cwd: Path, private val
}
override suspend fun installPackage(name: PythonPackageInstallRequest, options: List<String>): PyResult<Unit> {
uvCli.runUv(cwd, venvPath, true, "pip", "install", *name.formatPackageName(), *options.toTypedArray())
.getOr { return it }
for (args in partitionPackagesBySource(name, options)) {
uvCli.runUv(cwd, venvPath, true, "pip", "install", *args).getOr { return it }
}
return PyExecResult.success(Unit)
}
@@ -296,6 +297,32 @@ private class UvLowLevelImpl<P : PathHolder>(private val cwd: Path, private val
is PythonPackageInstallRequest.ByLocation -> error("UV does not support installing from location uri")
}
private fun partitionPackagesBySource(installRequest: PythonPackageInstallRequest, options: List<String>): List<Array<String>> {
if (installRequest !is PythonPackageInstallRequest.ByRepositoryPythonPackageSpecifications) {
return listOf(arrayOf(*installRequest.formatPackageName(), *options.toTypedArray()))
}
val (pypiSpecs, nonPypi) = installRequest.specifications.partition {
val url = it.repository.urlForInstallation?.toString()
url == null || url == PyPIPackageUtil.PYPI_LIST_URL
}
val result = mutableListOf<Array<String>>()
if (pypiSpecs.isNotEmpty()) {
result.add((options + pypiSpecs.map { it.nameWithVersionsSpec }).toTypedArray())
}
nonPypi
.groupBy { it.repository.urlForInstallation?.toString() }
.forEach { (url, specs) ->
if (url == null || specs.isEmpty()) return@forEach
val names = specs.map { it.nameWithVersionsSpec }
result.add((options + listOf("--index-url", url) + names).toTypedArray())
}
return result
}
override suspend fun sync(): PyResult<String> {
return uvCli.runUv(cwd, venvPath, true, "sync", "--all-packages")
}