mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-03-22 15:19:59 +07:00
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:
committed by
intellij-monorepo-bot
parent
07bd2a69f7
commit
dbee2722fa
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user