From 794ea9455ce316dbdd184c85e0028d845744cc1f Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sun, 30 Mar 2025 18:00:56 +0200 Subject: [PATCH] Add support for pep440 version identifiers (#353) Fixes: #264 --- .github/workflows/test.yml | 18 + README.md | 8 + dist/setup/index.js | 795 ++++++++++++++++++++++++++++++- package-lock.json | 16 + package.json | 5 +- src/download/download-version.ts | 24 +- 6 files changed, 860 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d176c9e..025c9fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -82,6 +82,23 @@ jobs: env: UV_VERSION: ${{ steps.setup-uv.outputs.uv-version }} + test-pep440-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install version 0.4.30 + id: setup-uv + uses: ./ + with: + version: ">=0.4.25,<0.5" + - name: Correct version gets installed + run: | + if [ "$UV_VERSION" != "0.4.30" ]; then + exit 1 + fi + env: + UV_VERSION: ${{ steps.setup-uv.outputs.uv-version }} + test-pyproject-file-version: runs-on: ubuntu-latest steps: @@ -475,6 +492,7 @@ jobs: - test-default-version - test-specific-version - test-semver-range + - test-pep440-version - test-pyproject-file-version - test-malformed-pyproject-file-fallback - test-uv-file-version diff --git a/README.md b/README.md index 55ac8eb..f34ffb6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ For an example workflow, see ### Install a version by supplying a semver range You can specify a [semver range](https://github.com/npm/node-semver?tab=readme-ov-file#ranges) +or [pep440 identifier](https://peps.python.org/pep-0440/#version-specifiers) to install the latest version that satisfies the range. ```yaml @@ -82,6 +83,13 @@ to install the latest version that satisfies the range. version: "0.4.x" ``` +```yaml +- name: Install a pep440-specifier-satisfying version of uv + uses: astral-sh/setup-uv@v5 + with: + version: ">=0.4.25,<0.5" +``` + ### Install a required-version You can specify a [required-version](https://docs.astral.sh/uv/reference/settings/#required-version) diff --git a/dist/setup/index.js b/dist/setup/index.js index 73f8ea5..f5d9014 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -76630,6 +76630,783 @@ class ReflectionTypeCheck { exports.ReflectionTypeCheck = ReflectionTypeCheck; +/***/ }), + +/***/ 63297: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const { valid, clean, explain, parse } = __nccwpck_require__(99961); + +const { lt, le, eq, ne, ge, gt, compare, rcompare } = __nccwpck_require__(99469); + +const { + filter, + maxSatisfying, + minSatisfying, + RANGE_PATTERN, + satisfies, + validRange, +} = __nccwpck_require__(23185); + +const { major, minor, patch, inc } = __nccwpck_require__(6829); + +module.exports = { + // version + valid, + clean, + explain, + parse, + + // operator + lt, + le, + lte: le, + eq, + ne, + neq: ne, + ge, + gte: ge, + gt, + compare, + rcompare, + + // range + filter, + maxSatisfying, + minSatisfying, + RANGE_PATTERN, + satisfies, + validRange, + + // semantic + major, + minor, + patch, + inc, +}; + + +/***/ }), + +/***/ 99469: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const { parse } = __nccwpck_require__(99961); + +module.exports = { + compare, + rcompare, + lt, + le, + eq, + ne, + ge, + gt, + '<': lt, + '<=': le, + '==': eq, + '!=': ne, + '>=': ge, + '>': gt, + '===': arbitrary, +}; + +function lt(version, other) { + return compare(version, other) < 0; +} + +function le(version, other) { + return compare(version, other) <= 0; +} + +function eq(version, other) { + return compare(version, other) === 0; +} + +function ne(version, other) { + return compare(version, other) !== 0; +} + +function ge(version, other) { + return compare(version, other) >= 0; +} + +function gt(version, other) { + return compare(version, other) > 0; +} + +function arbitrary(version, other) { + return version.toLowerCase() === other.toLowerCase(); +} + +function compare(version, other) { + const parsedVersion = parse(version); + const parsedOther = parse(other); + + const keyVersion = calculateKey(parsedVersion); + const keyOther = calculateKey(parsedOther); + + return pyCompare(keyVersion, keyOther); +} + +function rcompare(version, other) { + return -compare(version, other); +} + +// this logic is buitin in python, but we need to port it to js +// see https://stackoverflow.com/a/5292332/1438522 +function pyCompare(elemIn, otherIn) { + let elem = elemIn; + let other = otherIn; + if (elem === other) { + return 0; + } + if (Array.isArray(elem) !== Array.isArray(other)) { + elem = Array.isArray(elem) ? elem : [elem]; + other = Array.isArray(other) ? other : [other]; + } + if (Array.isArray(elem)) { + const len = Math.min(elem.length, other.length); + for (let i = 0; i < len; i += 1) { + const res = pyCompare(elem[i], other[i]); + if (res !== 0) { + return res; + } + } + return elem.length - other.length; + } + if (elem === -Infinity || other === Infinity) { + return -1; + } + if (elem === Infinity || other === -Infinity) { + return 1; + } + return elem < other ? -1 : 1; +} + +function calculateKey(input) { + const { epoch } = input; + let { release, pre, post, local, dev } = input; + // When we compare a release version, we want to compare it with all of the + // trailing zeros removed. So we'll use a reverse the list, drop all the now + // leading zeros until we come to something non zero, then take the rest + // re-reverse it back into the correct order and make it a tuple and use + // that for our sorting key. + release = release.concat(); + release.reverse(); + while (release.length && release[0] === 0) { + release.shift(); + } + release.reverse(); + + // We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0. + // We'll do this by abusing the pre segment, but we _only_ want to do this + // if there is !a pre or a post segment. If we have one of those then + // the normal sorting rules will handle this case correctly. + if (!pre && !post && dev) pre = -Infinity; + // Versions without a pre-release (except as noted above) should sort after + // those with one. + else if (!pre) pre = Infinity; + + // Versions without a post segment should sort before those with one. + if (!post) post = -Infinity; + + // Versions without a development segment should sort after those with one. + if (!dev) dev = Infinity; + + if (!local) { + // Versions without a local segment should sort before those with one. + local = -Infinity; + } else { + // Versions with a local segment need that segment parsed to implement + // the sorting rules in PEP440. + // - Alpha numeric segments sort before numeric segments + // - Alpha numeric segments sort lexicographically + // - Numeric segments sort numerically + // - Shorter versions sort before longer versions when the prefixes + // match exactly + local = local.map((i) => + Number.isNaN(Number(i)) ? [-Infinity, i] : [Number(i), ''], + ); + } + + return [epoch, release, pre, post, dev, local]; +} + + +/***/ }), + +/***/ 6829: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const { explain, parse, stringify } = __nccwpck_require__(99961); + +// those notation are borrowed from semver +module.exports = { + major, + minor, + patch, + inc, +}; + +function major(input) { + const version = explain(input); + if (!version) { + throw new TypeError('Invalid Version: ' + input); + } + return version.release[0]; +} + +function minor(input) { + const version = explain(input); + if (!version) { + throw new TypeError('Invalid Version: ' + input); + } + if (version.release.length < 2) { + return 0; + } + return version.release[1]; +} + +function patch(input) { + const version = explain(input); + if (!version) { + throw new TypeError('Invalid Version: ' + input); + } + if (version.release.length < 3) { + return 0; + } + return version.release[2]; +} + +function inc(input, release, preReleaseIdentifier) { + let identifier = preReleaseIdentifier || `a`; + const version = parse(input); + + if (!version) { + return null; + } + + if ( + !['a', 'b', 'c', 'rc', 'alpha', 'beta', 'pre', 'preview'].includes( + identifier, + ) + ) { + return null; + } + + switch (release) { + case 'premajor': + { + const [majorVersion] = version.release; + version.release.fill(0); + version.release[0] = majorVersion + 1; + } + version.pre = [identifier, 0]; + delete version.post; + delete version.dev; + delete version.local; + break; + case 'preminor': + { + const [majorVersion, minorVersion = 0] = version.release; + version.release.fill(0); + version.release[0] = majorVersion; + version.release[1] = minorVersion + 1; + } + version.pre = [identifier, 0]; + delete version.post; + delete version.dev; + delete version.local; + break; + case 'prepatch': + { + const [majorVersion, minorVersion = 0, patchVersion = 0] = + version.release; + version.release.fill(0); + version.release[0] = majorVersion; + version.release[1] = minorVersion; + version.release[2] = patchVersion + 1; + } + version.pre = [identifier, 0]; + delete version.post; + delete version.dev; + delete version.local; + break; + case 'prerelease': + if (version.pre === null) { + const [majorVersion, minorVersion = 0, patchVersion = 0] = + version.release; + version.release.fill(0); + version.release[0] = majorVersion; + version.release[1] = minorVersion; + version.release[2] = patchVersion + 1; + version.pre = [identifier, 0]; + } else { + if (preReleaseIdentifier === undefined && version.pre !== null) { + [identifier] = version.pre; + } + + const [letter, number] = version.pre; + if (letter === identifier) { + version.pre = [letter, number + 1]; + } else { + version.pre = [identifier, 0]; + } + } + + delete version.post; + delete version.dev; + delete version.local; + break; + case 'major': + if ( + version.release.slice(1).some((value) => value !== 0) || + version.pre === null + ) { + const [majorVersion] = version.release; + version.release.fill(0); + version.release[0] = majorVersion + 1; + } + delete version.pre; + delete version.post; + delete version.dev; + delete version.local; + break; + case 'minor': + if ( + version.release.slice(2).some((value) => value !== 0) || + version.pre === null + ) { + const [majorVersion, minorVersion = 0] = version.release; + version.release.fill(0); + version.release[0] = majorVersion; + version.release[1] = minorVersion + 1; + } + delete version.pre; + delete version.post; + delete version.dev; + delete version.local; + break; + case 'patch': + if ( + version.release.slice(3).some((value) => value !== 0) || + version.pre === null + ) { + const [majorVersion, minorVersion = 0, patchVersion = 0] = + version.release; + version.release.fill(0); + version.release[0] = majorVersion; + version.release[1] = minorVersion; + version.release[2] = patchVersion + 1; + } + delete version.pre; + delete version.post; + delete version.dev; + delete version.local; + break; + default: + return null; + } + + return stringify(version); +} + + +/***/ }), + +/***/ 23185: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +const { VERSION_PATTERN, explain: explainVersion } = __nccwpck_require__(99961); + +const Operator = __nccwpck_require__(99469); + +const RANGE_PATTERN = [ + '(?(===|~=|==|!=|<=|>=|<|>))', + '\\s*', + '(', + /* */ '(?(?:' + VERSION_PATTERN.replace(/\?<\w+>/g, '?:') + '))', + /* */ '(?\\.\\*)?', + /* */ '|', + /* */ '(?[^,;\\s)]+)', + ')', +].join(''); + +module.exports = { + RANGE_PATTERN, + parse, + satisfies, + filter, + validRange, + maxSatisfying, + minSatisfying, +}; + +const isEqualityOperator = (op) => ['==', '!=', '==='].includes(op); + +const rangeRegex = new RegExp('^' + RANGE_PATTERN + '$', 'i'); + +function parse(ranges) { + if (!ranges.trim()) { + return []; + } + + const specifiers = ranges + .split(',') + .map((range) => rangeRegex.exec(range.trim()) || {}) + .map(({ groups }) => { + if (!groups) { + return null; + } + + let { ...spec } = groups; + const { operator, version, prefix, legacy } = groups; + + if (version) { + spec = { ...spec, ...explainVersion(version) }; + if (operator === '~=') { + if (spec.release.length < 2) { + return null; + } + } + if (!isEqualityOperator(operator) && spec.local) { + return null; + } + + if (prefix) { + if (!isEqualityOperator(operator) || spec.dev || spec.local) { + return null; + } + } + } + if (legacy && operator !== '===') { + return null; + } + + return spec; + }); + + if (specifiers.filter(Boolean).length !== specifiers.length) { + return null; + } + + return specifiers; +} + +function filter(versions, specifier, options = {}) { + const filtered = pick(versions, specifier, options); + if (filtered.length === 0 && options.prereleases === undefined) { + return pick(versions, specifier, { prereleases: true }); + } + return filtered; +} + +function maxSatisfying(versions, range, options) { + const found = filter(versions, range, options).sort(Operator.compare); + return found.length === 0 ? null : found[found.length - 1]; +} + +function minSatisfying(versions, range, options) { + const found = filter(versions, range, options).sort(Operator.compare); + return found.length === 0 ? null : found[0]; +} + +function pick(versions, specifier, options) { + const parsed = parse(specifier); + + if (!parsed) { + return []; + } + + return versions.filter((version) => { + const explained = explainVersion(version); + + if (!parsed.length) { + return explained && !(explained.is_prerelease && !options.prereleases); + } + + return parsed.reduce((pass, spec) => { + if (!pass) { + return false; + } + return contains({ ...spec, ...options }, { version, explained }); + }, true); + }); +} + +function satisfies(version, specifier, options = {}) { + const filtered = pick([version], specifier, options); + + return filtered.length === 1; +} + +function arrayStartsWith(array, prefix) { + if (prefix.length > array.length) { + return false; + } + + for (let i = 0; i < prefix.length; i += 1) { + if (prefix[i] !== array[i]) { + return false; + } + } + + return true; +} + +function contains(specifier, input) { + const { explained } = input; + let { version } = input; + const { ...spec } = specifier; + + if (spec.prereleases === undefined) { + spec.prereleases = spec.is_prerelease; + } + + if (explained && explained.is_prerelease && !spec.prereleases) { + return false; + } + + if (spec.operator === '~=') { + let compatiblePrefix = spec.release.slice(0, -1).concat('*').join('.'); + if (spec.epoch) { + compatiblePrefix = spec.epoch + '!' + compatiblePrefix; + } + return satisfies(version, `>=${spec.version}, ==${compatiblePrefix}`); + } + + if (spec.prefix) { + const isMatching = + explained.epoch === spec.epoch && + arrayStartsWith(explained.release, spec.release); + const isEquality = spec.operator !== '!='; + return isEquality ? isMatching : !isMatching; + } + + if (explained) + if (explained.local && spec.version) { + version = explained.public; + spec.version = explainVersion(spec.version).public; + } + + if (spec.operator === '<' || spec.operator === '>') { + // simplified version of https://www.python.org/dev/peps/pep-0440/#exclusive-ordered-comparison + if (Operator.eq(spec.release.join('.'), explained.release.join('.'))) { + return false; + } + } + + const op = Operator[spec.operator]; + return op(version, spec.version || spec.legacy); +} + +function validRange(specifier) { + return Boolean(parse(specifier)); +} + + +/***/ }), + +/***/ 99961: +/***/ ((module) => { + +const VERSION_PATTERN = [ + 'v?', + '(?:', + /* */ '(?:(?[0-9]+)!)?', // epoch + /* */ '(?[0-9]+(?:\\.[0-9]+)*)', // release segment + /* */ '(?
', // pre-release
+  /*    */ '[-_\\.]?',
+  /*    */ '(?(a|b|c|rc|alpha|beta|pre|preview))',
+  /*    */ '[-_\\.]?',
+  /*    */ '(?[0-9]+)?',
+  /* */ ')?',
+  /* */ '(?', // post release
+  /*    */ '(?:-(?[0-9]+))',
+  /*    */ '|',
+  /*    */ '(?:',
+  /*        */ '[-_\\.]?',
+  /*        */ '(?post|rev|r)',
+  /*        */ '[-_\\.]?',
+  /*        */ '(?[0-9]+)?',
+  /*    */ ')',
+  /* */ ')?',
+  /* */ '(?', // dev release
+  /*    */ '[-_\\.]?',
+  /*    */ '(?dev)',
+  /*    */ '[-_\\.]?',
+  /*    */ '(?[0-9]+)?',
+  /* */ ')?',
+  ')',
+  '(?:\\+(?[a-z0-9]+(?:[-_\\.][a-z0-9]+)*))?', // local version
+].join('');
+
+module.exports = {
+  VERSION_PATTERN,
+  valid,
+  clean,
+  explain,
+  parse,
+  stringify,
+};
+
+const validRegex = new RegExp('^' + VERSION_PATTERN + '$', 'i');
+
+function valid(version) {
+  return validRegex.test(version) ? version : null;
+}
+
+const cleanRegex = new RegExp('^\\s*' + VERSION_PATTERN + '\\s*$', 'i');
+function clean(version) {
+  return stringify(parse(version, cleanRegex));
+}
+
+function parse(version, regex) {
+  // Validate the version and parse it into pieces
+  const { groups } = (regex || validRegex).exec(version) || {};
+  if (!groups) {
+    return null;
+  }
+
+  // Store the parsed out pieces of the version
+  const parsed = {
+    epoch: Number(groups.epoch ? groups.epoch : 0),
+    release: groups.release.split('.').map(Number),
+    pre: normalize_letter_version(groups.pre_l, groups.pre_n),
+    post: normalize_letter_version(
+      groups.post_l,
+      groups.post_n1 || groups.post_n2,
+    ),
+    dev: normalize_letter_version(groups.dev_l, groups.dev_n),
+    local: parse_local_version(groups.local),
+  };
+
+  return parsed;
+}
+
+function stringify(parsed) {
+  if (!parsed) {
+    return null;
+  }
+  const { epoch, release, pre, post, dev, local } = parsed;
+  const parts = [];
+
+  // Epoch
+  if (epoch !== 0) {
+    parts.push(`${epoch}!`);
+  }
+  // Release segment
+  parts.push(release.join('.'));
+
+  // Pre-release
+  if (pre) {
+    parts.push(pre.join(''));
+  }
+  // Post-release
+  if (post) {
+    parts.push('.' + post.join(''));
+  }
+  // Development release
+  if (dev) {
+    parts.push('.' + dev.join(''));
+  }
+  // Local version segment
+  if (local) {
+    parts.push(`+${local}`);
+  }
+  return parts.join('');
+}
+
+function normalize_letter_version(letterIn, numberIn) {
+  let letter = letterIn;
+  let number = numberIn;
+  if (letter) {
+    // We consider there to be an implicit 0 in a pre-release if there is
+    // not a numeral associated with it.
+    if (!number) {
+      number = 0;
+    }
+    // We normalize any letters to their lower case form
+    letter = letter.toLowerCase();
+
+    // We consider some words to be alternate spellings of other words and
+    // in those cases we want to normalize the spellings to our preferred
+    // spelling.
+    if (letter === 'alpha') {
+      letter = 'a';
+    } else if (letter === 'beta') {
+      letter = 'b';
+    } else if (['c', 'pre', 'preview'].includes(letter)) {
+      letter = 'rc';
+    } else if (['rev', 'r'].includes(letter)) {
+      letter = 'post';
+    }
+    return [letter, Number(number)];
+  }
+  if (!letter && number) {
+    // We assume if we are given a number, but we are not given a letter
+    // then this is using the implicit post release syntax (e.g. 1.0-1)
+    letter = 'post';
+
+    return [letter, Number(number)];
+  }
+  return null;
+}
+
+function parse_local_version(local) {
+  /*
+    Takes a string like abc.1.twelve and turns it into("abc", 1, "twelve").
+    */
+  if (local) {
+    return local
+      .split(/[._-]/)
+      .map((part) =>
+        Number.isNaN(Number(part)) ? part.toLowerCase() : Number(part),
+      );
+  }
+  return null;
+}
+
+function explain(version) {
+  const parsed = parse(version);
+  if (!parsed) {
+    return parsed;
+  }
+  const { epoch, release, pre, post, dev, local } = parsed;
+
+  let base_version = '';
+  if (epoch !== 0) {
+    base_version += epoch + '!';
+  }
+  base_version += release.join('.');
+
+  const is_prerelease = Boolean(dev || pre);
+  const is_devrelease = Boolean(dev);
+  const is_postrelease = Boolean(post);
+
+  // return
+
+  return {
+    epoch,
+    release,
+    pre,
+    post: post ? post[1] : post,
+    dev: dev ? dev[1] : dev,
+    local: local ? local.join('.') : local,
+    public: stringify(parsed).split('+', 1)[0],
+    base_version,
+    is_prerelease,
+    is_devrelease,
+    is_postrelease,
+  };
+}
+
+
 /***/ }),
 
 /***/ 31324:
@@ -123171,6 +123948,7 @@ exports.resolveVersion = resolveVersion;
 const core = __importStar(__nccwpck_require__(37484));
 const tc = __importStar(__nccwpck_require__(33472));
 const path = __importStar(__nccwpck_require__(76760));
+const pep440 = __importStar(__nccwpck_require__(63297));
 const node_fs_1 = __nccwpck_require__(73024);
 const constants_1 = __nccwpck_require__(56156);
 const checksum_1 = __nccwpck_require__(95391);
@@ -123222,8 +124000,8 @@ async function resolveVersion(versionInput, githubToken) {
     }
     const availableVersions = await getAvailableVersions(githubToken);
     core.debug(`Available versions: ${availableVersions}`);
-    const resolvedVersion = tc.evaluateVersions(availableVersions, version);
-    if (resolvedVersion === "") {
+    const resolvedVersion = maxSatisfying(availableVersions, version);
+    if (resolvedVersion === undefined) {
         throw new Error(`No version found for ${version}`);
     }
     return resolvedVersion;
@@ -123281,6 +124059,19 @@ async function getLatestRelease(octokit) {
     });
     return latestRelease;
 }
+function maxSatisfying(versions, version) {
+    const maxSemver = tc.evaluateVersions(versions, version);
+    if (maxSemver !== "") {
+        core.debug(`Found a version that satisfies the semver range: ${maxSemver}`);
+        return maxSemver;
+    }
+    const maxPep440 = pep440.maxSatisfying(versions, version);
+    if (maxPep440 !== null) {
+        core.debug(`Found a version that satisfies the pep440 specifier: ${maxPep440}`);
+        return maxPep440;
+    }
+    return undefined;
+}
 
 
 /***/ }),
diff --git a/package-lock.json b/package-lock.json
index cfc9462..00e9d87 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
         "@octokit/core": "^6.1.4",
         "@octokit/plugin-paginate-rest": "^11.4.3",
         "@octokit/plugin-rest-endpoint-methods": "^13.3.1",
+        "@renovatebot/pep440": "^4.1.0",
         "smol-toml": "^1.3.1",
         "undici": "^7.5.0"
       },
@@ -1705,6 +1706,16 @@
         "@protobuf-ts/runtime": "^2.9.4"
       }
     },
+    "node_modules/@renovatebot/pep440": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.1.0.tgz",
+      "integrity": "sha512-mo2RxnOSp78Njt1HmgMwjl6FapP4OyIS8HypJlymCvN7AIV2Xf5PmZfl/E3O1WWZ6IjKrfsEAaPWFMi8tnkq3g==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^20.9.0 || ^22.11.0",
+        "pnpm": "^9.0.0"
+      }
+    },
     "node_modules/@sinclair/typebox": {
       "version": "0.27.8",
       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -5933,6 +5944,11 @@
         "@protobuf-ts/runtime": "^2.9.4"
       }
     },
+    "@renovatebot/pep440": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.1.0.tgz",
+      "integrity": "sha512-mo2RxnOSp78Njt1HmgMwjl6FapP4OyIS8HypJlymCvN7AIV2Xf5PmZfl/E3O1WWZ6IjKrfsEAaPWFMi8tnkq3g=="
+    },
     "@sinclair/typebox": {
       "version": "0.27.8",
       "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
diff --git a/package.json b/package.json
index 345e4dd..a41f677 100644
--- a/package.json
+++ b/package.json
@@ -32,8 +32,9 @@
     "@octokit/core": "^6.1.4",
     "@octokit/plugin-paginate-rest": "^11.4.3",
     "@octokit/plugin-rest-endpoint-methods": "^13.3.1",
-    "undici": "^7.5.0",
-    "smol-toml": "^1.3.1"
+    "@renovatebot/pep440": "^4.1.0",
+    "smol-toml": "^1.3.1",
+    "undici": "^7.5.0"
   },
   "devDependencies": {
     "@biomejs/biome": "1.9.4",
diff --git a/src/download/download-version.ts b/src/download/download-version.ts
index 0be5bd6..010e538 100644
--- a/src/download/download-version.ts
+++ b/src/download/download-version.ts
@@ -1,6 +1,7 @@
 import * as core from "@actions/core";
 import * as tc from "@actions/tool-cache";
 import * as path from "node:path";
+import * as pep440 from "@renovatebot/pep440";
 import { promises as fs } from "node:fs";
 import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants";
 import type { Architecture, Platform } from "../utils/platforms";
@@ -85,8 +86,8 @@ export async function resolveVersion(
   }
   const availableVersions = await getAvailableVersions(githubToken);
   core.debug(`Available versions: ${availableVersions}`);
-  const resolvedVersion = tc.evaluateVersions(availableVersions, version);
-  if (resolvedVersion === "") {
+  const resolvedVersion = maxSatisfying(availableVersions, version);
+  if (resolvedVersion === undefined) {
     throw new Error(`No version found for ${version}`);
   }
   return resolvedVersion;
@@ -154,3 +155,22 @@ async function getLatestRelease(octokit: InstanceType) {
   });
   return latestRelease;
 }
+
+function maxSatisfying(
+  versions: string[],
+  version: string,
+): string | undefined {
+  const maxSemver = tc.evaluateVersions(versions, version);
+  if (maxSemver !== "") {
+    core.debug(`Found a version that satisfies the semver range: ${maxSemver}`);
+    return maxSemver;
+  }
+  const maxPep440 = pep440.maxSatisfying(versions, version);
+  if (maxPep440 !== null) {
+    core.debug(
+      `Found a version that satisfies the pep440 specifier: ${maxPep440}`,
+    );
+    return maxPep440;
+  }
+  return undefined;
+}