Last active
June 4, 2025 00:43
-
-
Save dmyersturnbull/d312e174bf7af28c73febb96fdf3a383 to your computer and use it in GitHub Desktop.
Intro to the strange world of PEP 440 version specifiers.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# SPDX-FileCopyrightText: Copyright 2025, Contributors | |
# SPDX-PackageHomePage: https://gist.github.com/dmyersturnbull | |
# SPDX-License-Identifier: MIT OR CC0-1.0 | |
# /// script | |
# requires-python = ">=3.13" | |
# dependencies = ["loguru>=0.7", "packaging>=25", "semver>=3.0"] | |
# /// | |
"""Intro to the strange world of PEP 440 version specifiers. | |
Examples are tested using the official package [_packaging_](https://pypi.org/project/packaging/), | |
and compared against npm rules for semver. | |
Prints a table to stdout like: | |
``` | |
PEP npm version spec | |
--- --- ----------- ------------- | |
✔ ✘ 1.0.0-a9 < 1.0.0-a10 | |
✔ ✔ 1.0.0-a.9 < 1.0.0-a.10 | |
... ... ... ... | |
``` | |
""" | |
# This code could have been a lot shorter, but hindsight is 2020. | |
import re | |
import sys | |
from argparse import ArgumentParser, Namespace | |
from collections.abc import Iterable, Iterator | |
from typing import Final, NamedTuple | |
from loguru import logger | |
from packaging.specifiers import InvalidSpecifier, SpecifierSet | |
from packaging.version import InvalidVersion | |
from packaging.version import Version as Pep440 | |
from semver import Version as Semver | |
_CLAIM_REGEX: Final[re.Pattern] = re.compile( | |
r" *(?P<vr>[^!<>~=^]++) *(?P<op>[!><=~^]++) *(?P<test>[^!<>~=^]++) *" | |
) | |
class Claim(NamedTuple): | |
"""A PyPA and/or SemVer version string, a comparison operator, and a test version.""" | |
version: str | |
operator: str | |
test: str | |
@property | |
def predicate(self) -> str: | |
return self.operator + self.test | |
@property | |
def as_statement(self) -> str: | |
return self.version + self.operator + self.test | |
class ClaimResult(NamedTuple): | |
"""The result of evaluating a comparison as either PyPA / PEP 440 (or SemVer+NPM). | |
The attribute `value` is `None` if the string is invalid in the relevant DSL. | |
""" | |
value: bool | None | |
@property | |
def as_icon(self) -> str: | |
return {True: "✔", False: "✘", None: " "}[self.value] | |
class ClaimResultPair(NamedTuple): | |
"""The results of evaluating a comparison as both PEP 440 and SemVer+NPM.""" | |
string: Claim | |
pypa_result: ClaimResult | |
npm_result: ClaimResult | |
class PypaSpecs: | |
"""Utils for PyPA / PEP 440 specs.""" | |
def is_match(self, spec: Claim) -> ClaimResult: | |
"""Returns the result of the PEP 440 comparison, or `None` if the comparison is invalid.""" | |
# `packaging` is surprisingly accepting of invalid versions and specifiers. | |
vr_: Pep440 | None = self._parse_vr(spec.version) | |
pred_: SpecifierSet | None = self._parse_predicate(spec.predicate) | |
if vr_ and pred_: | |
return ClaimResult(vr_ in pred_) | |
logger.warning( | |
"Invalid PEP 440 comparison: '{} {}'.", | |
spec.version if vr_ else f" ⟪{spec.version}⟫", | |
spec.predicate if pred_ else f" ⟪{spec.predicate}⟫", | |
) | |
return ClaimResult(None) | |
def _parse_vr(self, version: str) -> Pep440 | None: | |
try: | |
return Pep440(version) | |
except InvalidVersion: | |
logger.info("Not a PEP 440 version string: '{}'", version, exc_info=True) | |
return None | |
def _parse_predicate(self, predicate: str) -> SpecifierSet | None: | |
try: | |
return SpecifierSet(predicate) | |
except InvalidSpecifier: | |
logger.info("Not a PEP 440 predicate: '{}'", predicate, exc_info=True) | |
return None | |
class NpmSpecs: | |
"""Utils for SemVer+NPM specs.""" | |
def is_match(self, spec: Claim) -> ClaimResult: | |
"""Returns the result of the NPM comparison, or `None` if the comparison is invalid.""" | |
vr_ = self._parse_version(spec.version) | |
op_ = self._parse_op(spec.operator) | |
test_ = self._parse_test(spec.test) | |
if vr_ and op_ and test_: | |
return ClaimResult(self._eval(op_, test_, vr_)) | |
logger.warning( | |
"Invalid SemVer comparison: '{} {} {}'.", | |
spec.version if vr_ else f" ⟪{spec.version}⟫", | |
spec.operator if op_ else f" ⟪{spec.operator}⟫", | |
spec.test if test_ else f" ⟪{spec.test}⟫", | |
) | |
return ClaimResult(None) | |
def _eval(self, op_: str, test_: Semver, vr_: Semver) -> bool: | |
sgn = vr_.compare(test_) | |
return ( | |
op_ == "~" and sgn >= 0 and (vr_.major, vr_.minor) == (test_.major, test_.minor) | |
or op_ == "^" and sgn >= 0 and vr_.major == test_.major | |
or op_ == ">" and sgn > 0 | |
or op_ == ">=" and sgn >= 0 | |
or op_ == "<" and sgn < 0 | |
or op_ == "<=" and sgn <= 0 | |
or op_ == "!=" and sgn != 0 | |
or op_ == "=" and sgn == 0 | |
) # fmt: skip | |
def _parse_version(self, version: str) -> Semver | None: | |
try: | |
return Semver.parse(version) | |
except ValueError: | |
logger.info("Not a SemVer version: '{}'", version, exc_info=True) | |
return None | |
def _parse_op(self, operator: str) -> str | None: | |
# NPM uses `^`, `~`, and `=`. | |
# PyPA doesn't understand `^=`, but we'll correct that anyway. | |
op_map = {"~=": "~", "^=": "^", "==": "="} | |
op_map |= {o: o for o in (">", ">=", "<", "<=", "!=", "=", "~", "^")} | |
return op_map.get(operator) | |
def _parse_test(self, test: str) -> Semver | None: | |
# Allow e.g. `0.5` instead of `0.5.0` for the test. | |
if "-" not in test and "+" not in test: | |
test += ".0" * max(0, 2 - test.count(".")) | |
try: | |
return Semver.parse(test) | |
except ValueError: | |
logger.info("Not a SemVer version: '{}'", test, exc_info=True) | |
return None | |
class ClaimEvaluator: | |
"""A utility that evaluates a spec claim.""" | |
def __call__(self, string: str) -> ClaimResultPair | None: | |
"""Evaluates a single claim, returning `None` if the string doesn't match the regex.""" | |
if match := _CLAIM_REGEX.fullmatch(string): | |
spec = Claim(*match.groups()) | |
return ClaimResultPair(spec, PypaSpecs().is_match(spec), NpmSpecs().is_match(spec)) | |
logger.exception("Invalid input string '{}'.", string) | |
return None | |
class ClaimResultsTables: | |
"""A utility to show a table of spec claims and results.""" | |
def create_table(self, spec_results: list[ClaimResultPair]) -> list[str]: | |
return self._get_table_lines(self._get_table_rows(spec_results)) | |
def _get_table_rows(self, spec_results: list[ClaimResultPair]) -> Iterator[str]: | |
"""For each provided test result, yields a line of the form `✔/✘ <vr> <spec>`.""" | |
if not spec_results: | |
msg = "Must provide at least 1 test." | |
raise ValueError(msg) | |
vr_len = max(len(r.string.version) for r in spec_results) | |
test_len = max(len(r.string.test) for r in spec_results) | |
yield "PEP npm version " + " " * (vr_len - 7) + "spec" + " " * (test_len - 3) | |
yield "--- --- " + "-" * vr_len + " " + "-" * test_len | |
for result in spec_results: | |
vr, op, test = result.string | |
pypa_char, npm_char = result.pypa_result.as_icon, result.npm_result.as_icon | |
yield f"{pypa_char} {npm_char} {vr:<{vr_len}} {op:>2}{test:<{test_len}}" | |
def _get_table_lines(self, rows: Iterable[str]) -> list[str]: | |
return [ | |
"See:", | |
"- https://packaging.python.org/en/latest/specifications/version-specifiers/", | |
"- https://semver.org/", | |
"- https://gist.github.com/jonlabelle/706b28d50ba75bf81d40782aa3c84b3e#npm-version-symbols", | |
"", | |
"Cheatsheet – version grammars:", | |
r"PEP 440: [<epoch>!]<major>[.<minor>[.<micro>{<etc>}]] [(a|b|rc)<pre>] [.post<post>][.dev<dev>]", | |
r"Semver: <major> .<minor> .<patch> [-<pre>]", | |
r"(Key: () grouped [] optional {} repeatable)", | |
"", | |
*rows, | |
"", | |
"Key: ✔ true ✘ false '' invalid", | |
] | |
class Main: | |
"""A CLI.""" | |
_LOG_LEVELS: Final = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | |
DEFAULT_CLAIMS: Final = [ | |
"1.0.0-a9<1.0.0-a10", | |
"1.0.0-a.9<1.0.0-a.10", | |
"0.6.0~=0.5.0", | |
"0.6.0~=0.5", | |
"0.6.0-dev.1~=0.5.0", | |
"0.5.0-dev.1~=0.5.0", | |
"1.1.0-a.1>=1.0.0", | |
"1.1.0-a.1>=1.1.0-a.0", | |
"1.1.0-rc.0>=1.1.0-dev.0", | |
"1.1.0>=1.1.0-a.0", | |
"1.1.0-1<=1.1.0-dev.1", | |
"1.1.0-dev.0<=1.1.0-dev.1", | |
"1.1.0-alpha<=1.1.0-beta", | |
"1.1.0-dev<=1.1.0-alpha", | |
"1.1.0<=1.1.0", | |
"1.0.0-alpha==1.0.0-alpha.0", | |
"1.0.0-a.0==1.0.0-alpha.0", | |
"1.0.0-rc==1.0.0-c.0", | |
] | |
def run(self, args: list[str]) -> None: | |
ns: Namespace = self._make_parser().parse_args(args) | |
self._configure_logging(quiet=ns.quiet, verbose=ns.verbose) | |
claims: list[str] = ns.claims or self.DEFAULT_CLAIMS | |
results = [ClaimEvaluator()(claim) for claim in claims] | |
results = [r for r in results if r] | |
for line in ClaimResultsTables().create_table(results): | |
print(line, flush=True) # noqa: T201 | |
def _configure_logging(self, *, quiet: int, verbose: int) -> None: | |
level_index = 2 + quiet - verbose | |
level_index = max(0, min(len(self._LOG_LEVELS) - 1, level_index)) | |
log_level = self._LOG_LEVELS[level_index] | |
logger.remove() | |
logger.add(sys.stdout) | |
logger.add(sys.stderr, format="{message}", level=log_level, backtrace=level_index < 1) | |
def _make_parser(self) -> ArgumentParser: | |
parser = ArgumentParser( | |
description="Compare PyPA / PEP 440 and SemVer + NPM version specifiers." | |
) | |
parser.add_argument( | |
"claims", | |
metavar="CLAIM", | |
nargs="*", | |
help=( | |
"Version specification claims to evaluate (e.g. '1.0.0-a9 < 1.0.0-a10')." | |
" If not provided, an default will be used." | |
), | |
) | |
parser.add_argument( | |
"--verbose", "-v", action="count", default=0, help="Decrease log level (repeatable)." | |
) | |
parser.add_argument( | |
"--quiet", "-q", action="count", default=0, help="Increase log level (repeatable)." | |
) | |
return parser | |
if __name__ == "__main__": | |
Main().run(sys.argv[1:]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
To run:
The
tail -n +3
is needed to skip the Gist description – apparently, that's not what--raw
does.