Skip to content

Instantly share code, notes, and snippets.

@dmyersturnbull
Last active June 4, 2025 00:43
Show Gist options
  • Save dmyersturnbull/d312e174bf7af28c73febb96fdf3a383 to your computer and use it in GitHub Desktop.
Save dmyersturnbull/d312e174bf7af28c73febb96fdf3a383 to your computer and use it in GitHub Desktop.
Intro to the strange world of PEP 440 version specifiers.
# 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:])
@dmyersturnbull
Copy link
Author

dmyersturnbull commented Jun 4, 2025

To run:

gh gist view --raw d312e174bf7af28c73febb96fdf3a383 | tail -n +3 > ./pep_440_showcase.py
uv run --script ./pep_440_showcase.py

The tail -n +3 is needed to skip the Gist description – apparently, that's not what --raw does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment