diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 8855775f9d5ed11c707530dd3f62f8eebad970b2..ef109b2932ab45a3917b6657faa18fb9764d3339 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -1226,6 +1226,19 @@ const options = [ mergeable: true, cli: false, }, + { + name: 'pipenv', + releaseStatus: 'beta', + description: 'Configuration object for Pipfile files', + stage: 'package', + type: 'json', + default: { + enabled: false, + fileMatch: ['(^|/)Pipfile$'], + }, + mergeable: true, + cli: false, + }, { name: 'python', description: 'Configuration object for python', diff --git a/lib/manager/index.js b/lib/manager/index.js index f9379a1e8abffdb3043eb07ec14bf6f33b492fbe..e4a67138b3596040586f009b693fbd55b1b6a04f 100644 --- a/lib/manager/index.js +++ b/lib/manager/index.js @@ -15,6 +15,7 @@ const managerList = [ 'nvm', 'pip_requirements', 'pip_setup', + 'pipenv', 'terraform', 'travis', 'nuget', diff --git a/lib/manager/pipenv/artifacts.js b/lib/manager/pipenv/artifacts.js new file mode 100644 index 0000000000000000000000000000000000000000..7118e2db1fe0961d1d3f0c2b6de6ffc807241a03 --- /dev/null +++ b/lib/manager/pipenv/artifacts.js @@ -0,0 +1,104 @@ +const { exec } = require('child-process-promise'); +const fs = require('fs-extra'); +const os = require('os'); +const upath = require('upath'); + +module.exports = { + getArtifacts, +}; + +async function getArtifacts( + pipfileName, + updatedDeps, + newPipfileContent, + config +) { + logger.debug(`pipenv.getArtifacts(${pipfileName})`); + process.env.PIPENV_CACHE_DIR = + process.env.PIPENV_CACHE_DIR || + upath.join(os.tmpdir(), '/renovate/cache/pipenv'); + await fs.ensureDir(process.env.PIPENV_CACHE_DIR); + logger.debug('Using pipenv cache ' + process.env.PIPENV_CACHE_DIR); + const lockFileName = pipfileName + '.lock'; + const existingLockFileContent = await platform.getFile(lockFileName); + if (!existingLockFileContent) { + logger.debug('No Pipfile.lock found'); + return null; + } + const cwd = upath.join(config.localDir, upath.dirname(pipfileName)); + let stdout; + let stderr; + try { + const localPipfileFileName = upath.join(config.localDir, pipfileName); + await fs.outputFile(localPipfileFileName, newPipfileContent); + const localLockFileName = upath.join(config.localDir, lockFileName); + const env = + config.global && config.global.exposeEnv + ? process.env + : { + HOME: process.env.HOME, + PATH: process.env.PATH, + LC_ALL: process.env.LC_ALL, + LANG: process.env.LANG, + PIPENV_CACHE_DIR: process.env.PIPENV_CACHE_DIR, + }; + const startTime = process.hrtime(); + let cmd; + if (config.binarySource === 'docker') { + logger.info('Running pipenv via docker'); + cmd = `docker run --rm `; + const volumes = [config.localDir]; + cmd += volumes.map(v => `-v ${v}:${v} `).join(''); + const envVars = ['LC_ALL', 'LANG', 'PIPENV_CACHE_DIR']; + cmd += envVars.map(e => `-e ${e} `).join(''); + cmd += `-w ${cwd} `; + cmd += `renovate/pipenv pipenv`; + } else { + logger.info('Running pipenv via global command'); + cmd = 'pipenv'; + } + const args = 'lock'; + logger.debug({ cmd, args }, 'pipenv lock command'); + ({ stdout, stderr } = await exec(`${cmd} ${args}`, { + cwd, + shell: true, + env, + })); + const duration = process.hrtime(startTime); + const seconds = Math.round(duration[0] + duration[1] / 1e9); + stdout = stdout ? stdout.replace(/(Locking|Running)[^\s]*?\s/g, '') : null; + logger.info( + { seconds, type: 'Pipfile.lock', stdout, stderr }, + 'Generated lockfile' + ); + // istanbul ignore if + if (config.gitFs) { + const status = await platform.getRepoStatus(); + if (!status.modified.includes(lockFileName)) { + return null; + } + } else { + const newLockFileContent = await fs.readFile(localLockFileName, 'utf8'); + + if (newLockFileContent === existingLockFileContent) { + logger.debug('Pipfile.lock is unchanged'); + return null; + } + } + logger.debug('Returning updated Pipfile.lock'); + return { + file: { + name: lockFileName, + contents: await fs.readFile(localLockFileName, 'utf8'), + }, + }; + } catch (err) { + logger.warn({ err, message: err.message }, 'Failed to update Pipfile.lock'); + return { + lockFileError: { + lockFile: lockFileName, + stderr: err.message, + }, + }; + } +} diff --git a/lib/manager/pipenv/extract.js b/lib/manager/pipenv/extract.js new file mode 100644 index 0000000000000000000000000000000000000000..8457e66da97ec067619ee0ac40c879d93c6c1abd --- /dev/null +++ b/lib/manager/pipenv/extract.js @@ -0,0 +1,89 @@ +const toml = require('toml'); + +// based on https://www.python.org/dev/peps/pep-0508/#names +const packageRegex = /^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$/i; +const rangePattern = require('@renovate/pep440/lib/specifier').RANGE_PATTERN; + +const specifierPartPattern = `\\s*${rangePattern.replace( + /\?<\w+>/g, + '?:' +)}\\s*`; +const specifierPattern = `${specifierPartPattern}(?:,${specifierPartPattern})*`; + +module.exports = { + extractPackageFile, +}; + +function extractPackageFile(content) { + logger.debug('pipenv.extractPackageFile()'); + let pipfile; + try { + pipfile = toml.parse(content); + } catch (err) { + logger.debug({ err }, 'Error parsing Pipfile'); + return null; + } + let registryUrls; + if (pipfile.source) { + registryUrls = pipfile.source.map(source => + source.url.replace(/simple(\/)?$/, 'pypi/') + ); + } + + const deps = [ + ...extractFromSection(pipfile, 'packages', registryUrls), + ...extractFromSection(pipfile, 'dev-packages', registryUrls), + ]; + if (!deps.length) { + return null; + } + return { deps }; +} + +function extractFromSection(pipfile, section, registryUrls) { + if (!(section in pipfile)) { + return []; + } + const specifierRegex = new RegExp(`^${specifierPattern}$`); + const deps = Object.entries(pipfile[section]) + .map(x => { + const [depName, requirements] = x; + let currentValue; + let pipenvNestedVersion; + if (requirements.version) { + currentValue = requirements.version; + pipenvNestedVersion = true; + } else { + currentValue = requirements; + pipenvNestedVersion = false; + } + const packageMatches = packageRegex.exec(depName); + const specifierMatches = specifierRegex.exec(currentValue); + if (!packageMatches) { + logger.debug( + `Skipping dependency with malformed package name "${depName}".` + ); + return null; + } + if (!specifierMatches) { + logger.debug( + `Skipping dependency with malformed version specifier "${currentValue}".` + ); + return null; + } + const dep = { + depName, + currentValue, + pipenvNestedVersion, + purl: 'pkg:pypi/' + depName, + versionScheme: 'pep440', + depType: section, + }; + if (registryUrls) { + dep.registryUrls = registryUrls; + } + return dep; + }) + .filter(Boolean); + return deps; +} diff --git a/lib/manager/pipenv/index.js b/lib/manager/pipenv/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e2001f3d8c1b3a3dd89c46ba3fa25a7c64cc1cd2 --- /dev/null +++ b/lib/manager/pipenv/index.js @@ -0,0 +1,12 @@ +const { extractPackageFile } = require('./extract'); +const { updateDependency } = require('./update'); +const { getArtifacts } = require('./artifacts'); + +const language = 'python'; + +module.exports = { + extractPackageFile, + updateDependency, + getArtifacts, + language, +}; diff --git a/lib/manager/pipenv/update.js b/lib/manager/pipenv/update.js new file mode 100644 index 0000000000000000000000000000000000000000..995d71e33357551bdb826761a1bad7a145f3de1f --- /dev/null +++ b/lib/manager/pipenv/update.js @@ -0,0 +1,80 @@ +const _ = require('lodash'); +const toml = require('toml'); + +module.exports = { + updateDependency, +}; + +// Return true if the match string is found at index in content +function matchAt(content, index, match) { + return content.substring(index, index + match.length) === match; +} + +// Replace oldString with newString at location index of content +function replaceAt(content, index, oldString, newString) { + logger.debug(`Replacing ${oldString} with ${newString} at index ${index}`); + return ( + content.substr(0, index) + + newString + + content.substr(index + oldString.length) + ); +} + +function updateDependency(fileContent, upgrade) { + try { + const { depType, depName, newValue, pipenvNestedVersion } = upgrade; + logger.debug(`pipenv.updateDependency(): ${newValue}`); + const parsedContents = toml.parse(fileContent); + let oldVersion; + if (pipenvNestedVersion) { + oldVersion = parsedContents[depType][depName].version; + } else { + oldVersion = parsedContents[depType][depName]; + } + if (oldVersion === newValue) { + logger.info('Version is already updated'); + return fileContent; + } + if (pipenvNestedVersion) { + parsedContents[depType][depName].version = newValue; + } else { + parsedContents[depType][depName] = newValue; + } + const searchString = `"${oldVersion}"`; + const newString = `"${newValue}"`; + let newFileContent = null; + let searchIndex = fileContent.indexOf(`[${depType}]`) + depType.length; + for (; searchIndex < fileContent.length; searchIndex += 1) { + // First check if we have a hit for the old version + if (matchAt(fileContent, searchIndex, searchString)) { + logger.trace(`Found match at index ${searchIndex}`); + // Now test if the result matches + const testContent = replaceAt( + fileContent, + searchIndex, + searchString, + newString + ); + // Compare the parsed toml structure of old and new + if (_.isEqual(parsedContents, toml.parse(testContent))) { + newFileContent = testContent; + break; + } else { + logger.debug('Mismatched replace at searchIndex ' + searchIndex); + } + } + } + // istanbul ignore if + if (!newFileContent) { + logger.info( + { fileContent, parsedContents, depType, depName, newValue }, + 'Warning: updateDependency error' + ); + return fileContent; + } + return newFileContent; + } catch (err) { + logger.info({ err }, 'Error setting new package version'); + return null; + } +} diff --git a/package.json b/package.json index b1381616b96194c7077d8473f50465b5519a1700..e0ce39d2eccfd5d85abfe7e97b4174ab257bcde2 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ "semver-utils": "1.1.4", "simple-git": "1.107.0", "slugify": "1.3.3", + "toml": "2.3.3", + "tomlify-j0.4": "3.0.0", "traverse": "0.6.6", "upath": "1.1.0", "validator": "10.9.0", diff --git a/test/_fixtures/pipenv/Pipfile1 b/test/_fixtures/pipenv/Pipfile1 new file mode 100644 index 0000000000000000000000000000000000000000..2780a7a308442a9d0e4b230af4fc6bb9d1f8029e --- /dev/null +++ b/test/_fixtures/pipenv/Pipfile1 @@ -0,0 +1,22 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[[source]] +url = "http://example.com/private-pypi/" +verify_ssl = false +name = "private-pypi" + +[packages] +some-package = "==0.3.1" +some-other-package = "==1.0.0" +"_invalid-package" = "==1.0.0" +invalid-version = "==0 0" +pytest-benchmark = {version = "==1.0.0", extras = ["histogram"]} + +[dev-packages] +dev-package = "==0.1.0" + +[requires] +python_version = "3.6" diff --git a/test/_fixtures/pipenv/Pipfile2 b/test/_fixtures/pipenv/Pipfile2 new file mode 100644 index 0000000000000000000000000000000000000000..b84f8069426ec0b5a59c54d00c242097f3ef162f --- /dev/null +++ b/test/_fixtures/pipenv/Pipfile2 @@ -0,0 +1,17 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +Django = "==1" +distribute = "==0.6.27" +dj-database-url = "==0.2" +psycopg2 = "==2.4.5" +wsgiref = "==0.1.2" + +[dev-packages] + +[requires] +python_version = "3.6" + diff --git a/test/manager/__snapshots__/manager-docs.spec.js.snap b/test/manager/__snapshots__/manager-docs.spec.js.snap index 837f06458ff1e26faa9b73b0d46bb464d9901e56..c51cf469ec0462a730cded1cfa168913c3174c47 100644 --- a/test/manager/__snapshots__/manager-docs.spec.js.snap +++ b/test/manager/__snapshots__/manager-docs.spec.js.snap @@ -20,6 +20,7 @@ Array [ "nvm", "pip_requirements", "pip_setup", + "pipenv", "terraform", "travis", ] diff --git a/test/manager/pipenv/__snapshots__/artifacts.spec.js.snap b/test/manager/pipenv/__snapshots__/artifacts.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..7c50564902f60324e3d5ec6b34838cb45fa572bc --- /dev/null +++ b/test/manager/pipenv/__snapshots__/artifacts.spec.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`.getArtifacts() catches errors 1`] = ` +Object { + "lockFileError": Object { + "lockFile": "Pipfile.lock", + "stderr": "not found", + }, +} +`; diff --git a/test/manager/pipenv/__snapshots__/extract.spec.js.snap b/test/manager/pipenv/__snapshots__/extract.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..571a1b7e4aa014e6283bad6a7ee6aa18b2fb412d --- /dev/null +++ b/test/manager/pipenv/__snapshots__/extract.spec.js.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lib/manager/pipenv/extract extractPackageFile() extracts dependencies 1`] = ` +Array [ + Object { + "currentValue": "==0.3.1", + "depName": "some-package", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/some-package", + "registryUrls": Array [ + "https://pypi.org/pypi/", + "http://example.com/private-pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==1.0.0", + "depName": "some-other-package", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/some-other-package", + "registryUrls": Array [ + "https://pypi.org/pypi/", + "http://example.com/private-pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==1.0.0", + "depName": "pytest-benchmark", + "depType": "packages", + "pipenvNestedVersion": true, + "purl": "pkg:pypi/pytest-benchmark", + "registryUrls": Array [ + "https://pypi.org/pypi/", + "http://example.com/private-pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==0.1.0", + "depName": "dev-package", + "depType": "dev-packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/dev-package", + "registryUrls": Array [ + "https://pypi.org/pypi/", + "http://example.com/private-pypi/", + ], + "versionScheme": "pep440", + }, +] +`; + +exports[`lib/manager/pipenv/extract extractPackageFile() extracts multiple dependencies 1`] = ` +Array [ + Object { + "currentValue": "==1", + "depName": "Django", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/Django", + "registryUrls": Array [ + "https://pypi.org/pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==0.6.27", + "depName": "distribute", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/distribute", + "registryUrls": Array [ + "https://pypi.org/pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==0.2", + "depName": "dj-database-url", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/dj-database-url", + "registryUrls": Array [ + "https://pypi.org/pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==2.4.5", + "depName": "psycopg2", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/psycopg2", + "registryUrls": Array [ + "https://pypi.org/pypi/", + ], + "versionScheme": "pep440", + }, + Object { + "currentValue": "==0.1.2", + "depName": "wsgiref", + "depType": "packages", + "pipenvNestedVersion": false, + "purl": "pkg:pypi/wsgiref", + "registryUrls": Array [ + "https://pypi.org/pypi/", + ], + "versionScheme": "pep440", + }, +] +`; diff --git a/test/manager/pipenv/__snapshots__/update.spec.js.snap b/test/manager/pipenv/__snapshots__/update.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..ec1f8a30684317d050b149855bb9fe3e7d9a6f52 --- /dev/null +++ b/test/manager/pipenv/__snapshots__/update.spec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`manager/pipenv/update updateDependency replaces existing value 1`] = ` +"[[source]] +url = \\"https://pypi.org/simple\\" +verify_ssl = true +name = \\"pypi\\" + +[[source]] +url = \\"http://example.com/private-pypi/\\" +verify_ssl = false +name = \\"private-pypi\\" + +[packages] +some-package = \\"==1.0.1\\" +some-other-package = \\"==1.0.0\\" +\\"_invalid-package\\" = \\"==1.0.0\\" +invalid-version = \\"==0 0\\" +pytest-benchmark = {version = \\"==1.0.0\\", extras = [\\"histogram\\"]} + +[dev-packages] +dev-package = \\"==0.1.0\\" + +[requires] +python_version = \\"3.6\\" +" +`; + +exports[`manager/pipenv/update updateDependency replaces nested value 1`] = ` +"[[source]] +url = \\"https://pypi.org/simple\\" +verify_ssl = true +name = \\"pypi\\" + +[[source]] +url = \\"http://example.com/private-pypi/\\" +verify_ssl = false +name = \\"private-pypi\\" + +[packages] +some-package = \\"==0.3.1\\" +some-other-package = \\"==1.0.0\\" +\\"_invalid-package\\" = \\"==1.0.0\\" +invalid-version = \\"==0 0\\" +pytest-benchmark = {version = \\"==1.9.1\\", extras = [\\"histogram\\"]} + +[dev-packages] +dev-package = \\"==0.1.0\\" + +[requires] +python_version = \\"3.6\\" +" +`; + +exports[`manager/pipenv/update updateDependency upgrades dev packages 1`] = ` +"[[source]] +url = \\"https://pypi.org/simple\\" +verify_ssl = true +name = \\"pypi\\" + +[[source]] +url = \\"http://example.com/private-pypi/\\" +verify_ssl = false +name = \\"private-pypi\\" + +[packages] +some-package = \\"==0.3.1\\" +some-other-package = \\"==1.0.0\\" +\\"_invalid-package\\" = \\"==1.0.0\\" +invalid-version = \\"==0 0\\" +pytest-benchmark = {version = \\"==1.0.0\\", extras = [\\"histogram\\"]} + +[dev-packages] +dev-package = \\"==0.2.0\\" + +[requires] +python_version = \\"3.6\\" +" +`; diff --git a/test/manager/pipenv/artifacts.spec.js b/test/manager/pipenv/artifacts.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..809f3986a975dff74c0bbb8f0213dc0e7d24202c --- /dev/null +++ b/test/manager/pipenv/artifacts.spec.js @@ -0,0 +1,63 @@ +jest.mock('fs-extra'); +jest.mock('child-process-promise'); +jest.mock('../../../lib/util/host-rules'); + +const fs = require('fs-extra'); +const { exec } = require('child-process-promise'); +const pipenv = require('../../../lib/manager/pipenv/artifacts'); + +const config = { + localDir: '/tmp/github/some/repo', +}; + +describe('.getArtifacts()', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns if no Pipfile.lock found', async () => { + expect(await pipenv.getArtifacts('Pipfile', [], '', config)).toBeNull(); + }); + it('returns null if unchanged', async () => { + platform.getFile.mockReturnValueOnce('Current Pipfile.lock'); + exec.mockReturnValueOnce({ + stdout: '', + stderror: '', + }); + fs.readFile = jest.fn(() => 'Current Pipfile.lock'); + expect(await pipenv.getArtifacts('Pipfile', [], '{}', config)).toBeNull(); + }); + it('returns updated Pipfile.lock', async () => { + platform.getFile.mockReturnValueOnce('Current Pipfile.lock'); + exec.mockReturnValueOnce({ + stdout: '', + stderror: '', + }); + fs.readFile = jest.fn(() => 'New Pipfile.lock'); + expect( + await pipenv.getArtifacts('Pipfile', [], '{}', config) + ).not.toBeNull(); + }); + it('supports docker mode', async () => { + platform.getFile.mockReturnValueOnce('Current Pipfile.lock'); + exec.mockReturnValueOnce({ + stdout: '', + stderror: '', + }); + fs.readFile = jest.fn(() => 'New Pipfile.lock'); + expect( + await pipenv.getArtifacts('Pipfile', [], '{}', { + ...config, + binarySource: 'docker', + }) + ).not.toBeNull(); + }); + it('catches errors', async () => { + platform.getFile.mockReturnValueOnce('Current Pipfile.lock'); + fs.outputFile = jest.fn(() => { + throw new Error('not found'); + }); + expect( + await pipenv.getArtifacts('Pipfile', [], '{}', config) + ).toMatchSnapshot(); + }); +}); diff --git a/test/manager/pipenv/extract.spec.js b/test/manager/pipenv/extract.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b4d9180ce4197db27ac3cdee4a56d7eaa3f58f62 --- /dev/null +++ b/test/manager/pipenv/extract.spec.js @@ -0,0 +1,59 @@ +const fs = require('fs'); +const { extractPackageFile } = require('../../../lib/manager/pipenv/extract'); + +const pipfile1 = fs.readFileSync('test/_fixtures/pipenv/Pipfile1', 'utf8'); +const pipfile2 = fs.readFileSync('test/_fixtures/pipenv/Pipfile2', 'utf8'); + +describe('lib/manager/pipenv/extract', () => { + describe('extractPackageFile()', () => { + let config; + beforeEach(() => { + config = {}; + }); + it('returns null for empty', () => { + expect(extractPackageFile('[packages]\r\n', config)).toBe(null); + }); + it('returns null for invalid toml file', () => { + expect(extractPackageFile('nothing here', config)).toBe(null); + }); + it('extracts dependencies', () => { + const res = extractPackageFile(pipfile1, config).deps; + expect(res).toMatchSnapshot(); + expect(res).toHaveLength(4); + }); + it('extracts multiple dependencies', () => { + const res = extractPackageFile(pipfile2, config).deps; + expect(res).toMatchSnapshot(); + expect(res).toHaveLength(5); + }); + it('ignores invalid package names', () => { + const content = '[packages]\r\nfoo = "==1.0.0"\r\n_invalid = "==1.0.0"'; + const res = extractPackageFile(content, config).deps; + expect(res).toHaveLength(1); + }); + it('ignores invalid versions', () => { + const content = '[packages]\r\nfoo = "==1.0.0"\r\nsome-package = "==0 0"'; + const res = extractPackageFile(content, config).deps; + expect(res).toHaveLength(1); + }); + it('extracts all sources', () => { + const content = + '[[source]]\r\nurl = "source-url"\r\n' + + '[[source]]\r\nurl = "other-source-url"\r\n' + + '[packages]\r\nfoo = "==1.0.0"\r\n'; + const res = extractPackageFile(content, config).deps; + expect(res[0].registryUrls).toEqual(['source-url', 'other-source-url']); + }); + it('converts simple-API URLs to JSON-API URLs', () => { + const content = + '[[source]]\r\nurl = "https://my-pypi/foo/simple/"\r\n' + + '[[source]]\r\nurl = "https://other-pypi/foo/simple"\r\n' + + '[packages]\r\nfoo = "==1.0.0"\r\n'; + const res = extractPackageFile(content, config).deps; + expect(res[0].registryUrls).toEqual([ + 'https://my-pypi/foo/pypi/', + 'https://other-pypi/foo/pypi/', + ]); + }); + }); +}); diff --git a/test/manager/pipenv/update.spec.js b/test/manager/pipenv/update.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cff86fa89b51aa2e8b02162aca3020e9352a3ea3 --- /dev/null +++ b/test/manager/pipenv/update.spec.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const { updateDependency } = require('../../../lib/manager/pipenv/update'); + +const pipfile = fs.readFileSync('test/_fixtures/pipenv/Pipfile1', 'utf8'); + +describe('manager/pipenv/update', () => { + describe('updateDependency', () => { + it('replaces existing value', () => { + const upgrade = { + depName: 'some-package', + newValue: '==1.0.1', + depType: 'packages', + }; + const res = updateDependency(pipfile, upgrade); + expect(res).not.toEqual(pipfile); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('handles already replace values', () => { + const upgrade = { + depName: 'some-package', + newValue: '==0.3.1', + depType: 'packages', + }; + const res = updateDependency(pipfile, upgrade); + expect(res).toEqual(pipfile); + }); + it('replaces nested value', () => { + const upgrade = { + depName: 'pytest-benchmark', + newValue: '==1.9.1', + depType: 'packages', + pipenvNestedVersion: true, + }; + const res = updateDependency(pipfile, upgrade); + expect(res).not.toEqual(pipfile); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('upgrades dev packages', () => { + const upgrade = { + depName: 'dev-package', + newValue: '==0.2.0', + depType: 'dev-packages', + }; + const res = updateDependency(pipfile, upgrade); + expect(res).not.toEqual(pipfile); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('returns null if error', () => { + const res = updateDependency(null, null); + expect(res).toBe(null); + }); + }); +}); diff --git a/test/workers/repository/extract/__snapshots__/index.spec.js.snap b/test/workers/repository/extract/__snapshots__/index.spec.js.snap index b45f5f37dbb0ecc88708ab7b8b9e51228b9f3f23..1bd977c380156a4bf18eb53d9a2a91509cd18b42 100644 --- a/test/workers/repository/extract/__snapshots__/index.spec.js.snap +++ b/test/workers/repository/extract/__snapshots__/index.spec.js.snap @@ -56,6 +56,9 @@ Object { "pip_setup": Array [ Object {}, ], + "pipenv": Array [ + Object {}, + ], "terraform": Array [ Object {}, ], diff --git a/website/docs/configuration-options.md b/website/docs/configuration-options.md index 710b02ce15b41b4668dc5f83e790f13319e4fd36..f0fe54f71b3814d340db303a621e84e189c6036f 100644 --- a/website/docs/configuration-options.md +++ b/website/docs/configuration-options.md @@ -578,6 +578,12 @@ Add configuration here to specifically override settings for `setup.py` files. Warning: `setup.py` support is currently in beta, so is not enabled by default. You will need to configure `{ "pip_setup": { "enabled": true }}" to enable. +## pipenv + +Add configuration here to change pipenv settings, e.g. to change the file pattern for pipenv so that you can use filenames other than Pipfile. + +Warning: 'pipenv' support is currently in beta, so it is not enabled by default. You will need to configure `{ "pipenv": { "enabled": true }}" to enable. + ## prBodyColumns Use this array to provide a list of column names you wish to include in the PR tables. diff --git a/website/docs/python.md b/website/docs/python.md index 753f3ad7bc395651459a899b88799fd810c3a769..650a9a88e5453a25898bf17ce83cfd991f176613 100644 --- a/website/docs/python.md +++ b/website/docs/python.md @@ -5,7 +5,11 @@ description: Python/pip dependencies support in Renovate # Python Package Manager Support -Renovate supports upgrading dependencies in `pip` requirements (e.g. `requirements.txt`, `requirements.pip`) files. +Renovate supports the following Python package managers: + +- `pip` (e.g. `requirements.txt`, `requirements.pip`) files +- `pipenv` (e.g. `Pipfile`) +- `setup.py` ## Versioning Support @@ -13,14 +17,28 @@ The [PEP440](https://www.python.org/dev/peps/pep-0440/) versioning scheme has be ## How It Works -1. Renovate will search each repository for any requirements files it finds. +1. Renovate will search each repository for any package files it finds. 2. Existing dependencies will be extracted from the file(s) 3. Renovate will look up the latest version on [PyPI](https://pypi.org/) to determine if any upgrades are available 4. If the source package includes a GitHub URL as its source, and has either a "changelog" file or uses GitHub releases, then Release Notes for each version will be embedded in the generated PR. +## Enabling Beta + +Both `pipenv` and `setup.py` are classified a "beta", so they are not enabled by default. To enable them, you need to add configuration like the following to your `renovate.json` file: + +```json +{ + "pipenv": { + "enabled": false + } +} +``` + +Note: if you _only_ have these package files and no other package files (like `package.json`, `Dockerfile`, etc) then Renovate won't detect them and you won't get an onboarding PR. In that case you need to add Renovate configuration manually to skip the onboarding step. + ## Alternative file names -The default file matching regex for requirements.txt aims to pick up the most popular conventions for file naming, but it's possible that some get missed. If you have a specific file or file pattern you want to get found by Renovate, then you can do this by adding a new pattern under the `fileMatch` field of `pip_requirements`. e.g. you could add this to your config: +The default file matching regex for `requirements.txt` aims to pick up the most popular conventions for file naming, but it's possible that some get missed. If you have a specific file or file pattern you want to get found by Renovate, then you can do this by adding a new pattern under the `fileMatch` field of `pip_requirements`. e.g. you could add this to your config: ```json "pip_requirements": { @@ -42,6 +60,10 @@ some-package==0.3.1 some-other-package==1.0.0 ``` +#### Sources in `Pipfile` + +Renovate will detect any custom-configured sources in `Pipfile` and use them. + #### Specify URL in configuration The configuration option `registryUrls` can be used to configure an alternate index URL. Example: @@ -72,4 +94,4 @@ Alternatively, maybe you only want one package manager, such as `npm`. In that c ## Future work -Feature requests are open for conda support, additional file types (e.g. `setup.cfg`), and of course `pipenv` support. You can locate these issues by filtering on the [#python](https://github.com/renovatebot/renovate/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%23python) hashtag in the repository. Please +1 and/or add a comment to each issue that would benefit you so that we can gauge the popularity/importance of each. +Feature requests are open for conda support and additional file types (e.g. `setup.cfg`). You can locate these issues by filtering on the [#python](https://github.com/renovatebot/renovate/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%23python) hashtag in the repository. Please +1 and/or add a comment to each issue that would benefit you so that we can gauge the popularity/importance of each. diff --git a/yarn.lock b/yarn.lock index c6a211d0556b108b4cdc052a94ae9ea7d04fb229..c04287935863e9434b60355bd6f91a4154d84bf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8219,6 +8219,16 @@ to-vfile@^2.2.0: vfile "^2.0.0" x-is-function "^1.0.4" +toml@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/toml/-/toml-2.3.3.tgz#8d683d729577cb286231dfc7a8affe58d31728fb" + integrity sha512-O7L5hhSQHxuufWUdcTRPfuTh3phKfAZ/dqfxZFoxPCj2RYmpaSGLEIs016FCXItQwNr08yefUB5TSjzRYnajTA== + +tomlify-j0.4@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tomlify-j0.4/-/tomlify-j0.4-3.0.0.tgz#99414d45268c3a3b8bf38be82145b7bba34b7473" + integrity sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ== + tough-cookie@2.0.x: version "2.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.0.0.tgz#41ce08720b35cf90beb044dd2609fb19e928718f"