diff --git a/lib/versioning/poetry/index.spec.ts b/lib/versioning/poetry/index.spec.ts index 6f9d3a999e5794cde237177955718e2c4b207964..9f835ebb6bbf069222fc1a449ba956fc0e81b73a 100644 --- a/lib/versioning/poetry/index.spec.ts +++ b/lib/versioning/poetry/index.spec.ts @@ -3,25 +3,33 @@ import { api as versioning } from '.'; describe('versioning/poetry/index', () => { describe('equals', () => { test.each` - a | b | expected - ${'1'} | ${'1'} | ${true} - ${'1.0'} | ${'1'} | ${true} - ${'1.0.0'} | ${'1'} | ${true} - ${'1.9.0'} | ${'1.9'} | ${true} - ${'1'} | ${'2'} | ${false} - ${'1.9.1'} | ${'1.9'} | ${false} - ${'1.9-beta'} | ${'1.9'} | ${false} + a | b | expected + ${'1'} | ${'1'} | ${true} + ${'1.0'} | ${'1'} | ${true} + ${'1.0.0'} | ${'1'} | ${true} + ${'1.9.0'} | ${'1.9'} | ${true} + ${'1'} | ${'2'} | ${false} + ${'1.9.1'} | ${'1.9'} | ${false} + ${'1.9-beta'} | ${'1.9'} | ${false} + ${'1.9b0'} | ${'1.9'} | ${false} + ${'1.9b0'} | ${'1.9.0-beta.0'} | ${true} + ${'1.9-0'} | ${'1.9.0-post.0'} | ${true} + ${'1.9.0-post'} | ${'1.9.0-post.0'} | ${true} + ${'1.9.0dev0'} | ${'1.9.0-dev.0'} | ${true} `('equals("$a", "$b") === $expected', ({ a, b, expected }) => { expect(versioning.equals(a, b)).toBe(expected); }); }); test.each` - version | major | minor | patch - ${'1'} | ${1} | ${0} | ${0} - ${'1.9'} | ${1} | ${9} | ${0} - ${'1.9.0'} | ${1} | ${9} | ${0} - ${'1.9.4'} | ${1} | ${9} | ${4} + version | major | minor | patch + ${'1'} | ${1} | ${0} | ${0} + ${'1.9'} | ${1} | ${9} | ${0} + ${'1.9.0'} | ${1} | ${9} | ${0} + ${'1.9.4'} | ${1} | ${9} | ${4} + ${'1.9.4b0'} | ${1} | ${9} | ${4} + ${'1.9.4-beta0'} | ${1} | ${9} | ${4} + ${'17.04.01'} | ${17} | ${4} | ${1} `( 'getMajor, getMinor, getPatch for "$version"', ({ version, major, minor, patch }) => { @@ -38,6 +46,7 @@ describe('versioning/poetry/index', () => { ${'2.0.0'} | ${'1'} | ${true} ${'1.10.0'} | ${'1.9'} | ${true} ${'1.9'} | ${'1.9-beta'} | ${true} + ${'1.9'} | ${'1.9a0'} | ${true} ${'1'} | ${'1'} | ${false} ${'1.0'} | ${'1'} | ${false} ${'1.0.0'} | ${'1'} | ${false} @@ -53,21 +62,41 @@ describe('versioning/poetry/index', () => { ${'1.9.0'} | ${true} ${'1.9.4'} | ${true} ${'1.9.4-beta'} | ${false} + ${'1.9.4a0'} | ${false} `('isStable("$version") === $expected', ({ version, expected }) => { const res = !!versioning.isStable(version); expect(res).toBe(expected); }); + test.each` + version | expected + ${'1.2.3a0'} | ${true} + ${'1.2.3b1'} | ${true} + ${'1.2.3rc23'} | ${true} + ${'17.04.01'} | ${true} + ${'17.b4.0'} | ${false} + ${'0.98.5.1'} | ${false} + `('isVersion("$version") === $expected', ({ version, expected }) => { + expect(!!versioning.isVersion(version)).toBe(expected); + }); + test.each` version | expected - ${'17.04.0'} | ${false} + ${'17.04.00'} | ${true} + ${'17.b4.0'} | ${false} ${'1.2.3'} | ${true} ${'1.2.3-foo'} | ${true} ${'1.2.3foo'} | ${false} + ${'1.2.3a0'} | ${true} + ${'1.2.3b1'} | ${true} + ${'1.2.3rc23'} | ${true} ${'*'} | ${true} ${'~1.2.3'} | ${true} ${'^1.2.3'} | ${true} ${'>1.2.3'} | ${true} + ${'~=1.9'} | ${true} + ${'==1.9'} | ${true} + ${'===1.9.4'} | ${true} ${'renovatebot/renovate'} | ${false} ${'renovatebot/renovate#master'} | ${false} ${'https://github.com/renovatebot/renovate.git'} | ${false} @@ -87,15 +116,20 @@ describe('versioning/poetry/index', () => { }); test.each` - version | range | expected - ${'4.2.0'} | ${'4.2, >= 3.0, < 5.0.0'} | ${true} - ${'4.2.0'} | ${'2.0, >= 3.0, < 5.0.0'} | ${false} - ${'4.2.2'} | ${'4.2.0, < 4.2.4'} | ${false} - ${'4.2.2'} | ${'^4.2.0, < 4.2.4'} | ${true} - ${'4.2.0'} | ${'4.3.0, 3.0.0'} | ${false} - ${'4.2.0'} | ${'> 5.0.0, <= 6.0.0'} | ${false} - ${'4.2.0'} | ${'*'} | ${true} - ${'1.4'} | ${'1.4'} | ${true} + version | range | expected + ${'4.2.0'} | ${'4.2, >= 3.0, < 5.0.0'} | ${true} + ${'4.2.0'} | ${'2.0, >= 3.0, < 5.0.0'} | ${false} + ${'4.2.2'} | ${'4.2.0, < 4.2.4'} | ${false} + ${'4.2.2'} | ${'^4.2.0, < 4.2.4'} | ${true} + ${'4.2.0'} | ${'4.3.0, 3.0.0'} | ${false} + ${'4.2.0'} | ${'> 5.0.0, <= 6.0.0'} | ${false} + ${'4.2.0'} | ${'*'} | ${true} + ${'1.9.4'} | ${'==1.9'} | ${true} + ${'1.9.4'} | ${'===1.9.4'} | ${true} + ${'1.9.4'} | ${'===1.9.3'} | ${false} + ${'0.8.0a1'} | ${'^0.8.0-alpha.0'} | ${true} + ${'0.7.4'} | ${'^0.8.0-alpha.0'} | ${false} + ${'1.4'} | ${'1.4'} | ${true} `( 'matches("$version", "$range") === "$expected"', ({ version, range, expected }) => { @@ -121,6 +155,7 @@ describe('versioning/poetry/index', () => { ${['0.4.0', '0.5.0', '4.2.0', '5.0.0']} | ${'^4.0.0, = 0.5.0'} | ${null} ${['0.4.0', '0.5.0', '4.2.0', '5.0.0']} | ${'^4.0.0, > 4.1.0, <= 4.3.5'} | ${'4.2.0'} ${['0.4.0', '0.5.0', '4.2.0', '5.0.0']} | ${'^6.2.0, 3.*'} | ${null} + ${['0.8.0a2', '0.8.0a7']} | ${'^0.8.0-alpha.0'} | ${'0.8.0-alpha.2'} `( 'minSatisfyingVersion($versions, "$range") === $expected', ({ versions, range, expected }) => { @@ -132,6 +167,7 @@ describe('versioning/poetry/index', () => { versions | range | expected ${['4.2.1', '0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0']} | ${'4.*.0, < 4.2.5'} | ${'4.2.1'} ${['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0', '5.0.3']} | ${'5.0, > 5.0.0'} | ${'5.0.3'} + ${['0.8.0a2', '0.8.0a7']} | ${'^0.8.0-alpha.0'} | ${'0.8.0-alpha.7'} `( 'getSatisfyingVersion($versions, "$range") === $expected', ({ versions, range, expected }) => { @@ -139,48 +175,53 @@ describe('versioning/poetry/index', () => { } ); test.each` - currentValue | rangeStrategy | currentVersion | newVersion | expected - ${'1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} - ${' 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} - ${'1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} - ${'=1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${'= 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${'= 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${' = 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${' = 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${'= 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${'^1.0'} | ${'bump'} | ${'1.0.0'} | ${'1.0.7'} | ${'^1.0'} - ${'^1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'2.0.7'} | ${'^2.0.0'} - ${'^5.0.3'} | ${'replace'} | ${'5.3.1'} | ${'5.5'} | ${'^5.0.3'} - ${'1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'2.0.7'} | ${'2.0.7'} - ${'^1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'2.0.7'} | ${'^2.0.0'} - ${'^0.5.15'} | ${'replace'} | ${'0.5.15'} | ${'0.6'} | ${'^0.5.15'} - ${'^1'} | ${'bump'} | ${'1.0.0'} | ${'2.1.7'} | ${'^2'} - ${'~1'} | ${'bump'} | ${'1.0.0'} | ${'1.1.7'} | ${'~1'} - ${'5'} | ${'bump'} | ${'5.0.0'} | ${'5.1.7'} | ${'5'} - ${'5'} | ${'bump'} | ${'5.0.0'} | ${'6.1.7'} | ${'6'} - ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'5.0.7'} | ${'5.0'} - ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'5.1.7'} | ${'5.1'} - ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'6.1.7'} | ${'6.1'} - ${'5.0'} | ${'replace'} | ${'5.0.0'} | ${'6.1.7'} | ${'6.1'} - ${'=1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} - ${'^1'} | ${'bump'} | ${'1.0.0'} | ${'1.0.7-prerelease.1'} | ${'^1.0.7-prerelease.1'} - ${'^1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'1.2.3'} | ${'^1.0.0'} - ${'~1.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.7'} | ${'~1.1'} - ${'1.0.*'} | ${'replace'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.*'} - ${'1.*'} | ${'replace'} | ${'1.0.0'} | ${'2.1.0'} | ${'2.*'} - ${'~0.6.1'} | ${'replace'} | ${'0.6.8'} | ${'0.7.0-rc.2'} | ${'~0.7.0-rc'} - ${'<1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<1.5.1'} - ${'< 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'< 1.5.1'} - ${'< 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'< 1.5.1'} - ${'<=1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<=1.5.0'} - ${'<= 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<= 1.5.0'} - ${'<= 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<= 1.5.0'} - ${'^1.2'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'^2.0'} - ${'^1'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'^2'} - ${'~1.2'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'~2.0'} - ${'~1'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'~2'} - ${'^2.2'} | ${'widen'} | ${'2.2.0'} | ${'3.0.0'} | ${'^2.2 || ^3.0.0'} + currentValue | rangeStrategy | currentVersion | newVersion | expected + ${'1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${' 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'=1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${'= 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${'= 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${' = 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${' = 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${'= 1.0.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${'^1.0'} | ${'bump'} | ${'1.0.0'} | ${'1.0.7'} | ${'^1.0'} + ${'^1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'2.0.7'} | ${'^2.0.0'} + ${'^5.0.3'} | ${'replace'} | ${'5.3.1'} | ${'5.5'} | ${'^5.0.3'} + ${'1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'2.0.7'} | ${'2.0.7'} + ${'^1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'2.0.7'} | ${'^2.0.0'} + ${'^0.5.15'} | ${'replace'} | ${'0.5.15'} | ${'0.6'} | ${'^0.5.15'} + ${'^0.5.15'} | ${'replace'} | ${'0.5.15'} | ${'0.6b.4'} | ${'^0.5.15'} + ${'^1'} | ${'bump'} | ${'1.0.0'} | ${'2.1.7'} | ${'^2'} + ${'~1'} | ${'bump'} | ${'1.0.0'} | ${'1.1.7'} | ${'~1'} + ${'5'} | ${'bump'} | ${'5.0.0'} | ${'5.1.7'} | ${'5'} + ${'5'} | ${'bump'} | ${'5.0.0'} | ${'6.1.7'} | ${'6'} + ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'5.0.7'} | ${'5.0'} + ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'5.1.7'} | ${'5.1'} + ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'6.1.7'} | ${'6.1'} + ${'5.0'} | ${'bump'} | ${'5.0.0'} | ${'6.b0.0'} | ${'5.0'} + ${'5.0'} | ${'replace'} | ${'5.0.0'} | ${'6.1.7'} | ${'6.1'} + ${'=1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'1.1.0'} | ${'=1.1.0'} + ${'^1'} | ${'bump'} | ${'1.0.0'} | ${'1.0.7rc.1'} | ${'^1.0.7-rc.1'} + ${'^1'} | ${'bump'} | ${'1.0.0'} | ${'1.0.7a0'} | ${'^1.0.7-alpha.0'} + ${'^0.8.0-alpha.0'} | ${'bump'} | ${'0.8.0-alpha.0'} | ${'0.8.0-alpha.1'} | ${'^0.8.0-alpha.1'} + ${'^0.8.0-alpha.0'} | ${'bump'} | ${'0.8.0-alpha.0'} | ${'0.8.0a1'} | ${'^0.8.0-alpha.1'} + ${'^1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'1.2.3'} | ${'^1.0.0'} + ${'~1.0'} | ${'bump'} | ${'1.0.0'} | ${'1.1.7'} | ${'~1.1'} + ${'1.0.*'} | ${'replace'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.*'} + ${'1.*'} | ${'replace'} | ${'1.0.0'} | ${'2.1.0'} | ${'2.*'} + ${'~0.6.1'} | ${'replace'} | ${'0.6.8'} | ${'0.7.0-rc.2'} | ${'~0.7.0-rc'} + ${'<1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<1.5.1'} + ${'< 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'< 1.5.1'} + ${'< 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'< 1.5.1'} + ${'<=1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<=1.5.0'} + ${'<= 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<= 1.5.0'} + ${'<= 1.3.4'} | ${'replace'} | ${'1.2.3'} | ${'1.5.0'} | ${'<= 1.5.0'} + ${'^1.2'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'^2.0'} + ${'^1'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'^2'} + ${'~1.2'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'~2.0'} + ${'~1'} | ${'replace'} | ${'1.2.3'} | ${'2.0.0'} | ${'~2'} + ${'^2.2'} | ${'widen'} | ${'2.2.0'} | ${'3.0.0'} | ${'^2.2 || ^3.0.0'} `( 'getNewValue("$currentValue", "$rangeStrategy", "$currentVersion", "$newVersion") === "$expected"', ({ currentValue, rangeStrategy, currentVersion, newVersion, expected }) => { @@ -205,6 +246,8 @@ describe('versioning/poetry/index', () => { ${'1.0'} | ${'1'} | ${0} ${'1.0.0'} | ${'1'} | ${0} ${'1.9.0'} | ${'1.9'} | ${0} + ${'1.9'} | ${'1.9b'} | ${1} + ${'1.9'} | ${'1.9rc0'} | ${1} `('sortVersions("$a", "$b") === $expected', ({ a, b, expected }) => { expect(versioning.sortVersions(a, b)).toEqual(expected); }); diff --git a/lib/versioning/poetry/index.ts b/lib/versioning/poetry/index.ts index 144c84360ebbc31db466e7152685537aa1ba9218..e2914024d600c4fad31569764e6d64c25079782a 100644 --- a/lib/versioning/poetry/index.ts +++ b/lib/versioning/poetry/index.ts @@ -1,8 +1,14 @@ import { parseRange } from 'semver-utils'; import { logger } from '../../logger'; import { api as npm } from '../npm'; -import { api as pep440 } from '../pep440'; import type { NewValueConfig, VersioningApi } from '../types'; +import { VERSION_PATTERN } from './patterns'; +import { + npm2poetry, + poetry2npm, + poetry2semver, + semver2poetry, +} from './transform'; export const id = 'poetry'; export const displayName = 'Poetry'; @@ -10,130 +16,76 @@ export const urls = ['https://python-poetry.org/docs/versions/']; export const supportsRanges = true; export const supportedRangeStrategies = ['bump', 'extend', 'pin', 'replace']; -function notEmpty(s: string): boolean { - return s !== ''; +function equals(a: string, b: string): boolean { + return npm.equals(poetry2semver(a), poetry2semver(b)); } -function getVersionParts(input: string): [string, string] { - const versionParts = input.split('-'); - if (versionParts.length === 1) { - return [input, '']; - } - - return [versionParts[0], '-' + versionParts[1]]; +function getMajor(version: string): number { + return npm.getMajor(poetry2semver(version)); } -function padZeroes(input: string): string { - if (/[~^*]/.test(input)) { - // ignore ranges - return input; - } - - const [output, stability] = getVersionParts(input); - - const sections = output.split('.'); - while (sections.length < 3) { - sections.push('0'); - } - return sections.join('.') + stability; -} - -// This function works like cargo2npm, but it doesn't -// add a '^', because poetry treats versions without operators as -// exact versions. -function poetry2npm(input: string): string { - return input - .split(',') - .map((str) => str.trim()) - .filter(notEmpty) - .join(' '); -} - -// NOTE: This function is copied from cargo versioning code. -// Poetry uses commas (like in cargo) instead of spaces (like in npm) -// for AND operation. -function npm2poetry(input: string): string { - // Note: this doesn't remove the ^ - const res = input - .split(' ') - .map((str) => str.trim()) - .filter(notEmpty); - const operators = ['^', '~', '=', '>', '<', '<=', '>=']; - for (let i = 0; i < res.length - 1; i += 1) { - if (operators.includes(res[i])) { - const newValue = res[i] + ' ' + res[i + 1]; - res.splice(i, 2, newValue); - } - } - return res.join(', ').replace(/\s*,?\s*\|\|\s*,?\s*/, ' || '); +function getMinor(version: string): number { + return npm.getMinor(poetry2semver(version)); } -const equals = (a: string, b: string): boolean => { - try { - return npm.equals(padZeroes(a), padZeroes(b)); - } catch (err) /* istanbul ignore next */ { - return pep440.equals(a, b); - } -}; - -const getMajor = (version: string): number => { - try { - return npm.getMajor(padZeroes(version)); - } catch (err) /* istanbul ignore next */ { - return pep440.getMajor(version); - } -}; - -const getMinor = (version: string): number => { - try { - return npm.getMinor(padZeroes(version)); - } catch (err) /* istanbul ignore next */ { - return pep440.getMinor(version); - } -}; - -const getPatch = (version: string): number => { - try { - return npm.getPatch(padZeroes(version)); - } catch (err) /* istanbul ignore next */ { - return pep440.getPatch(version); - } -}; +function getPatch(version: string): number { + return npm.getPatch(poetry2semver(version)); +} -const isGreaterThan = (a: string, b: string): boolean => { - try { - return npm.isGreaterThan(padZeroes(a), padZeroes(b)); - } catch (err) /* istanbul ignore next */ { - return pep440.isGreaterThan(a, b); - } -}; +function isVersion(input: string): boolean { + return VERSION_PATTERN.test(input); +} -const isLessThanRange = (version: string, range: string): boolean => - npm.isVersion(padZeroes(version)) && - npm.isLessThanRange(padZeroes(version), poetry2npm(range)); +function isGreaterThan(a: string, b: string): boolean { + return npm.isGreaterThan(poetry2semver(a), poetry2semver(b)); +} -export const isValid = (input: string): string | boolean => - npm.isValid(poetry2npm(input)); +function isLessThanRange(version: string, range: string): boolean { + return ( + isVersion(version) && + npm.isLessThanRange(poetry2semver(version), poetry2npm(range)) + ); +} -const isStable = (version: string): boolean => npm.isStable(padZeroes(version)); +export function isValid(input: string): string | boolean { + return npm.isValid(poetry2npm(input)); +} -const isVersion = (input: string): string | boolean => - npm.isVersion(padZeroes(input)); +function isStable(version: string): boolean { + return npm.isStable(poetry2semver(version)); +} -const matches = (version: string, range: string): boolean => - npm.isVersion(padZeroes(version)) && - npm.matches(padZeroes(version), poetry2npm(range)); +function matches(version: string, range: string): boolean { + return ( + isVersion(version) && npm.matches(poetry2semver(version), poetry2npm(range)) + ); +} -const getSatisfyingVersion = (versions: string[], range: string): string => - npm.getSatisfyingVersion(versions, poetry2npm(range)); +function getSatisfyingVersion(versions: string[], range: string): string { + return semver2poetry( + npm.getSatisfyingVersion( + versions.map((version) => poetry2semver(version)), + poetry2npm(range) + ) + ); +} -const minSatisfyingVersion = (versions: string[], range: string): string => - npm.minSatisfyingVersion(versions, poetry2npm(range)); +function minSatisfyingVersion(versions: string[], range: string): string { + return semver2poetry( + npm.minSatisfyingVersion( + versions.map((version) => poetry2semver(version)), + poetry2npm(range) + ) + ); +} -const isSingleVersion = (constraint: string): string | boolean => - (constraint.trim().startsWith('=') && - isVersion(constraint.trim().substring(1).trim())) || - isVersion(constraint.trim()); +function isSingleVersion(constraint: string): string | boolean { + return ( + (constraint.trim().startsWith('=') && + isVersion(constraint.trim().substring(1).trim())) || + isVersion(constraint.trim()) + ); +} function handleShort( operator: string, @@ -163,9 +115,9 @@ function getNewValue({ if (rangeStrategy === 'replace') { const npmCurrentValue = poetry2npm(currentValue); try { - const massagedNewVersion = padZeroes(newVersion); + const massagedNewVersion = poetry2semver(newVersion); if ( - npm.isVersion(massagedNewVersion) && + isVersion(massagedNewVersion) && npm.matches(massagedNewVersion, npmCurrentValue) ) { return currentValue; @@ -193,7 +145,12 @@ function getNewValue({ } } } - if (!npm.isVersion(newVersion)) { + + // Explicitly check whether this is a fully-qualified version + if ( + (VERSION_PATTERN.exec(newVersion)?.groups?.release || '').split('.') + .length !== 3 + ) { logger.debug( 'Cannot massage python version to npm - returning currentValue' ); @@ -203,8 +160,8 @@ function getNewValue({ const newSemver = npm.getNewValue({ currentValue: poetry2npm(currentValue), rangeStrategy, - currentVersion, - newVersion, + currentVersion: poetry2semver(currentVersion), + newVersion: poetry2semver(newVersion), }); const newPoetry = npm2poetry(newSemver); return newPoetry; @@ -218,7 +175,7 @@ function getNewValue({ } function sortVersions(a: string, b: string): number { - return npm.sortVersions(padZeroes(a), padZeroes(b)); + return npm.sortVersions(poetry2semver(a), poetry2semver(b)); } export const api: VersioningApi = { diff --git a/lib/versioning/poetry/patterns.ts b/lib/versioning/poetry/patterns.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b9df8306986d541a4655eb0a806aad11db0cfb7 --- /dev/null +++ b/lib/versioning/poetry/patterns.ts @@ -0,0 +1,47 @@ +import { regEx } from '../../util/regex'; + +/** + * regex used by poetry.core.version.Version to parse union of SemVer + * (with a subset of pre/post/dev tags) and PEP440 + * see: https://github.com/python-poetry/poetry-core/blob/01c0472d9cef3e1a4958364122dd10358a9bd719/poetry/core/version/version.py + */ + +// prettier-ignore +export const VERSION_PATTERN = regEx( + [ + '^', + 'v?', + '(?:', + '(?:(?<epoch>[0-9]+)!)?', // epoch + '(?<release>[0-9]+(?:\\.[0-9]+){0,2})', // release segment + '(?<pre>', // pre-release + '[-_.]?', + '(?<pre_l>(a|b|c|rc|alpha|beta|pre|preview))', + '[-_.]?', + '(?<pre_n>[0-9]+)?', + ')?', + '(?<post>', // post release + '(?:-(?<post_n1>[0-9]+))', + '|', + '(?:', + '[-_.]?', + '(?<post_l>post|rev|r)', + '[-_.]?', + '(?<post_n2>[0-9]+)?', + ')', + ')?', + '(?<dev>', // dev release + '[-_.]?', + '(?<dev_l>dev)', + '[-_.]?', + '(?<dev_n>[0-9]+)?', + ')?', + ')', + '(?:\\+(?<local>[a-z0-9]+(?:[-_.][a-z0-9]+)*))?', // local version + '$' + ].join('') + ); + +export const RANGE_COMPARATOR_PATTERN = regEx( + /(\s*(?:\^|~|[><!]?=|[><]|\|\|)\s*)/ +); diff --git a/lib/versioning/poetry/readme.md b/lib/versioning/poetry/readme.md index a9ebe10b5e736e6963b55c01ed6dcdb7b4a6a3ac..8d2bb1109732a9f8264530f84c1b985b4b5cf480 100644 --- a/lib/versioning/poetry/readme.md +++ b/lib/versioning/poetry/readme.md @@ -1,3 +1,6 @@ Poetry versioning is a little like a mix of PEP440 and SemVer. -Currently Renovate's implementation is based off npm versioning, but it is being migrated to be based off PEP440 to be more compatible with Poetry's behavior. +Currently Renovate's implementation is based off npm versioning. +This works by parsing versions using the same patterns and similar normalization rules as Poetry, passing them to the npm versioning implementation, and then reversing the normalizations. +This allows Renovate to meaningfully compare the SemVer-style versions allowed in `pyproject.toml` to the PEP440 representations used on PyPI. +These are equivalent for major.minor.patch releases, but different for pre-, post-, and dev releases. diff --git a/lib/versioning/poetry/transform.ts b/lib/versioning/poetry/transform.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b89535c713e38a88611d8bdea70f1fb1da516d2 --- /dev/null +++ b/lib/versioning/poetry/transform.ts @@ -0,0 +1,146 @@ +import { parse } from 'semver'; +import { RANGE_COMPARATOR_PATTERN, VERSION_PATTERN } from './patterns'; + +function parseLetterTag( + letter?: string, + number?: string +): { letter?: string; number?: string } | null { + if (letter !== undefined) { + // apply the same normalizations as poetry + const spellings = { + alpha: 'a', + beta: 'b', + c: 'rc', + pre: 'rc', + preview: 'rc', + r: 'post', + rev: 'post', + }; + return { + letter: spellings[letter] || letter, + number: number === undefined ? '0' : number, + }; + } + if (letter === undefined && number !== undefined) { + return { letter: 'post', number }; + } + return null; +} + +function notEmpty(s: string): boolean { + return s !== ''; +} + +/** + * Parse versions like poetry.core.masonry.version.Version does (union of SemVer + * and PEP440, with normalization of certain prerelease tags), and emit in SemVer + * format. NOTE: this silently discards the epoch field in PEP440 versions, as + * it has no equivalent in SemVer. + */ +export function poetry2semver( + poetry_version: string, + padRelease = true +): string | null { + const match = VERSION_PATTERN.exec(poetry_version); + if (!match) { + return null; + } + // trim leading zeros from valid numbers + const releaseParts = match.groups.release + .split('.') + .map((segment) => parseInt(segment, 10)); + while (padRelease && releaseParts.length < 3) { + releaseParts.push(0); + } + const pre = parseLetterTag(match.groups.pre_l, match.groups.pre_n); + const post = match.groups.post_n1 + ? parseLetterTag(undefined, match.groups.post_n1) + : parseLetterTag(match.groups.post_l, match.groups.post_n); + const dev = parseLetterTag(match.groups.dev_l, match.groups.dev_n); + + const parts = [releaseParts.map((num) => num.toString()).join('.')]; + if (pre !== null) { + parts.push(`-${pre.letter}.${pre.number}`); + } + if (post !== null) { + parts.push(`-${post.letter}.${post.number}`); + } + if (dev !== null) { + parts.push(`-${dev.letter}.${dev.number}`); + } + + return parts.join(''); +} + +/** Reverse normalizations applied by poetry2semver */ +export function semver2poetry(version?: string): string | null { + if (!version) { + return null; + } + const s = parse(version); + if (!s) { + return null; + } + const spellings = { + a: 'alpha', + b: 'beta', + c: 'rc', + dev: 'alpha', + }; + s.prerelease = s.prerelease.map((letter) => spellings[letter] || letter); + return s.format(); +} + +/** + * Translate a poetry-style version range to npm format + * + * This function works like cargo2npm, but it doesn't + * add a '^', because poetry treats versions without operators as + * exact versions. + */ +export function poetry2npm(input: string): string { + // replace commas with spaces, then split at valid semver comparators + const chunks = input + .split(',') + .map((str) => str.trim()) + .filter(notEmpty) + .join(' ') + .split(RANGE_COMPARATOR_PATTERN); + // do not pad versions with zeros in a range + const transformed = chunks + .map((chunk) => poetry2semver(chunk, false) || chunk) + .join('') + .replace(/===/, '='); + return transformed; +} + +/** + * Translate an npm-style version range to poetry format + * + * NOTE: This function is largely copied from cargo versioning code. + * Poetry uses commas (like in cargo) instead of spaces (like in npm) + * for AND operation. + */ +export function npm2poetry(range: string): string { + // apply poetry-style normalizations to versions embedded in range string + // (i.e. anything that is not a range operator, potentially surrounded by whitespace) + const transformedRange = range + .split(RANGE_COMPARATOR_PATTERN) + .map((chunk) => semver2poetry(chunk) || chunk) + .join(''); + + // Note: this doesn't remove the ^ + const res = transformedRange + .split(' ') + .map((str) => str.trim()) + .filter(notEmpty); + + const operators = ['^', '~', '=', '>', '<', '<=', '>=']; + for (let i = 0; i < res.length - 1; i += 1) { + if (operators.includes(res[i])) { + const newValue = res[i] + ' ' + res[i + 1]; + res.splice(i, 2, newValue); + } + } + return res.join(', ').replace(/\s*,?\s*\|\|\s*,?\s*/, ' || '); +}