diff --git a/lib/manager/api.ts b/lib/manager/api.ts index 295cb5dfae9973025259f5367b136b0696549423..f6e05be9c587294f51c234f3295f1f7ee8bd656a 100644 --- a/lib/manager/api.ts +++ b/lib/manager/api.ts @@ -43,6 +43,7 @@ import * as nodenv from './nodenv'; import * as npm from './npm'; import * as nuget from './nuget'; import * as nvm from './nvm'; +import * as pipCompile from './pip-compile'; import * as pip_requirements from './pip_requirements'; import * as pip_setup from './pip_setup'; import * as pipenv from './pipenv'; @@ -110,6 +111,7 @@ api.set('nodenv', nodenv); api.set('npm', npm); api.set('nuget', nuget); api.set('nvm', nvm); +api.set('pip-compile', pipCompile); api.set('pip_requirements', pip_requirements); api.set('pip_setup', pip_setup); api.set('pipenv', pipenv); diff --git a/lib/manager/pip-compile/__snapshots__/artifacts.spec.ts.snap b/lib/manager/pip-compile/__snapshots__/artifacts.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..7fa37078f87784a9da2add329656891eaec4f85c --- /dev/null +++ b/lib/manager/pip-compile/__snapshots__/artifacts.spec.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`.updateArtifacts() catches errors 1`] = ` +Array [ + Object { + "artifactError": Object { + "lockFile": "requirements.txt", + "stderr": "not found", + }, + }, +] +`; + +exports[`.updateArtifacts() returns null if unchanged 1`] = ` +Array [ + Object { + "cmd": "pip-compile", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + "maxBuffer": 10485760, + "timeout": 900000, + }, + }, +] +`; + +exports[`.updateArtifacts() returns updated requirements.txt 1`] = ` +Array [ + Object { + "cmd": "pip-compile", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + "maxBuffer": 10485760, + "timeout": 900000, + }, + }, +] +`; + +exports[`.updateArtifacts() returns updated requirements.txt when doing lockfile maintenance 1`] = ` +Array [ + Object { + "cmd": "pip-compile", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + "maxBuffer": 10485760, + "timeout": 900000, + }, + }, +] +`; + +exports[`.updateArtifacts() supports docker mode 1`] = ` +Array [ + Object { + "cmd": "docker pull renovate/python", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker ps --filter name=renovate_python -aq", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker run --rm --name=renovate_python --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/renovate/cache\\":\\"/tmp/renovate/cache\\" -w \\"/tmp/github/some/repo\\" renovate/python bash -l -c \\"pip install --user pip-tools && pip-compile\\"", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + "maxBuffer": 10485760, + "timeout": 900000, + }, + }, +] +`; + +exports[`.updateArtifacts() uses pipenv version from config 1`] = ` +Array [ + Object { + "cmd": "docker pull renovate/python", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker ps --filter name=renovate_python -aq", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker run --rm --name=renovate_python --label=renovate_child -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -v \\"/tmp/renovate/cache\\":\\"/tmp/renovate/cache\\" -w \\"/tmp/github/some/repo\\" renovate/python bash -l -c \\"pip install --user pip-tools1.2.3 && pip-compile\\"", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + "maxBuffer": 10485760, + "timeout": 900000, + }, + }, +] +`; diff --git a/lib/manager/pip-compile/artifacts.spec.ts b/lib/manager/pip-compile/artifacts.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf8f52af8b750813f2d553115b2a9d26ebdd575e --- /dev/null +++ b/lib/manager/pip-compile/artifacts.spec.ts @@ -0,0 +1,159 @@ +import { exec as _exec } from 'child_process'; +import _fs from 'fs-extra'; +import { join } from 'upath'; +import { envMock, mockExecAll } from '../../../test/exec-util'; +import { git, mocked } from '../../../test/util'; +import { setAdminConfig } from '../../config/admin'; +import type { RepoAdminConfig } from '../../config/types'; +import * as docker from '../../util/exec/docker'; +import * as _env from '../../util/exec/env'; +import type { StatusResult } from '../../util/git'; +import type { UpdateArtifactsConfig } from '../types'; +import * as pipCompile from './artifacts'; + +jest.mock('fs-extra'); +jest.mock('child_process'); +jest.mock('../../util/exec/env'); +jest.mock('../../util/git'); +jest.mock('../../util/host-rules'); +jest.mock('../../util/http'); + +const fs: jest.Mocked<typeof _fs> = _fs as any; +const exec: jest.Mock<typeof _exec> = _exec as any; +const env = mocked(_env); + +const adminConfig: RepoAdminConfig = { + // `join` fixes Windows CI + localDir: join('/tmp/github/some/repo'), + cacheDir: join('/tmp/renovate/cache'), +}; +const dockerAdminConfig = { ...adminConfig, binarySource: 'docker' }; + +const config: UpdateArtifactsConfig = {}; +const lockMaintenanceConfig = { ...config, isLockFileMaintenance: true }; + +describe('.updateArtifacts()', () => { + beforeEach(() => { + jest.resetAllMocks(); + env.getChildProcessEnv.mockReturnValue({ + ...envMock.basic, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US', + }); + setAdminConfig(adminConfig); + docker.resetPrefetchedImages(); + }); + + it('returns if no requirements.txt found', async () => { + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: '', + config, + }) + ).toBeNull(); + }); + + it('returns null if unchanged', async () => { + fs.readFile.mockResolvedValueOnce('content' as any); + const execSnapshots = mockExecAll(exec); + fs.readFile.mockReturnValueOnce('content' as any); + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: 'some new content', + config, + }) + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + + it('returns updated requirements.txt', async () => { + fs.readFile.mockResolvedValueOnce('current requirements.txt' as any); + const execSnapshots = mockExecAll(exec); + git.getRepoStatus.mockResolvedValue({ + modified: ['requirements.txt'], + } as StatusResult); + fs.readFile.mockReturnValueOnce('New requirements.txt' as any); + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: 'some new content', + config: { ...config, constraints: { python: '3.7' } }, + }) + ).not.toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + + it('supports docker mode', async () => { + setAdminConfig(dockerAdminConfig); + const execSnapshots = mockExecAll(exec); + git.getRepoStatus.mockResolvedValue({ + modified: ['requirements.txt'], + } as StatusResult); + fs.readFile.mockReturnValueOnce('new lock' as any); + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: 'some new content', + config, + }) + ).not.toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + + it('catches errors', async () => { + fs.readFile.mockResolvedValueOnce('Current requirements.txt' as any); + fs.outputFile.mockImplementationOnce(() => { + throw new Error('not found'); + }); + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: '{}', + config, + }) + ).toMatchSnapshot(); + }); + + it('returns updated requirements.txt when doing lockfile maintenance', async () => { + fs.readFile.mockResolvedValueOnce('Current requirements.txt' as any); + const execSnapshots = mockExecAll(exec); + git.getRepoStatus.mockResolvedValue({ + modified: ['requirements.txt'], + } as StatusResult); + fs.readFile.mockReturnValueOnce('New requirements.txt' as any); + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: '{}', + config: lockMaintenanceConfig, + }) + ).not.toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + + it('uses pipenv version from config', async () => { + setAdminConfig(dockerAdminConfig); + const execSnapshots = mockExecAll(exec); + git.getRepoStatus.mockResolvedValue({ + modified: ['requirements.txt'], + } as StatusResult); + fs.readFile.mockReturnValueOnce('new lock' as any); + expect( + await pipCompile.updateArtifacts({ + packageFileName: 'requirements.in', + updatedDeps: [], + newPackageFileContent: 'some new content', + config: { ...config, constraints: { pipTools: '1.2.3' } }, + }) + ).not.toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); +}); diff --git a/lib/manager/pip-compile/artifacts.ts b/lib/manager/pip-compile/artifacts.ts new file mode 100644 index 0000000000000000000000000000000000000000..58056c07d93dc836012582004093613b668553ae --- /dev/null +++ b/lib/manager/pip-compile/artifacts.ts @@ -0,0 +1,103 @@ +import is from '@sindresorhus/is'; +import { quote as pipCompile } from 'shlex'; +import { TEMPORARY_ERROR } from '../../constants/error-messages'; +import { logger } from '../../logger'; +import { ExecOptions, exec } from '../../util/exec'; +import { deleteLocalFile, readLocalFile, writeLocalFile } from '../../util/fs'; +import { getRepoStatus } from '../../util/git'; +import type { + UpdateArtifact, + UpdateArtifactsConfig, + UpdateArtifactsResult, +} from '../types'; + +function getPythonConstraint( + config: UpdateArtifactsConfig +): string | undefined | null { + const { constraints = {} } = config; + const { python } = constraints; + + if (python) { + logger.debug('Using python constraint from config'); + return python; + } + + return undefined; +} + +function getPipToolsConstraint(config: UpdateArtifactsConfig): string { + const { constraints = {} } = config; + const { pipTools } = constraints; + + if (is.string(pipTools)) { + logger.debug('Using pipTools constraint from config'); + return pipTools; + } + + return ''; +} + +export async function updateArtifacts({ + packageFileName: inputFileName, + newPackageFileContent: newInputContent, + config, +}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> { + const outputFileName = inputFileName.replace(/(\.in)?$/, '.txt'); + logger.debug( + `pipCompile.updateArtifacts(${inputFileName}->${outputFileName})` + ); + const existingOutput = await readLocalFile(outputFileName, 'utf8'); + if (!existingOutput) { + logger.debug('No pip-compile output file found'); + return null; + } + try { + await writeLocalFile(inputFileName, newInputContent); + if (config.isLockFileMaintenance) { + await deleteLocalFile(outputFileName); + } + const cmd = 'pip-compile'; + const tagConstraint = getPythonConstraint(config); + const pipToolsConstraint = getPipToolsConstraint(config); + const execOptions: ExecOptions = { + cwdFile: inputFileName, + docker: { + image: 'python', + tagConstraint, + tagScheme: 'pep440', + preCommands: [ + `pip install --user ${pipCompile(`pip-tools${pipToolsConstraint}`)}`, + ], + }, + }; + logger.debug({ cmd }, 'pip-compile command'); + await exec(cmd, execOptions); + const status = await getRepoStatus(); + if (!status?.modified.includes(outputFileName)) { + return null; + } + logger.debug('Returning updated pip-compile result'); + return [ + { + file: { + name: outputFileName, + contents: await readLocalFile(outputFileName, 'utf8'), + }, + }, + ]; + } catch (err) { + // istanbul ignore if + if (err.message === TEMPORARY_ERROR) { + throw err; + } + logger.debug({ err }, 'Failed to pip-compile'); + return [ + { + artifactError: { + lockFile: outputFileName, + stderr: err.message, + }, + }, + ]; + } +} diff --git a/lib/manager/pip-compile/index.ts b/lib/manager/pip-compile/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..606569dcf2a0348eda5dea90b406fee7f6878461 --- /dev/null +++ b/lib/manager/pip-compile/index.ts @@ -0,0 +1,10 @@ +import { LANGUAGE_PYTHON } from '../../constants/languages'; + +export { extractPackageFile } from '../pip_requirements/extract'; +export { updateArtifacts } from './artifacts'; + +export const language = LANGUAGE_PYTHON; + +export const defaultConfig = { + fileMatch: [], +}; diff --git a/lib/manager/pip-compile/readme.md b/lib/manager/pip-compile/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..42330b600641fb3c2b94f831cc11e1f54d521500 --- /dev/null +++ b/lib/manager/pip-compile/readme.md @@ -0,0 +1,42 @@ +Due to limited functionality, the `pip-compile` manager should be considered in an "alpha" stage, which means it's not ready for production use for the majority of end users. +We welcome feedback and bug reports! + +The current implementation has some limitations. +Read the full document before you start using the `pip-compile` manager. + +### Non-configured fileMatch + +The `pip-compile` manager has an empty array for default `fileMatch`, meaning it won't match any files ever by default. +You can "activate" the manager by specifying a `fileMatch` pattern such as: + +```json +{ + "pip-compile": { + "fileMatch": ["(^|/)requirements\\.in$"] + } +} +``` + +### Assumption of `.in`/`.txt` + +If Renovate matches/extracts a file, it assumes that the corresponding output file is found by swapping the `.in` for `.txt`. +e.g. `requirements.in` => `requirements.txt` + +Therefore it will not work if files are in separate directories, including `input/requirements.in` and `output/requirements.txt`. + +If no `.in` suffix is found, then a `.txt` suffix is appended for the output file, e.g. `foo.file` would look for a corresponding `foo.file.txt`. + +We intend to make the mapping configurable in future iterations. + +### Configuration of Python version + +By default Renovate uses the latest version of Python. +To get Renovate to use another version of Python, add a contraints` rule to the Renovate config: + +```json +{ + "constraints": { + "python": "3.7" + } +} +```