From 53c5b869d7908cbc91367ba14343c19d5fa5ba50 Mon Sep 17 00:00:00 2001 From: Maxime Brunet <max@brnt.mx> Date: Thu, 20 Feb 2025 13:13:00 +0000 Subject: [PATCH] feat(pip-compile): support tool version constraints with uv (#34029) --- .../manager/pip-compile/artifacts.spec.ts | 94 ++++++++++++++++++- lib/modules/manager/pip-compile/artifacts.ts | 12 ++- .../manager/pip-compile/common.spec.ts | 9 +- lib/modules/manager/pip-compile/common.ts | 58 +++++++++--- lib/modules/manager/pip-compile/readme.md | 2 +- lib/modules/manager/pip-compile/types.ts | 1 + 6 files changed, 147 insertions(+), 29 deletions(-) diff --git a/lib/modules/manager/pip-compile/artifacts.spec.ts b/lib/modules/manager/pip-compile/artifacts.spec.ts index d162929e55..75217a8ca4 100644 --- a/lib/modules/manager/pip-compile/artifacts.spec.ts +++ b/lib/modules/manager/pip-compile/artifacts.spec.ts @@ -29,7 +29,7 @@ jest.mock('../../../util/http'); jest.mock('../../datasource', () => mockDeep()); const requirementsWithUv = `# This file was autogenerated by uv via the following command: -# uv pip compile --generate-hashes --output-file=requirements.txt --universal requirements.in +# uv pip compile --generate-hashes --output-file=requirements.txt --python-version=3.11 --universal requirements.in attrs==21.2.0 \ --hash=sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1 \ --hash=sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb @@ -50,6 +50,12 @@ function getCommandInHeader(command: string) { `; } +function getCommandInUvHeader(command: string) { + return `# This file was autogenerated by uv via the following command: +# ${command} +`; +} + const simpleHeader = getCommandInHeader('pip-compile requirements.in'); const adminConfig: RepoGlobalConfig = { @@ -294,6 +300,90 @@ describe('modules/manager/pip-compile/artifacts', () => { ]); }); + it('installs Python version according to the uv option', async () => { + GlobalConfig.set({ ...adminConfig, binarySource: 'install' }); + datasource.getPkgReleases.mockResolvedValueOnce({ + releases: [ + { version: '3.11.0' }, + { version: '3.11.1' }, + { version: '3.12.0' }, + ], + }); + const execSnapshots = mockExecAll(); + git.getRepoStatus.mockResolvedValue( + partial<StatusResult>({ + modified: ['requirements.txt'], + }), + ); + fs.readLocalFile.mockResolvedValueOnce( + getCommandInUvHeader( + 'uv pip compile --python-version=3.11 requirements.in', + ), + ); + expect( + await updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: 'some new content', + config: { + ...config, + lockFiles: ['requirements.txt'], + constraints: { uv: '0.5.27' }, + }, + }), + ).not.toBeNull(); + + expect(execSnapshots).toMatchObject([ + { cmd: 'install-tool python 3.11.1' }, + { cmd: 'install-tool uv 0.5.27' }, + { + cmd: 'uv pip compile --python-version=3.11 requirements.in', + options: { cwd: '/tmp/github/some/repo' }, + }, + ]); + }); + + it('install uv tools without constraints', async () => { + GlobalConfig.set({ ...adminConfig, binarySource: 'install' }); + // python + datasource.getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.12.0' }], + }); + // uv + datasource.getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.5.27' }], + }); + const execSnapshots = mockExecAll(); + git.getRepoStatus.mockResolvedValue( + partial<StatusResult>({ + modified: ['requirements.txt'], + }), + ); + fs.readLocalFile.mockResolvedValueOnce( + getCommandInUvHeader('uv pip compile requirements.in'), + ); + expect( + await updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: 'some new content', + config: { + ...config, + lockFiles: ['requirements.txt'], + }, + }), + ).not.toBeNull(); + + expect(execSnapshots).toMatchObject([ + { cmd: 'install-tool python 3.12.0' }, + { cmd: 'install-tool uv 0.5.27' }, + { + cmd: 'uv pip compile requirements.in', + options: { cwd: '/tmp/github/some/repo' }, + }, + ]); + }); + it('installs latest Python version if no constraints and not in header', async () => { GlobalConfig.set({ ...adminConfig, binarySource: 'install' }); datasource.getPkgReleases.mockResolvedValueOnce({ @@ -506,7 +596,7 @@ describe('modules/manager/pip-compile/artifacts', () => { extractHeaderCommand(requirementsWithUv, 'subdir/requirements.txt'), ), ).toBe( - 'uv pip compile --generate-hashes --output-file=requirements.txt --universal requirements.in', + 'uv pip compile --generate-hashes --output-file=requirements.txt --python-version=3.11 --universal requirements.in', ); }); diff --git a/lib/modules/manager/pip-compile/artifacts.ts b/lib/modules/manager/pip-compile/artifacts.ts index 3608808a6a..30b0a7bf6d 100644 --- a/lib/modules/manager/pip-compile/artifacts.ts +++ b/lib/modules/manager/pip-compile/artifacts.ts @@ -115,11 +115,12 @@ export async function updateArtifacts({ await deleteLocalFile(outputFileName); } const compileArgs = extractHeaderCommand(existingOutput, outputFileName); - const pythonVersion = extractPythonVersion( - compileArgs.commandType, - existingOutput, - outputFileName, - ); + let pythonVersion: string | undefined; + if (compileArgs.commandType === 'uv') { + pythonVersion = compileArgs.pythonVersion; + } else { + pythonVersion = extractPythonVersion(existingOutput, outputFileName); + } const cwd = inferCommandExecDir(outputFileName, compileArgs.outputFile); const upgradePackages = updatedDeps.filter((dep) => dep.isLockfileUpdate); const packageFiles: PackageFileContent[] = []; @@ -139,6 +140,7 @@ export async function updateArtifacts({ const cmd = constructPipCompileCmd(compileArgs, upgradePackages); const execOptions = await getExecOptions( config, + compileArgs.commandType, cwd, getRegistryCredVarsFromPackageFiles(packageFiles), pythonVersion, diff --git a/lib/modules/manager/pip-compile/common.spec.ts b/lib/modules/manager/pip-compile/common.spec.ts index 1188ec47b3..aa04aa764c 100644 --- a/lib/modules/manager/pip-compile/common.spec.ts +++ b/lib/modules/manager/pip-compile/common.spec.ts @@ -188,7 +188,6 @@ describe('modules/manager/pip-compile/common', () => { it('extracts Python version from valid header', () => { expect( extractPythonVersion( - 'pip-compile', getCommandInHeader('pip-compile reqs.in'), 'reqs.txt', ), @@ -196,13 +195,7 @@ describe('modules/manager/pip-compile/common', () => { }); it('returns undefined if version cannot be extracted', () => { - expect( - extractPythonVersion('pip-compile', '', 'reqs.txt'), - ).toBeUndefined(); - }); - - it('returns undefined if the command type is uv', () => { - expect(extractPythonVersion('uv', '', 'reqs.txt')).toBeUndefined(); + expect(extractPythonVersion('', 'reqs.txt')).toBeUndefined(); }); }); diff --git a/lib/modules/manager/pip-compile/common.ts b/lib/modules/manager/pip-compile/common.ts index a055edc032..d2664cb03b 100644 --- a/lib/modules/manager/pip-compile/common.ts +++ b/lib/modules/manager/pip-compile/common.ts @@ -3,7 +3,11 @@ import { split } from 'shlex'; import upath from 'upath'; import { logger } from '../../../logger'; import { isNotNullOrUndefined } from '../../../util/array'; -import type { ExecOptions, ExtraEnv } from '../../../util/exec/types'; +import type { + ExecOptions, + ExtraEnv, + ToolConstraint, +} from '../../../util/exec/types'; import { ensureCacheDir } from '../../../util/fs'; import { ensureLocalPath } from '../../../util/fs/util'; import * as hostRules from '../../../util/host-rules'; @@ -30,6 +34,7 @@ export function getPythonVersionConstraint( return undefined; } + export function getPipToolsVersionConstraint( config: UpdateArtifactsConfig, ): string { @@ -43,14 +48,44 @@ export function getPipToolsVersionConstraint( return ''; } + +export function getUvVersionConstraint(config: UpdateArtifactsConfig): string { + const { constraints = {} } = config; + const { uv } = constraints; + + if (is.string(uv)) { + logger.debug('Using uv constraint from config'); + return uv; + } + + return ''; +} + +export function getToolVersionConstraint( + config: UpdateArtifactsConfig, + commandType: CommandType, +): ToolConstraint { + if (commandType === 'uv') { + return { + toolName: 'uv', + constraint: getUvVersionConstraint(config), + }; + } + + return { + toolName: 'pip-tools', + constraint: getPipToolsVersionConstraint(config), + }; +} + export async function getExecOptions( config: UpdateArtifactsConfig, + commandType: CommandType, cwd: string, extraEnv: ExtraEnv<string>, extractedPythonVersion: string | undefined, ): Promise<ExecOptions> { const constraint = getPythonVersionConstraint(config, extractedPythonVersion); - const pipToolsConstraint = getPipToolsVersionConstraint(config); const execOptions: ExecOptions = { cwd: ensureLocalPath(cwd), docker: {}, @@ -60,10 +95,7 @@ export async function getExecOptions( toolName: 'python', constraint, }, - { - toolName: 'pip-tools', - constraint: pipToolsConstraint, - }, + getToolVersionConstraint(config, commandType), ], extraEnv: { PIP_CACHE_DIR: await ensureCacheDir('pip'), @@ -93,7 +125,11 @@ const pipOptionsWithArguments = [ '--constraint', ...commonOptionsWithArguments, ]; -const uvOptionsWithArguments = ['--constraints', ...commonOptionsWithArguments]; +const uvOptionsWithArguments = [ + '--constraints', + '--python-version', + ...commonOptionsWithArguments, +]; export const optionsWithArguments = [ ...pipOptionsWithArguments, ...uvOptionsWithArguments, @@ -192,6 +228,8 @@ export function extractHeaderCommand( throw new Error('Cannot use multiple --output-file options'); } result.outputFile = upath.normalize(value); + } else if (option === '--python-version') { + result.pythonVersion = value; } else if (option === '--index-url') { if (result.indexUrl) { throw new Error('Cannot use multiple --index-url options'); @@ -241,15 +279,9 @@ const pythonVersionRegex = regEx( ); export function extractPythonVersion( - commandType: CommandType, content: string, fileName: string, ): string | undefined { - // uv's headers do not include the Python version - // https://github.com/astral-sh/uv/issues/3588 - if (commandType === 'uv') { - return; - } const match = pythonVersionRegex.exec(content); if (match?.groups === undefined) { logger.warn( diff --git a/lib/modules/manager/pip-compile/readme.md b/lib/modules/manager/pip-compile/readme.md index 1432bb4f4c..6f6e8caa9a 100644 --- a/lib/modules/manager/pip-compile/readme.md +++ b/lib/modules/manager/pip-compile/readme.md @@ -66,7 +66,7 @@ Because `pip-compile` will update source files with their associated manager you ### Configuration of Python version -By default Renovate extracts Python version from the header. +By default Renovate extracts Python version from the header for `pip-compile`, and from the `--python-version` option for `uv`. To get Renovate to use another version of Python, add a constraints` rule to the Renovate config: ```json diff --git a/lib/modules/manager/pip-compile/types.ts b/lib/modules/manager/pip-compile/types.ts index ad0b9dcaf6..2ed36062e1 100644 --- a/lib/modules/manager/pip-compile/types.ts +++ b/lib/modules/manager/pip-compile/types.ts @@ -12,6 +12,7 @@ export interface PipCompileArgs { command: string; commandType: CommandType; constraintsFiles?: string[]; + pythonVersion?: string; extra?: string[]; allExtras?: boolean; extraIndexUrl?: string[]; -- GitLab