diff --git a/lib/modules/manager/pep621/processors/index.ts b/lib/modules/manager/pep621/processors/index.ts index 9529f131065bd3dfb4dc585f8bc34a084f11bea6..2ef6c52f3c0467aa04b04c730ecb90b8ecc56a34 100644 --- a/lib/modules/manager/pep621/processors/index.ts +++ b/lib/modules/manager/pep621/processors/index.ts @@ -1,4 +1,9 @@ import { HatchProcessor } from './hatch'; import { PdmProcessor } from './pdm'; +import { UvProcessor } from './uv'; -export const processors = [new HatchProcessor(), new PdmProcessor()]; +export const processors = [ + new HatchProcessor(), + new PdmProcessor(), + new UvProcessor(), +]; diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7488abe4fc8e744c6f549d6485258cdd35def95b --- /dev/null +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -0,0 +1,256 @@ +import { join } from 'upath'; +import { mockExecAll } from '../../../../../test/exec-util'; +import { fs, mockedFunction } from '../../../../../test/util'; +import { GlobalConfig } from '../../../../config/global'; +import type { RepoGlobalConfig } from '../../../../config/types'; +import { getPkgReleases as _getPkgReleases } from '../../../datasource'; +import type { UpdateArtifactsConfig } from '../../types'; +import { depTypes } from '../utils'; +import { UvProcessor } from './uv'; + +jest.mock('../../../../util/fs'); +jest.mock('../../../datasource'); + +const getPkgReleases = mockedFunction(_getPkgReleases); + +const config: UpdateArtifactsConfig = {}; +const adminConfig: RepoGlobalConfig = { + localDir: join('/tmp/github/some/repo'), + cacheDir: join('/tmp/cache'), + containerbaseDir: join('/tmp/cache/containerbase'), +}; + +const processor = new UvProcessor(); + +describe('modules/manager/pep621/processors/uv', () => { + describe('process()', () => { + it('returns initial dependencies if there is no tool.uv section', () => { + const pyproject = { tool: {} }; + const dependencies = [{ packageName: 'dep1' }]; + + const result = processor.process(pyproject, dependencies); + + expect(result).toEqual(dependencies); + }); + + it('includes uv dev dependencies if there is a tool.uv section', () => { + const pyproject = { + tool: { uv: { 'dev-dependencies': ['dep2==1.2.3', 'dep3==2.3.4'] } }, + }; + const dependencies = [{ packageName: 'dep1' }]; + + const result = processor.process(pyproject, dependencies); + + expect(result).toEqual([ + { packageName: 'dep1' }, + { + currentValue: '==1.2.3', + currentVersion: '1.2.3', + datasource: 'pypi', + depName: 'dep2', + depType: 'tool.uv.dev-dependencies', + packageName: 'dep2', + }, + { + currentValue: '==2.3.4', + currentVersion: '2.3.4', + datasource: 'pypi', + depName: 'dep3', + depType: 'tool.uv.dev-dependencies', + packageName: 'dep3', + }, + ]); + }); + }); + + describe('updateArtifacts()', () => { + it('returns null if there is no lock file', async () => { + fs.getSiblingFileName.mockReturnValueOnce('uv.lock'); + const updatedDeps = [{ packageName: 'dep1' }]; + const result = await processor.updateArtifacts( + { + packageFileName: 'pyproject.toml', + newPackageFileContent: '', + config, + updatedDeps, + }, + {}, + ); + expect(result).toBeNull(); + }); + + it('returns null if the lock file is unchanged', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set({ + ...adminConfig, + binarySource: 'docker', + dockerSidecarImage: 'ghcr.io/containerbase/sidecar', + }); + fs.getSiblingFileName.mockReturnValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }, { version: '3.11.2' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }, { version: '0.2.28' }], + }); + + const updatedDeps = [{ packageName: 'dep1' }]; + const result = await processor.updateArtifacts( + { + packageFileName: 'pyproject.toml', + newPackageFileContent: '', + config: {}, + updatedDeps, + }, + {}, + ); + expect(result).toBeNull(); + expect(execSnapshots).toMatchObject([ + { + cmd: 'docker pull ghcr.io/containerbase/sidecar', + }, + { + cmd: 'docker ps --filter name=renovate_sidecar -aq', + }, + { + cmd: + 'docker run --rm --name=renovate_sidecar --label=renovate_child ' + + '-v "/tmp/github/some/repo":"/tmp/github/some/repo" ' + + '-v "/tmp/cache":"/tmp/cache" ' + + '-e CONTAINERBASE_CACHE_DIR ' + + '-w "/tmp/github/some/repo" ' + + 'ghcr.io/containerbase/sidecar ' + + 'bash -l -c "' + + 'install-tool python 3.11.2 ' + + '&& ' + + 'install-tool uv 0.2.28 ' + + '&& ' + + 'uv lock --upgrade-package dep1' + + '"', + }, + ]); + }); + + it('returns artifact error', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set({ ...adminConfig, binarySource: 'docker' }); + fs.getSiblingFileName.mockReturnValueOnce('uv.lock'); + fs.readLocalFile.mockImplementationOnce(() => { + throw new Error('test error'); + }); + + const updatedDeps = [{ packageName: 'dep1' }]; + const result = await processor.updateArtifacts( + { + packageFileName: 'pyproject.toml', + newPackageFileContent: '', + config: {}, + updatedDeps, + }, + {}, + ); + expect(result).toEqual([ + { artifactError: { lockFile: 'uv.lock', stderr: 'test error' } }, + ]); + expect(execSnapshots).toEqual([]); + }); + + it('return update dep update', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.getSiblingFileName.mockReturnValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }, { version: '3.11.2' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }, { version: '0.2.28' }], + }); + + const updatedDeps = [ + { packageName: 'dep1', depType: depTypes.dependencies }, + { packageName: 'dep2', depType: depTypes.dependencies }, + { depName: 'group1/dep3', depType: depTypes.optionalDependencies }, + { depName: 'group1/dep4', depType: depTypes.optionalDependencies }, + { depName: 'dep5', depType: depTypes.uvDevDependencies }, + { depName: 'dep6', depType: depTypes.uvDevDependencies }, + { depName: 'dep7', depType: depTypes.buildSystemRequires }, + ]; + const result = await processor.updateArtifacts( + { + packageFileName: 'pyproject.toml', + newPackageFileContent: '', + config: {}, + updatedDeps, + }, + {}, + ); + expect(result).toEqual([ + { + file: { + contents: 'changed test content', + path: 'uv.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade-package dep1 --upgrade-package dep2 --upgrade-package dep3 --upgrade-package dep4 --upgrade-package dep5 --upgrade-package dep6', + }, + ]); + }); + + it('return update on lockfileMaintenance', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.getSiblingFileName.mockReturnValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }, { version: '3.11.2' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }, { version: '0.2.28' }], + }); + + const result = await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + updateType: 'lockFileMaintenance', + }, + updatedDeps: [], + }, + {}, + ); + expect(result).toEqual([ + { + file: { + contents: 'changed test content', + path: 'uv.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade', + options: { + cwd: '/tmp/github/some/repo/folder', + }, + }, + ]); + }); + }); +}); diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6529e96407c0a72df61c17840a5ba533ac67725 --- /dev/null +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -0,0 +1,147 @@ +import is from '@sindresorhus/is'; +import { quote } from 'shlex'; +import { TEMPORARY_ERROR } from '../../../../constants/error-messages'; +import { logger } from '../../../../logger'; +import { exec } from '../../../../util/exec'; +import type { ExecOptions, ToolConstraint } from '../../../../util/exec/types'; +import { getSiblingFileName, readLocalFile } from '../../../../util/fs'; +import type { + PackageDependency, + UpdateArtifact, + UpdateArtifactsResult, + Upgrade, +} from '../../types'; +import { type PyProject } from '../schema'; +import { depTypes, parseDependencyList } from '../utils'; +import type { PyProjectProcessor } from './types'; + +const uvUpdateCMD = 'uv lock'; + +export class UvProcessor implements PyProjectProcessor { + process(project: PyProject, deps: PackageDependency[]): PackageDependency[] { + const uv = project.tool?.uv; + if (is.nullOrUndefined(uv)) { + return deps; + } + + deps.push( + ...parseDependencyList( + depTypes.uvDevDependencies, + uv['dev-dependencies'], + ), + ); + + return deps; + } + + extractLockedVersions( + project: PyProject, + deps: PackageDependency[], + packageFile: string, + ): Promise<PackageDependency[]> { + return Promise.resolve(deps); + } + + async updateArtifacts( + updateArtifact: UpdateArtifact, + project: PyProject, + ): Promise<UpdateArtifactsResult[] | null> { + const { config, updatedDeps, packageFileName } = updateArtifact; + + const isLockFileMaintenance = config.updateType === 'lockFileMaintenance'; + + // abort if no lockfile is defined + const lockFileName = getSiblingFileName(packageFileName, 'uv.lock'); + try { + const existingLockFileContent = await readLocalFile(lockFileName, 'utf8'); + if (is.nullOrUndefined(existingLockFileContent)) { + logger.debug('No uv.lock found'); + return null; + } + + const pythonConstraint: ToolConstraint = { + toolName: 'python', + constraint: + config.constraints?.python ?? project.project?.['requires-python'], + }; + const uvConstraint: ToolConstraint = { + toolName: 'uv', + constraint: config.constraints?.uv, + }; + + const execOptions: ExecOptions = { + cwdFile: packageFileName, + docker: {}, + userConfiguredEnv: config.env, + toolConstraints: [pythonConstraint, uvConstraint], + }; + + // on lockFileMaintenance do not specify any packages and update the complete lock file + // else only update specific packages + let cmd: string; + if (isLockFileMaintenance) { + cmd = `${uvUpdateCMD} --upgrade`; + } else { + cmd = generateCMD(updatedDeps); + } + await exec(cmd, execOptions); + + // check for changes + const fileChanges: UpdateArtifactsResult[] = []; + const newLockContent = await readLocalFile(lockFileName, 'utf8'); + const isLockFileChanged = existingLockFileContent !== newLockContent; + if (isLockFileChanged) { + fileChanges.push({ + file: { + type: 'addition', + path: lockFileName, + contents: newLockContent, + }, + }); + } else { + logger.debug('uv.lock is unchanged'); + } + + return fileChanges.length ? fileChanges : null; + } catch (err) { + // istanbul ignore if + if (err.message === TEMPORARY_ERROR) { + throw err; + } + logger.debug({ err }, 'Failed to update uv lock file'); + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } + } +} + +function generateCMD(updatedDeps: Upgrade[]): string { + const deps: string[] = []; + + for (const dep of updatedDeps) { + switch (dep.depType) { + case depTypes.optionalDependencies: { + deps.push(dep.depName!.split('/')[1]); + break; + } + case depTypes.uvDevDependencies: { + deps.push(dep.depName!); + break; + } + case depTypes.buildSystemRequires: + // build requirements are not locked in the lock files, no need to update. + break; + default: { + deps.push(dep.packageName!); + } + } + } + + return `${uvUpdateCMD} ${deps.map((dep) => `--upgrade-package ${quote(dep)}`).join(' ')}`; +} diff --git a/lib/modules/manager/pep621/readme.md b/lib/modules/manager/pep621/readme.md index 3d3009e5b558564a21d5a2004711b1bc69adf73a..8d0992cf4dbab339e489cb471113055c9630166e 100644 --- a/lib/modules/manager/pep621/readme.md +++ b/lib/modules/manager/pep621/readme.md @@ -3,6 +3,7 @@ This manager supports updating dependencies inside `pyproject.toml` files. In addition to standard dependencies, these toolsets are also supported: - `pdm` (including `pdm.lock` files) +- `uv` (including `uv.lock` files) - `hatch` Available `depType`s: @@ -11,4 +12,5 @@ Available `depType`s: - `project.optional-dependencies` - `build-system.requires` - `tool.pdm.dev-dependencies` +- `tool.uv.dev-dependencies` - `tool.hatch.envs.<env-name>` diff --git a/lib/modules/manager/pep621/schema.ts b/lib/modules/manager/pep621/schema.ts index 5edcd8b8843d4df4d9bba3116675e99f4c6a2f89..07204d581670d723de3d8081f55231e99f75d454 100644 --- a/lib/modules/manager/pep621/schema.ts +++ b/lib/modules/manager/pep621/schema.ts @@ -54,6 +54,11 @@ export const PyProjectSchema = z.object({ .optional(), }) .optional(), + uv: z + .object({ + 'dev-dependencies': DependencyListSchema, + }) + .optional(), }) .optional(), }); diff --git a/lib/modules/manager/pep621/utils.ts b/lib/modules/manager/pep621/utils.ts index 03c5f39b545fb62c698183873534c0156e3acbec..6244523da4e62a6ce2a1a5515fdfc19ab840ef4b 100644 --- a/lib/modules/manager/pep621/utils.ts +++ b/lib/modules/manager/pep621/utils.ts @@ -17,6 +17,7 @@ export const depTypes = { dependencies: 'project.dependencies', optionalDependencies: 'project.optional-dependencies', pdmDevDependencies: 'tool.pdm.dev-dependencies', + uvDevDependencies: 'tool.uv.dev-dependencies', buildSystemRequires: 'build-system.requires', }; diff --git a/lib/util/exec/containerbase.ts b/lib/util/exec/containerbase.ts index 29462cad2513088c5f6fa4fa5aafe1e7a92ea809..4f54c4879a572e05b51f211463b5cf32fe2b6fe3 100644 --- a/lib/util/exec/containerbase.ts +++ b/lib/util/exec/containerbase.ts @@ -186,6 +186,11 @@ const allToolConfig: Record<string, ToolConfig> = { packageName: 'rust', versioning: semverVersioningId, }, + uv: { + datasource: 'pypi', + packageName: 'uv', + versioning: pep440VersioningId, + }, yarn: { datasource: 'npm', packageName: 'yarn',