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