diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 35c29c5ddf8d414b8d9a15baca1318010bc0cd2c..f1501964130df2ee4d25427152fa752e86125296 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -387,6 +387,7 @@ const options = [ 'node', 'npm', 'pep440', + 'poetry', 'ruby', 'semver', ], @@ -1441,6 +1442,19 @@ const options = [ mergeable: true, cli: false, }, + { + name: 'poetry', + releaseStatus: 'beta', + description: 'Configuration object for pyproject.toml files', + stage: 'package', + type: 'toml', + default: { + enabled: false, + versionScheme: 'poetry', + fileMatch: ['(^|/)pyproject\\.toml$'], + }, + mergeable: true, + }, { name: 'python', description: 'Configuration object for python', diff --git a/lib/manager/index.js b/lib/manager/index.js index bf695e247cf47bf5af3dffe7d97986114ae4ddaa..daef001a2ae274add8171f60e99c0ff23348e76c 100644 --- a/lib/manager/index.js +++ b/lib/manager/index.js @@ -22,6 +22,7 @@ const managerList = [ 'pip_requirements', 'pip_setup', 'pipenv', + 'poetry', 'terraform', 'travis', ]; diff --git a/lib/manager/poetry/artifacts.js b/lib/manager/poetry/artifacts.js new file mode 100644 index 0000000000000000000000000000000000000000..f54b7f92b35bb8e7916f5e53cc30c53ac1503524 --- /dev/null +++ b/lib/manager/poetry/artifacts.js @@ -0,0 +1,114 @@ +const upath = require('upath'); +const process = require('process'); +const fs = require('fs-extra'); +const { exec } = require('child-process-promise'); + +module.exports = { + getArtifacts, +}; + +async function getArtifacts( + packageFileName, + updatedDeps, + newPackageFileContent, + config +) { + await logger.debug(`poetry.getArtifacts(${packageFileName})`); + if (updatedDeps === undefined || updatedDeps.length < 1) { + logger.debug('No updated poetry deps - returning null'); + return null; + } + const lockFileName = 'poetry.lock'; + let existingLockFileContent = await platform.getFile(lockFileName); + let oldLockFileName; + if (!existingLockFileContent) { + oldLockFileName = 'pyproject.lock'; + existingLockFileContent = await platform.getFile(oldLockFileName); + // istanbul ignore if + if (existingLockFileContent) { + logger.info(`${oldLockFileName} found`); + } else { + logger.debug(`No ${lockFileName} found`); + return null; + } + } + const localPackageFileName = upath.join(config.localDir, packageFileName); + const localLockFileName = upath.join(config.localDir, lockFileName); + let stdout; + let stderr; + const startTime = process.hrtime(); + try { + await fs.outputFile(localPackageFileName, newPackageFileContent); + logger.debug(`Updating ${lockFileName}`); + const cwd = config.localDir; + const env = + global.trustLevel === 'high' + ? process.env + : { + HOME: process.env.HOME, + PATH: process.env.PATH, + }; + let cmd; + // istanbul ignore if + if (config.binarySource === 'docker') { + logger.info('Running poetry via docker'); + cmd = `docker run --rm `; + const volumes = [cwd]; + cmd += volumes.map(v => `-v ${v}:${v} `).join(''); + const envVars = []; + cmd += envVars.map(e => `-e ${e} `); + cmd += `-w ${cwd} `; + cmd += `renovate/poetry poetry`; + } else { + logger.info('Running poetry via global poetry'); + cmd = 'poetry'; + } + for (let i = 0; i < updatedDeps.length; i += 1) { + const dep = updatedDeps[i]; + cmd += ` update --lock --no-interaction ${dep}`; + ({ stdout, stderr } = await exec(cmd, { + cwd, + shell: true, + env, + })); + } + const duration = process.hrtime(startTime); + const seconds = Math.round(duration[0] + duration[1] / 1e9); + logger.info( + { seconds, type: `${lockFileName}`, stdout, stderr }, + 'Updated lockfile' + ); + logger.debug(`Returning updated ${lockFileName}`); + const newPoetryLockContent = await fs.readFile(localLockFileName, 'utf8'); + if (existingLockFileContent === newPoetryLockContent) { + logger.debug(`${lockFileName} is unchanged`); + return null; + } + let fileName; + // istanbul ignore if + if (oldLockFileName) { + fileName = oldLockFileName; + } else { + fileName = lockFileName; + } + return [ + { + file: { + name: fileName, + contents: newPoetryLockContent, + }, + }, + ]; + } catch (err) { + logger.warn( + { err, message: err.message }, + `Failed to update ${lockFileName} file` + ); + return { + lockFileError: { + lockFile: lockFileName, + stderr: err.message, + }, + }; + } +} diff --git a/lib/manager/poetry/extract.js b/lib/manager/poetry/extract.js new file mode 100644 index 0000000000000000000000000000000000000000..9a49e9f703ca1614dc12bf0e0a3f3a899b7ed90e --- /dev/null +++ b/lib/manager/poetry/extract.js @@ -0,0 +1,77 @@ +const toml = require('toml'); +const semver = require('../../versioning/poetry'); + +module.exports = { + extractPackageFile, +}; + +function extractPackageFile(content, fileName) { + logger.trace(`poetry.extractPackageFile(${fileName})`); + let pyprojectfile; + try { + pyprojectfile = toml.parse(content); + } catch (err) { + logger.debug({ err }, 'Error parsing pyproject.toml file'); + return null; + } + const deps = [ + ...extractFromSection(pyprojectfile, 'dependencies'), + ...extractFromSection(pyprojectfile, 'dev-dependencies'), + ...extractFromSection(pyprojectfile, 'extras'), + ]; + if (!deps.length) { + return null; + } + return { deps }; +} + +function extractFromSection(parsedFile, section) { + const deps = []; + const sectionContent = parsedFile.tool.poetry[section]; + if (!sectionContent) { + return []; + } + Object.keys(sectionContent).forEach(depName => { + let skipReason; + let currentValue = sectionContent[depName]; + let nestedVersion = false; + if (typeof currentValue !== 'string') { + const version = sectionContent[depName].version; + const path = sectionContent[depName].path; + const git = sectionContent[depName].git; + if (version) { + currentValue = version; + nestedVersion = true; + if (path) { + skipReason = 'path-dependency'; + } + if (git) { + skipReason = 'git-dependency'; + } + } else if (path) { + currentValue = ''; + skipReason = 'path-dependency'; + } else if (git) { + currentValue = ''; + skipReason = 'git-dependency'; + } else { + currentValue = ''; + skipReason = 'multiple-constraint-dep'; + } + } + const dep = { + depName, + depType: section, + currentValue, + nestedVersion, + datasource: 'pypi', + }; + if (skipReason) { + dep.skipReason = skipReason; + } else if (!semver.isValid(dep.currentValue)) { + dep.skipReason = 'unknown-version'; + } + deps.push(dep); + }); + return deps; +} diff --git a/lib/manager/poetry/index.js b/lib/manager/poetry/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a2a396b6f433ab857aae8d6fe4ccab31b067d93c --- /dev/null +++ b/lib/manager/poetry/index.js @@ -0,0 +1,14 @@ +const { extractPackageFile } = require('./extract'); +const { updateDependency } = require('./update'); +const { getArtifacts } = require('./artifacts'); + +const language = 'python'; + +module.exports = { + extractPackageFile, + getArtifacts, + language, + updateDependency, + // TODO: Support this + supportsLockFileMaintenance: false, +}; diff --git a/lib/manager/poetry/update.js b/lib/manager/poetry/update.js new file mode 100644 index 0000000000000000000000000000000000000000..1536899a858431f82a5bffc2b563d0386411092b --- /dev/null +++ b/lib/manager/poetry/update.js @@ -0,0 +1,93 @@ +const _ = require('lodash'); +const toml = require('toml'); + +module.exports = { + updateDependency, +}; + +// TODO: Maybe factor out common code from pipenv.updateDependency and poetry.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) { + logger.trace({ config: upgrade }, 'poetry.updateDependency()'); + if (!upgrade) { + return null; + } + const { depType, depName, newValue, nestedVersion } = upgrade; + const parsedContents = toml.parse(fileContent); + if (!parsedContents.tool.poetry[depType]) { + logger.info( + { config: upgrade }, + `Error: Section tool.poetry.${depType} doesn't exist in pyproject.toml file, update failed` + ); + return null; + } + let oldVersion; + if (nestedVersion) { + const oldDep = parsedContents.tool.poetry[depType][depName]; + if (!oldDep) { + logger.info( + { config: upgrade }, + `Could not get version of dependency ${depType}, update failed (most likely name is invalid)` + ); + return null; + } + oldVersion = oldDep.version; + } else { + oldVersion = parsedContents.tool.poetry[depType][depName]; + } + if (!oldVersion) { + logger.info( + { config: upgrade }, + `Could not get version of dependency ${depType}, update failed (most likely name is invalid)` + ); + return null; + } + if (oldVersion === newValue) { + logger.info('Version is already updated'); + return fileContent; + } + if (nestedVersion) { + parsedContents.tool.poetry[depType][depName].version = newValue; + } else { + parsedContents.tool.poetry[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); + } + } + } + return newFileContent; +} diff --git a/lib/versioning/poetry/index.js b/lib/versioning/poetry/index.js index 74fcdeaa1094e644f926033e1c8e51b1902af468..b073703b95dbd7d172cd14c7a945e41362508827 100644 --- a/lib/versioning/poetry/index.js +++ b/lib/versioning/poetry/index.js @@ -1,3 +1,5 @@ +const { parseRange } = require('semver-utils'); +const { major, minor } = require('semver'); const npm = require('../npm'); function notEmpty(s) { @@ -60,6 +62,25 @@ const isSingleVersion = constraint => isVersion(constraint.trim()); function getNewValue(currentValue, rangeStrategy, fromVersion, toVersion) { + if (rangeStrategy === 'replace') { + const npmCurrentValue = poetry2npm(currentValue); + const parsedRange = parseRange(npmCurrentValue); + const element = parsedRange[parsedRange.length - 1]; + if (parsedRange.length === 1 && element.operator) { + if (element.operator === '^') { + const version = handleShort('^', npmCurrentValue, toVersion); + if (version) { + return npm2poetry(version); + } + } + if (element.operator === '~') { + const version = handleShort('~', npmCurrentValue, toVersion); + if (version) { + return npm2poetry(version); + } + } + } + } const newSemver = npm.getNewValue( poetry2npm(currentValue), rangeStrategy, @@ -70,6 +91,21 @@ function getNewValue(currentValue, rangeStrategy, fromVersion, toVersion) { return newPoetry; } +function handleShort(operator, currentValue, toVersion) { + const toVersionMajor = major(toVersion); + const toVersionMinor = minor(toVersion); + const split = currentValue.split('.'); + if (split.length === 1) { + // [^,~]4 + return operator + toVersionMajor; + } + if (split.length === 2) { + // [^,~]4.1 + return operator + toVersionMajor + '.' + toVersionMinor; + } + return null; +} + module.exports = { ...npm, getNewValue, diff --git a/renovate-schema.json b/renovate-schema.json index da4d1488c251af32cebee6c18e9129e687266530..3d6bd6eaa1a35a56423ef0b2db83f36519ac4da5 100644 --- a/renovate-schema.json +++ b/renovate-schema.json @@ -252,6 +252,7 @@ "node", "npm", "pep440", + "poetry", "ruby", "semver" ], @@ -986,6 +987,15 @@ }, "$ref": "#" }, + "poetry": { + "description": "Configuration object for pyproject.toml files", + "type": "toml", + "default": { + "enabled": false, + "versionScheme": "poetry", + "fileMatch": ["(^|/)pyproject\\.toml$"] + } + }, "python": { "description": "Configuration object for python", "type": "object", diff --git a/test/config/__snapshots__/validation.spec.js.snap b/test/config/__snapshots__/validation.spec.js.snap index be7a0b6ec6df55a0e83c75c1a9a644f7f694a9c3..2584cf380aa29958fda8d1f79a798814f13baa3d 100644 --- a/test/config/__snapshots__/validation.spec.js.snap +++ b/test/config/__snapshots__/validation.spec.js.snap @@ -87,7 +87,7 @@ Array [ "depName": "Configuration Error", "message": "packageRules: You have included an unsupported manager in a package rule. Your list: foo. - Supported managers are: (ansible, bazel, buildkite, bundler, cargo, circleci, composer, docker-compose, dockerfile, github-actions, gitlabci, gomod, gradle, gradle-wrapper, kubernetes, maven, meteor, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, terraform, travis).", + Supported managers are: (ansible, bazel, buildkite, bundler, cargo, circleci, composer, docker-compose, dockerfile, github-actions, gitlabci, gomod, gradle, gradle-wrapper, kubernetes, maven, meteor, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, terraform, travis).", }, ] `; diff --git a/test/manager/poetry/__snapshots__/artifacts.spec.js.snap b/test/manager/poetry/__snapshots__/artifacts.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..46da37a1ed90ef60caf5bc1efde537b822abc526 --- /dev/null +++ b/test/manager/poetry/__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": "poetry.lock", + "stderr": "not found", + }, +} +`; diff --git a/test/manager/poetry/__snapshots__/extract.spec.js.snap b/test/manager/poetry/__snapshots__/extract.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..8635a124169f7593299d529a320ae5221e2cc566 --- /dev/null +++ b/test/manager/poetry/__snapshots__/extract.spec.js.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lib/manager/poetry/extract extractPackageFile() extracts multiple dependencies (with dep = {version = "1.2.3"} case) 1`] = ` +Array [ + Object { + "currentValue": "*", + "datasource": "pypi", + "depName": "dep1", + "depType": "dependencies", + "nestedVersion": true, + }, + Object { + "currentValue": "^0.6.0", + "datasource": "pypi", + "depName": "dep2", + "depType": "dependencies", + "nestedVersion": true, + }, + Object { + "currentValue": "^0.33.6", + "datasource": "pypi", + "depName": "dep3", + "depType": "dependencies", + "nestedVersion": true, + "skipReason": "path-dependency", + }, + Object { + "currentValue": "", + "datasource": "pypi", + "depName": "dep4", + "depType": "dependencies", + "nestedVersion": false, + "skipReason": "path-dependency", + }, + Object { + "currentValue": "^0.8.3", + "datasource": "pypi", + "depName": "extra_dep1", + "depType": "extras", + "nestedVersion": false, + }, + Object { + "currentValue": "^0.9.4", + "datasource": "pypi", + "depName": "extra_dep2", + "depType": "extras", + "nestedVersion": false, + }, + Object { + "currentValue": "^0.4.0", + "datasource": "pypi", + "depName": "extra_dep3", + "depType": "extras", + "nestedVersion": false, + }, +] +`; + +exports[`lib/manager/poetry/extract extractPackageFile() extracts multiple dependencies 1`] = ` +Array [ + Object { + "currentValue": "0.0.0", + "datasource": "pypi", + "depName": "dep1_", + "depType": "dependencies", + "nestedVersion": false, + }, + Object { + "currentValue": "0.0.0", + "datasource": "pypi", + "depName": "dep1", + "depType": "dependencies", + "nestedVersion": false, + }, + Object { + "currentValue": "^0.6.0", + "datasource": "pypi", + "depName": "dep2", + "depType": "dependencies", + "nestedVersion": false, + }, + Object { + "currentValue": "^0.33.6", + "datasource": "pypi", + "depName": "dep3", + "depType": "dependencies", + "nestedVersion": false, + }, + Object { + "currentValue": "^3.0", + "datasource": "pypi", + "depName": "dev_dep1", + "depType": "dev-dependencies", + "nestedVersion": false, + }, + Object { + "currentValue": "Invalid version.", + "datasource": "pypi", + "depName": "dev_dep2", + "depType": "dev-dependencies", + "nestedVersion": false, + "skipReason": "unknown-version", + }, + Object { + "currentValue": "^0.8.3", + "datasource": "pypi", + "depName": "extra_dep1", + "depType": "extras", + "nestedVersion": false, + }, + Object { + "currentValue": "^0.9.4", + "datasource": "pypi", + "depName": "extra_dep2", + "depType": "extras", + "nestedVersion": false, + }, + Object { + "currentValue": "^0.4.0", + "datasource": "pypi", + "depName": "extra_dep3", + "depType": "extras", + "nestedVersion": false, + }, +] +`; + +exports[`lib/manager/poetry/extract extractPackageFile() handles multiple constraint dependencies 1`] = ` +Array [ + Object { + "currentValue": "", + "datasource": "pypi", + "depName": "foo", + "depType": "dependencies", + "nestedVersion": false, + "skipReason": "multiple-constraint-dep", + }, +] +`; diff --git a/test/manager/poetry/__snapshots__/update.spec.js.snap b/test/manager/poetry/__snapshots__/update.spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..fc43034e3fb8133ab2c43b58ba6f56d80d3add1c --- /dev/null +++ b/test/manager/poetry/__snapshots__/update.spec.js.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`manager/poetry/update updateDependency replaces existing value 1`] = ` +"[tool.poetry] +name = \\"example 1\\" +version = \\"0.1.0\\" +description = \\"\\" +authors = [\\"John Doe <john.doe@gmail.com>\\"] + +[tool.poetry.dependencies] +dep1_ = \\"0.0.0\\" +dep1 = \\"1.0.0\\" +dep2 = \\"^0.6.0\\" +dep3 = \\"^0.33.6\\" + +[tool.poetry.extras] +extra_dep1 = \\"^0.8.3\\" +extra_dep2 = \\"^0.9.4\\" +extra_dep3 = \\"^0.4.0\\" + +[tool.poetry.dev-dependencies] +dev_dep1 = \\"^3.0\\" +dev_dep2 = \\"Invalid version.\\"" +`; + +exports[`manager/poetry/update updateDependency replaces nested value 1`] = ` +"[tool.poetry] +name = \\"example 2\\" +version = \\"0.1.0\\" +description = \\"\\" +authors = [\\"John Doe <john.doe@gmail.com>\\"] + +[tool.poetry.dependencies] +dep1 = { version = \\"1.0.0\\" } +dep2 = { version = \\"^0.6.0\\" } +dep3 = { path = \\"/some/path/\\", version = \\"^0.33.6\\" } +dep4 = { path = \\"/some/path/\\" } + +[tool.poetry.extras] +extra_dep1 = \\"^0.8.3\\" +extra_dep2 = \\"^0.9.4\\" +extra_dep3 = \\"^0.4.0\\"" +`; + +exports[`manager/poetry/update updateDependency replaces nested value for path dependency 1`] = ` +"[tool.poetry] +name = \\"example 2\\" +version = \\"0.1.0\\" +description = \\"\\" +authors = [\\"John Doe <john.doe@gmail.com>\\"] + +[tool.poetry.dependencies] +dep1 = { version = \\"*\\" } +dep2 = { version = \\"^0.6.0\\" } +dep3 = { path = \\"/some/path/\\", version = \\"1.0.0\\" } +dep4 = { path = \\"/some/path/\\" } + +[tool.poetry.extras] +extra_dep1 = \\"^0.8.3\\" +extra_dep2 = \\"^0.9.4\\" +extra_dep3 = \\"^0.4.0\\"" +`; + +exports[`manager/poetry/update updateDependency upgrades dev-dependencies 1`] = ` +"[tool.poetry] +name = \\"example 1\\" +version = \\"0.1.0\\" +description = \\"\\" +authors = [\\"John Doe <john.doe@gmail.com>\\"] + +[tool.poetry.dependencies] +dep1_ = \\"0.0.0\\" +dep1 = \\"0.0.0\\" +dep2 = \\"^0.6.0\\" +dep3 = \\"^0.33.6\\" + +[tool.poetry.extras] +extra_dep1 = \\"^0.8.3\\" +extra_dep2 = \\"^0.9.4\\" +extra_dep3 = \\"^0.4.0\\" + +[tool.poetry.dev-dependencies] +dev_dep1 = \\"1.0.0\\" +dev_dep2 = \\"Invalid version.\\"" +`; + +exports[`manager/poetry/update updateDependency upgrades extras 1`] = ` +"[tool.poetry] +name = \\"example 1\\" +version = \\"0.1.0\\" +description = \\"\\" +authors = [\\"John Doe <john.doe@gmail.com>\\"] + +[tool.poetry.dependencies] +dep1_ = \\"0.0.0\\" +dep1 = \\"0.0.0\\" +dep2 = \\"^0.6.0\\" +dep3 = \\"^0.33.6\\" + +[tool.poetry.extras] +extra_dep1 = \\"1.0.0\\" +extra_dep2 = \\"^0.9.4\\" +extra_dep3 = \\"^0.4.0\\" + +[tool.poetry.dev-dependencies] +dev_dep1 = \\"^3.0\\" +dev_dep2 = \\"Invalid version.\\"" +`; diff --git a/test/manager/poetry/_fixtures/pyproject.1.toml b/test/manager/poetry/_fixtures/pyproject.1.toml new file mode 100644 index 0000000000000000000000000000000000000000..d57ebe42d52be2c1235ca30f2bdf6c0493079196 --- /dev/null +++ b/test/manager/poetry/_fixtures/pyproject.1.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "example 1" +version = "0.1.0" +description = "" +authors = ["John Doe <john.doe@gmail.com>"] + +[tool.poetry.dependencies] +dep1_ = "0.0.0" +dep1 = "0.0.0" +dep2 = "^0.6.0" +dep3 = "^0.33.6" + +[tool.poetry.extras] +extra_dep1 = "^0.8.3" +extra_dep2 = "^0.9.4" +extra_dep3 = "^0.4.0" + +[tool.poetry.dev-dependencies] +dev_dep1 = "^3.0" +dev_dep2 = "Invalid version." \ No newline at end of file diff --git a/test/manager/poetry/_fixtures/pyproject.2.toml b/test/manager/poetry/_fixtures/pyproject.2.toml new file mode 100644 index 0000000000000000000000000000000000000000..c12a5a77c1fe5362044030e4a9b96090bf80375c --- /dev/null +++ b/test/manager/poetry/_fixtures/pyproject.2.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "example 2" +version = "0.1.0" +description = "" +authors = ["John Doe <john.doe@gmail.com>"] + +[tool.poetry.dependencies] +dep1 = { version = "*" } +dep2 = { version = "^0.6.0" } +dep3 = { path = "/some/path/", version = "^0.33.6" } +dep4 = { path = "/some/path/" } + +[tool.poetry.extras] +extra_dep1 = "^0.8.3" +extra_dep2 = "^0.9.4" +extra_dep3 = "^0.4.0" \ No newline at end of file diff --git a/test/manager/poetry/_fixtures/pyproject.3.toml b/test/manager/poetry/_fixtures/pyproject.3.toml new file mode 100644 index 0000000000000000000000000000000000000000..55bc5295e95ce5abc5ad854ef409773d5bc8b055 --- /dev/null +++ b/test/manager/poetry/_fixtures/pyproject.3.toml @@ -0,0 +1,5 @@ +[tool.poetry] +name = "example 3" +version = "0.1.0" +description = "" +authors = ["John Doe <john.doe@gmail.com>"] diff --git a/test/manager/poetry/_fixtures/pyproject.4.toml b/test/manager/poetry/_fixtures/pyproject.4.toml new file mode 100644 index 0000000000000000000000000000000000000000..0ae8e12d569f20cf37e8389aa9c2a219b75985e4 --- /dev/null +++ b/test/manager/poetry/_fixtures/pyproject.4.toml @@ -0,0 +1,11 @@ +[tool.poetry] +name = "example 3" +version = "0.1.0" +description = "" +authors = ["John Doe <john.doe@gmail.com>"] + +[tool.poetry.dependencies] +foo = [ + {version = "<=1.9", python = "^2.7"}, + {version = "^2.0", python = "^3.4"} +] diff --git a/test/manager/poetry/artifacts.spec.js b/test/manager/poetry/artifacts.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5a4d2f1c2038aed70709e521819eadb3c4bf8c42 --- /dev/null +++ b/test/manager/poetry/artifacts.spec.js @@ -0,0 +1,81 @@ +jest.mock('fs-extra'); +jest.mock('child-process-promise'); + +const fs = require('fs-extra'); +const { exec } = require('child-process-promise'); +const poetry = require('../../../lib/manager/poetry/artifacts'); + +const config = { + localDir: '/tmp/github/some/repo', +}; + +describe('.getArtifacts()', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns null if no poetry.lock found', async () => { + const updatedDeps = [ + { + depName: 'dep1', + currentValue: '1.2.3', + }, + ]; + expect( + await poetry.getArtifacts('pyproject.toml', updatedDeps, '', config) + ).toBeNull(); + }); + it('returns null if updatedDeps is empty', async () => { + expect( + await poetry.getArtifacts('pyproject.toml', [], '', config) + ).toBeNull(); + }); + it('returns null if unchanged', async () => { + platform.getFile.mockReturnValueOnce('Current poetry.lock'); + exec.mockReturnValueOnce({ + stdout: '', + stderror: '', + }); + fs.readFile = jest.fn(() => 'Current poetry.lock'); + const updatedDeps = [ + { + depName: 'dep1', + currentValue: '1.2.3', + }, + ]; + expect( + await poetry.getArtifacts('pyproject.toml', updatedDeps, '', config) + ).toBeNull(); + }); + it('returns updated poetry.lock', async () => { + platform.getFile.mockReturnValueOnce('Old poetry.lock'); + exec.mockReturnValueOnce({ + stdout: '', + stderror: '', + }); + fs.readFile = jest.fn(() => 'New poetry.lock'); + const updatedDeps = [ + { + depName: 'dep1', + currentValue: '1.2.3', + }, + ]; + expect( + await poetry.getArtifacts('pyproject.toml', updatedDeps, '{}', config) + ).not.toBeNull(); + }); + it('catches errors', async () => { + platform.getFile.mockReturnValueOnce('Current poetry.lock'); + fs.outputFile = jest.fn(() => { + throw new Error('not found'); + }); + const updatedDeps = [ + { + depName: 'dep1', + currentValue: '1.2.3', + }, + ]; + expect( + await poetry.getArtifacts('pyproject.toml', updatedDeps, '{}', config) + ).toMatchSnapshot(); + }); +}); diff --git a/test/manager/poetry/extract.spec.js b/test/manager/poetry/extract.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a00b6d68d9fd15e73faea9e4e9d0b06a7381cf95 --- /dev/null +++ b/test/manager/poetry/extract.spec.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const { extractPackageFile } = require('../../../lib/manager/poetry/extract'); + +const pyproject1toml = fs.readFileSync( + 'test/manager/poetry/_fixtures/pyproject.1.toml', + 'utf8' +); + +const pyproject2toml = fs.readFileSync( + 'test/manager/poetry/_fixtures/pyproject.2.toml', + 'utf8' +); + +const pyproject3toml = fs.readFileSync( + 'test/manager/poetry/_fixtures/pyproject.3.toml', + 'utf8' +); + +const pyproject4toml = fs.readFileSync( + 'test/manager/poetry/_fixtures/pyproject.4.toml', + 'utf8' +); + +describe('lib/manager/poetry/extract', () => { + describe('extractPackageFile()', () => { + let config; + beforeEach(() => { + config = {}; + }); + it('returns null for empty', () => { + expect(extractPackageFile('nothing here', config)).toBe(null); + }); + it('extracts multiple dependencies', () => { + const res = extractPackageFile(pyproject1toml, config); + expect(res.deps).toMatchSnapshot(); + expect(res.deps).toHaveLength(9); + }); + it('extracts multiple dependencies (with dep = {version = "1.2.3"} case)', () => { + const res = extractPackageFile(pyproject2toml, config); + expect(res.deps).toMatchSnapshot(); + expect(res.deps).toHaveLength(7); + }); + it('handles case with no dependencies', () => { + const res = extractPackageFile(pyproject3toml, config); + expect(res).toBeNull(); + }); + it('handles multiple constraint dependencies', () => { + const res = extractPackageFile(pyproject4toml, config); + expect(res.deps).toMatchSnapshot(); + expect(res.deps).toHaveLength(1); + }); + it('skips git dependencies', () => { + const content = + '[tool.poetry.dependencies]\r\nflask = {git = "https://github.com/pallets/flask.git"}\r\nwerkzeug = ">=0.14"'; + const res = extractPackageFile(content, config).deps; + expect(res[0].depName).toBe('flask'); + expect(res[0].currentValue).toBe(''); + expect(res[0].skipReason).toBe('git-dependency'); + expect(res).toHaveLength(2); + }); + it('skips git dependencies', () => { + const content = + '[tool.poetry.dependencies]\r\nflask = {git = "https://github.com/pallets/flask.git", version="1.2.3"}\r\nwerkzeug = ">=0.14"'; + const res = extractPackageFile(content, config).deps; + expect(res[0].depName).toBe('flask'); + expect(res[0].currentValue).toBe('1.2.3'); + expect(res[0].skipReason).toBe('git-dependency'); + expect(res).toHaveLength(2); + }); + it('skips path dependencies', () => { + const content = + '[tool.poetry.dependencies]\r\nflask = {path = "/some/path/"}\r\nwerkzeug = ">=0.14"'; + const res = extractPackageFile(content, config).deps; + expect(res[0].depName).toBe('flask'); + expect(res[0].currentValue).toBe(''); + expect(res[0].skipReason).toBe('path-dependency'); + expect(res).toHaveLength(2); + }); + it('skips path dependencies', () => { + const content = + '[tool.poetry.dependencies]\r\nflask = {path = "/some/path/", version = "1.2.3"}\r\nwerkzeug = ">=0.14"'; + const res = extractPackageFile(content, config).deps; + expect(res[0].depName).toBe('flask'); + expect(res[0].currentValue).toBe('1.2.3'); + expect(res[0].skipReason).toBe('path-dependency'); + expect(res).toHaveLength(2); + }); + }); +}); diff --git a/test/manager/poetry/update.spec.js b/test/manager/poetry/update.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d02b76de98c95d0d3d3c23d4656437191a288f56 --- /dev/null +++ b/test/manager/poetry/update.spec.js @@ -0,0 +1,134 @@ +const fs = require('fs'); +const { updateDependency } = require('../../../lib/manager/poetry/update'); + +const pyproject1toml = fs.readFileSync( + 'test/manager/poetry/_fixtures/pyproject.1.toml', + 'utf8' +); + +const pyproject2toml = fs.readFileSync( + 'test/manager/poetry/_fixtures/pyproject.2.toml', + 'utf8' +); + +describe('manager/poetry/update', () => { + describe('updateDependency', () => { + it('replaces existing value', () => { + const upgrade = { + depName: 'dep1', + depType: 'dependencies', + newValue: '1.0.0', + }; + const res = updateDependency(pyproject1toml, upgrade); + expect(res).not.toEqual(pyproject1toml); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('handles already replace values', () => { + const upgrade = { + depName: 'dep1', + depType: 'dependencies', + newValue: '0.0.0', + }; + const res = updateDependency(pyproject1toml, upgrade); + expect(res).toEqual(pyproject1toml); + }); + it('replaces nested value', () => { + const upgrade = { + depName: 'dep1', + depType: 'dependencies', + newValue: '1.0.0', + nestedVersion: true, + }; + const res = updateDependency(pyproject2toml, upgrade); + expect(res).not.toEqual(pyproject2toml); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('replaces nested value for path dependency', () => { + const upgrade = { + depName: 'dep3', + depType: 'dependencies', + newValue: '1.0.0', + nestedVersion: true, + }; + const res = updateDependency(pyproject2toml, upgrade); + expect(res).not.toEqual(pyproject2toml); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('gracefully handles nested value for path dependency withou version field', () => { + const upgrade = { + depName: 'dep4', + depType: 'dependencies', + newValue: '1.0.0', + nestedVersion: true, + }; + const res = updateDependency(pyproject2toml, upgrade); + expect(res).toBe(null); + }); + it('upgrades extras', () => { + const upgrade = { + depName: 'extra_dep1', + depType: 'extras', + newValue: '1.0.0', + }; + const res = updateDependency(pyproject1toml, upgrade); + expect(res).not.toEqual(pyproject1toml); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('upgrades dev-dependencies', () => { + const upgrade = { + depName: 'dev_dep1', + depType: 'dev-dependencies', + newValue: '1.0.0', + }; + const res = updateDependency(pyproject1toml, upgrade); + expect(res).not.toEqual(pyproject1toml); + expect(res.includes(upgrade.newValue)).toBe(true); + expect(res).toMatchSnapshot(); + }); + it('returns null if upgrade is null', () => { + const res = updateDependency(null, null); + expect(res).toBe(null); + }); + it('handles nonexistent depType gracefully', () => { + const upgrade = { + depName: 'dev1', + depType: '!invalid-dev-type!', + newValue: '1.0.0', + }; + const res = updateDependency(pyproject1toml, upgrade); + expect(res).toBe(null); + }); + it('handles nonexistent depType gracefully', () => { + const upgrade = { + depName: 'dev_dev1', + depType: 'dev-dependencies', + newValue: '1.0.0', + }; + const res = updateDependency(pyproject2toml, upgrade); + expect(res).toBe(null); + }); + it('handles nonexistent depName gracefully', () => { + const upgrade = { + depName: '~invalid-dep-name~', + depType: 'dependencies', + newValue: '1.0.0', + }; + const res = updateDependency(pyproject1toml, upgrade); + expect(res).toBe(null); + }); + it('handles nonexistent depName with nested value gracefully', () => { + const upgrade = { + depName: '~invalid-dep-name~', + depType: 'dependencies', + nestedVersion: true, + newValue: '1.0.0', + }; + const res = updateDependency(pyproject2toml, upgrade); + expect(res).toBe(null); + }); + }); +}); diff --git a/test/versioning/poetry.spec.js b/test/versioning/poetry.spec.js index 053aea5df7fb0e1fd8869e05b7e22940088fc06c..7938f549180c3e23c4506987acffd82cac752dc8 100644 --- a/test/versioning/poetry.spec.js +++ b/test/versioning/poetry.spec.js @@ -114,6 +114,9 @@ describe('semver.getNewValue()', () => { expect(semver.getNewValue(' 1.0.0', 'bump', '1.0.0', '1.1.0')).toEqual( '1.1.0' ); + expect(semver.getNewValue('1.0.0', 'bump', '1.0.0', '1.1.0')).toEqual( + '1.1.0' + ); }); it('bumps equals', () => { expect(semver.getNewValue('=1.0.0', 'bump', '1.0.0', '1.1.0')).toEqual( @@ -137,17 +140,12 @@ describe('semver.getNewValue()', () => { '=1.1.0' ); }); - it('bumps version range', () => { - expect(semver.getNewValue('1.0.0', 'bump', '1.0.0', '1.1.0')).toEqual( - '1.1.0' - ); - }); it('bumps short caret to same', () => { expect(semver.getNewValue('^1.0', 'bump', '1.0.0', '1.0.7')).toEqual( '^1.0' ); }); - it('replaces with newer', () => { + it('replaces caret with newer', () => { expect(semver.getNewValue('^1.0.0', 'replace', '1.0.0', '2.0.7')).toEqual( '^2.0.0' ); @@ -162,7 +160,7 @@ describe('semver.getNewValue()', () => { '^2.0.7' ); }); - it('updates naked caret', () => { + it('bumps naked caret', () => { expect(semver.getNewValue('^1', 'bump', '1.0.0', '2.1.7')).toEqual('^2'); }); it('bumps naked tilde', () => { @@ -239,4 +237,16 @@ describe('semver.getNewValue()', () => { semver.getNewValue('<= 1.3.4', 'replace', '1.2.3', '1.5.0') ).toEqual('<= 1.5.0'); }); + it('handles replacing short caret versions', () => { + expect(semver.getNewValue('^1.2', 'replace', '1.2.3', '2.0.0')).toEqual( + '^2.0' + ); + expect(semver.getNewValue('^1', 'replace', '1.2.3', '2.0.0')).toEqual('^2'); + }); + it('handles replacing short tilde versions', () => { + expect(semver.getNewValue('~1.2', 'replace', '1.2.3', '2.0.0')).toEqual( + '~2.0' + ); + expect(semver.getNewValue('~1', 'replace', '1.2.3', '2.0.0')).toEqual('~2'); + }); }); diff --git a/test/workers/repository/extract/__snapshots__/index.spec.js.snap b/test/workers/repository/extract/__snapshots__/index.spec.js.snap index 9ca0e9b27b8372d0ab02edda8839611b2d433a88..ff92c6208d5f6cbb276762a1a613a3a7af39ac91 100644 --- a/test/workers/repository/extract/__snapshots__/index.spec.js.snap +++ b/test/workers/repository/extract/__snapshots__/index.spec.js.snap @@ -71,6 +71,9 @@ Object { "pipenv": Array [ Object {}, ], + "poetry": Array [ + Object {}, + ], "terraform": Array [ Object {}, ], diff --git a/website/docs/configuration-options.md b/website/docs/configuration-options.md index 96668fcb6bbb49358a2a550a75733f1d03e3120e..af42f9c4937eaecc1d1a88be28874a5cead6c483 100644 --- a/website/docs/configuration-options.md +++ b/website/docs/configuration-options.md @@ -703,6 +703,8 @@ Add configuration here to change pipenv settings, e.g. to change the file patter Warning: 'pipenv' support is currently in beta, so it is not enabled by default. You will need to configure `{ "pipenv": { "enabled": true }}" to enable. +## poetry + ## postUpdateOptions `gomodTidy`: Run `go mod tidy` after Go module updates