renovate/lib/modules/versioning/pep440/range.ts

565 lines
15 KiB
TypeScript

import { gte, lt, lte, satisfies } from '@renovatebot/pep440';
import { parse as parseRange } from '@renovatebot/pep440/lib/specifier.js';
import { parse as parseVersion } from '@renovatebot/pep440/lib/version.js';
import { logger } from '../../../logger';
import { coerceArray } from '../../../util/array';
import { regEx } from '../../../util/regex';
import type { NewValueConfig } from '../types';
const UserPolicyPrecisionMap = {
Major: 0,
Minor: 1,
Micro: 2,
Bug: 3,
None: Infinity,
} as const;
const PrecisionUserPolicyMap = {
0: 'Major',
1: 'Minor',
2: 'Micro',
3: 'Bug',
} as const;
type UserPolicy =
(typeof UserPolicyPrecisionMap)[keyof typeof UserPolicyPrecisionMap];
/**
* Calculate current update range precision.
* @param ranges A {@link Range} consists of current range
* @returns A {@link UserPolicy}
*/
function getRangePrecision(ranges: Range[]): UserPolicy {
const bound = coerceArray(
parseVersion((ranges[1] || ranges[0]).version)?.release,
);
let rangePrecision = -1;
// range is defined by a single bound.
// ie. <1.2.2.3,
// >=7
if (ranges.length === 1) {
rangePrecision = bound.length - 1;
}
// Range is defined by both upper and lower bounds.
if (ranges.length === 2) {
const lowerBound = coerceArray(parseVersion(ranges[0].version)?.release);
rangePrecision = bound.findIndex((el, index) => el > lowerBound[index]);
}
// Tune down Major precision if followed by a zero
if (
rangePrecision === UserPolicyPrecisionMap.Major &&
rangePrecision + 1 < bound.length &&
bound[rangePrecision + 1] === 0
) {
rangePrecision++;
}
// Could not calculate user precision
// Default to the smallest possible
// istanbul ignore next
if (rangePrecision === -1) {
rangePrecision = bound.length - 1;
}
const key =
PrecisionUserPolicyMap[
rangePrecision as keyof typeof PrecisionUserPolicyMap
];
return UserPolicyPrecisionMap[key as keyof typeof UserPolicyPrecisionMap];
}
/**
* @param policy Required range precision
* @param newVersion The newly accepted version
* @param baseVersion Optional Current upper bound
* @returns A string represents a future version upper bound.
*/
function getFutureVersion(
policy: UserPolicy,
newVersion: string,
baseVersion?: string,
): number[] {
const toRelease = coerceArray(parseVersion(newVersion)?.release);
const baseRelease = coerceArray(
parseVersion(baseVersion ?? newVersion)?.release,
);
return baseRelease.map((_, index) => {
const toPart = toRelease[index] ?? 0;
if (index < policy) {
return toPart;
}
if (index === policy) {
return toPart + (baseVersion === undefined ? 0 : 1);
}
return 0;
});
}
interface Range {
operator: string;
prefix?: string;
version: string;
}
export function getNewValue({
currentValue,
rangeStrategy,
currentVersion,
newVersion,
isReplacement,
}: NewValueConfig): string | null {
let ranges: Range[];
let updatedRange: (string | null)[];
if (rangeStrategy === 'pin' && !isReplacement) {
return '==' + newVersion;
}
// no symbol: accept only that specific version specified
if (currentValue === currentVersion || isReplacement) {
return newVersion;
}
try {
ranges = parseCurrentRange(currentValue);
if (!ranges.length) {
// an empty string is an allowed value for PEP440 range
// it means get any version
logger.warn('Empty currentValue: ' + currentValue);
return currentValue;
}
} catch (err) {
logger.warn({ currentValue, err }, 'Unexpected range error');
return null;
}
// return early if newVersion is excluded from range
if (
ranges.some(
(range) => range.operator === '!=' && range.version === newVersion,
)
) {
logger.debug(
`Cannot calculate new value as the newVersion:\`${newVersion}\` is excluded from range: \`${currentValue}\``,
);
return null;
}
switch (rangeStrategy) {
case 'auto':
case 'replace':
updatedRange = handleReplaceStrategy(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
ranges,
);
break;
case 'widen':
updatedRange = handleWidenStrategy(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
ranges,
);
break;
case 'bump':
updatedRange = handleBumpStrategy(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
ranges,
);
break;
default:
// Unsupported rangeStrategy
// Valid rangeStrategy values are: bump, extend, pin, replace.
// https://docs.renovatebot.com/modules/versioning/#pep440-versioning
logger.debug(
'Unsupported rangeStrategy: ' +
rangeStrategy +
'. Using "replace" instead.',
);
return getNewValue({
currentValue,
rangeStrategy: 'replace',
currentVersion,
newVersion,
});
}
let result = updatedRange.filter(Boolean).join(', ');
if (result.includes(', ') && !currentValue.includes(', ')) {
result = result.replace(regEx(/, /g), ',');
}
const checkedResult = checkRangeAndRemoveUnnecessaryRangeLimit(
result,
newVersion,
);
// istanbul ignore if
if (!satisfies(newVersion, checkedResult)) {
// we failed at creating the range
logger.warn(
{ result, newVersion, currentValue },
'pep440: failed to calculate newValue',
);
return null;
}
return checkedResult;
}
export function isLessThanRange(input: string, range: string): boolean {
try {
let invertResult = true;
const results = range
.split(',')
.map((x) =>
x
.replace(regEx(/\s*/g), '')
.split(regEx(/(~=|==|!=|<=|>=|<|>|===)/))
.slice(1),
)
.map(([op, version]) => {
if (['!=', '<=', '<'].includes(op)) {
return true;
}
invertResult = false;
if (['~=', '==', '>=', '==='].includes(op)) {
return lt(input, version);
}
if (op === '>') {
return lte(input, version);
}
// istanbul ignore next
return false;
});
const result = results.every((res) => res === true);
return invertResult ? !result : result;
} catch /* istanbul ignore next */ {
return false;
}
}
function parseCurrentRange(currentValue: string): Range[] {
const ranges: Range[] = parseRange(currentValue);
if (!ranges) {
throw new TypeError('Invalid pep440 currentValue');
}
if (ranges.some((range) => range.operator === '===')) {
// the operator "===" is used for legacy non PEP440 versions
throw new TypeError('PEP440 arbitrary equality (===) not supported');
}
return ranges;
}
function handleLowerBound(range: Range, newVersion: string): string | null {
// used to mark minimum supported version
// lower the bound if the new version is lower than current range
if (['>', '>='].includes(range.operator)) {
if (lte(newVersion, range.version)) {
// this looks like a rollback
return '>=' + newVersion;
}
// otherwise, treat it same as exclude
return range.operator + range.version;
}
// istanbul ignore next
return null;
}
function handleUpperBound(range: Range, newVersion: string): string | null {
// this is used to exclude future versions
if (range.operator === '<') {
// if newVersion is that future version
if (gte(newVersion, range.version)) {
// now here things get tricky
// we calculate the new future version
const precision = getRangePrecision([range]);
const futureVersion = getFutureVersion(
precision,
newVersion,
range.version,
);
return range.operator + futureVersion.join('.');
}
// newVersion is in range, for other than "replace" strategies
return range.operator + range.version;
}
// istanbul ignore next
return null;
}
function updateRangeValue(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
range: Range,
): string | null {
// used to exclude versions,
// we assume that's for a good reason
if (range.operator === '!=') {
return range.operator + range.version;
}
// keep the .* suffix
if (range.prefix) {
const futureVersion = getFutureVersion(
UserPolicyPrecisionMap.None,
newVersion,
range.version,
).join('.');
return range.operator + futureVersion + '.*';
}
if (range.operator === '~=') {
const baseVersion = coerceArray(parseVersion(range.version)?.release);
const futureVersion = coerceArray(parseVersion(newVersion)?.release);
const baseLen = baseVersion.length;
const newVerLen = futureVersion.length;
// trim redundant trailing version specifiers
if (baseLen < newVerLen) {
return (
range.operator + futureVersion.slice(0, baseVersion.length).join('.')
);
}
// pad with specifiers
if (baseLen > newVerLen) {
for (let i = baseLen - newVerLen - 1; i >= 0; i--) {
futureVersion.push(0);
}
return range.operator + futureVersion.join('.');
}
return range.operator + newVersion;
}
if (['==', '<='].includes(range.operator)) {
if (lte(newVersion, range.version)) {
return range.operator + range.version;
}
return range.operator + newVersion;
}
let output = handleUpperBound(range, newVersion);
if (output) {
// manged to update upperbound
// no need to try anything else
return output;
}
output = handleLowerBound(range, newVersion);
if (output) {
return output;
}
// unless PEP440 changes, this won't happen
// istanbul ignore next
logger.error(
{ newVersion, currentValue, range },
'pep440: failed to process range',
);
// istanbul ignore next
return null;
}
/**
* Checks for zero specifiers.
* returns true if one of the bounds' length is < 3.
* @param ranges A {@link Range} array.
* @returns A boolean value
* Used mainly for cosmetics for the rez versioning syntax.
*/
function hasZeroSpecifier(ranges: Range[]): boolean {
return ranges.some((range) => {
const release = parseVersion(range.version)?.release;
return release && release.length < 3;
});
}
function trimTrailingZeros(numbers: number[]): number[] {
let i = numbers.length - 1;
while (numbers[i] === 0) {
i--;
}
return numbers.slice(0, i + 1);
}
function divideCompatibleReleaseRange(currentRange: Range): Range[] {
const currentVersionUpperBound = currentRange.version
.split('.')
.map((num) => parseInt(num));
if (currentVersionUpperBound.length > 1) {
currentVersionUpperBound.splice(-1);
}
currentVersionUpperBound[currentVersionUpperBound.length - 1] += 1;
return [
{ operator: '>=', version: currentRange.version },
{
operator: '<',
version: currentVersionUpperBound.join('.'),
},
];
}
function handleWidenStrategy(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
ranges: Range[],
): (string | null)[] {
// newVersion is within range
if (satisfies(newVersion, currentValue)) {
return [currentValue];
}
let rangePrecision = getRangePrecision(ranges);
const trimZeros = hasZeroSpecifier(ranges);
let newRanges: Range[] = [];
if (ranges.length === 1 && ranges[0].operator === '~=') {
// If range operator is '~=', divide the range into its logical equivalent.
// Example: ~=1.0 --> >=1.0,<2
newRanges = divideCompatibleReleaseRange(ranges[0]);
} else {
newRanges = ranges;
}
return newRanges.map((range) => {
// newVersion is over the upper bound
if (range.operator === '<' && gte(newVersion, range.version)) {
const upperBound = coerceArray(parseVersion(range.version)?.release);
const len = upperBound.length;
// Match the precision of the smallest specifier if other than 0
if (upperBound[len - 1] !== 0) {
const key =
PrecisionUserPolicyMap[
(len - 1) as keyof typeof PrecisionUserPolicyMap
];
rangePrecision =
UserPolicyPrecisionMap[key as keyof typeof UserPolicyPrecisionMap];
}
let futureVersion = getFutureVersion(
rangePrecision,
newVersion,
range.version,
);
if (trimZeros) {
futureVersion = trimTrailingZeros(futureVersion);
}
return range.operator + futureVersion.join('.');
}
// default
return updateRangeValue(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
range,
);
});
}
function handleReplaceStrategy(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
ranges: Range[],
): (string | null)[] {
// newVersion is within range
if (satisfies(newVersion, currentValue)) {
return [currentValue];
}
const trimZeros = hasZeroSpecifier(ranges);
return ranges.map((range) => {
// newVersion is over the upper bound
if (range.operator === '<' && gte(newVersion, range.version)) {
const rangePrecision = getRangePrecision(ranges);
let futureVersion = getFutureVersion(
rangePrecision,
newVersion,
range.version,
);
if (trimZeros) {
futureVersion = trimTrailingZeros(futureVersion);
}
return range.operator + futureVersion.join('.');
}
// handle lower bound
if (['>', '>='].includes(range.operator)) {
if (lte(newVersion, range.version)) {
// this looks like a rollback
return '>=' + newVersion;
}
// update the lower bound to reflect the accepted new version
const lowerBound = coerceArray(parseVersion(range.version)?.release);
const rangePrecision = lowerBound.length - 1;
let newBase = getFutureVersion(rangePrecision, newVersion);
if (trimZeros) {
newBase = trimTrailingZeros(newBase);
}
// trim last element of the newBase when new accepted version is out of range.
// example: let new bound be >8.2.5 & newVersion be 8.2.5
// return value will be: >8.2
if (range.operator === '>') {
if (newVersion === newBase.join('.') && newBase.length > 1) {
newBase.pop();
}
}
return range.operator + newBase.join('.');
}
// default
return updateRangeValue(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
range,
);
});
}
function handleBumpStrategy(
{ currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig,
ranges: Range[],
): (string | null)[] {
return ranges.map((range) => {
// bump lower bound to current new version
if (range.operator === '>=') {
return range.operator + newVersion;
}
return updateRangeValue(
{
currentValue,
rangeStrategy,
currentVersion,
newVersion,
},
range,
);
});
}
export function checkRangeAndRemoveUnnecessaryRangeLimit(
rangeInput: string,
newVersion: string,
): string {
let newRange: string = rangeInput;
if (rangeInput.includes(',')) {
const newRes = rangeInput.split(',');
if (
newRes[0].includes('.*') &&
newRes[0].includes('==') &&
newRes[1].includes('>=')
) {
if (satisfies(newVersion, newRes[0])) {
newRange = newRes[0];
}
}
} else {
return rangeInput;
}
return newRange;
}