← 中文资源

简体中文版本

无依赖比较语义化版本

解析并比较语义化版本,正确排序预发布版本,构建元数据不参与优先级。

阅读英文原文

功能说明

这段代码实现 Semantic Versioning 的解析与比较:主版本、次版本、补丁号按数值比较,预发布标识按规范排序,构建元数据被忽略。它适合在脚本中判断“当前版本是否落后于最新版本”,无需引入 packaging 或第三方 semver 库。

排序规则的关键是:有预发布标识的版本低于对应的正式版本;数字标识按数值比较,字母标识按字典序比较;较短且前缀相同的标识更低,例如 alpha 低于 alpha.1。

关键代码

SEMVER_RE 严格匹配 major.minor.patch,主次补丁不允许前导零,并可选捕获预发布段和构建段。Version.parse 把数字段转成 int,把预发布段拆成元组。

_rank 为正式版返回 (1,),为预发布版返回 (0, identifiers),从而保证正式版始终更大。@total_ordering 配合自定义 __lt__ 生成其余比较运算。__str__ 重建可读字符串,但会丢弃构建元数据。

使用注意

代码只实现比较,不实现 ^、~、>= 等范围约束;需要范围匹配时应另行实现。预发布标识中的数字段没有强制禁止前导零,这是相对规范的简化。构建元数据在解析时被丢弃,因此 1.0.0+build.9 与 1.0.0 相等,无法从对象恢复原始构建号。

版本字符串前后空白会被去除,内部空白会解析失败。比较只能在 Version 对象之间进行,直接和字符串比较会触发类型错误。生产环境若需要完整规范支持,建议使用成熟库并补充测试。

验证方式

示例把七个版本排序后输出 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0 < 1.10.0 < 2.0.0,断言列表完全匹配。1.0.0+build.9 与 1.0.0 相等,说明构建元数据不影响优先级;1.0 会触发 ValueError。

代码示例

from __future__ import annotations

import re
from dataclasses import dataclass
from functools import total_ordering

SEMVER_RE = re.compile(
    r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
    r"(?:-(?P<prerelease>[0-9A-Za-z.-]+))?"
    r"(?:\+(?P<build>[0-9A-Za-z.-]+))?$"
)


@total_ordering
@dataclass(frozen=True)
class Version:
    major: int
    minor: int
    patch: int
    prerelease: tuple[str, ...] = ()

    @classmethod
    def parse(cls, text: str) -> Version:
        match = SEMVER_RE.match(text.strip())
        if match is None:
            raise ValueError(f"invalid semantic version: {text!r}")
        prerelease = match.group("prerelease")
        return cls(
            major=int(match.group("major")),
            minor=int(match.group("minor")),
            patch=int(match.group("patch")),
            prerelease=tuple(prerelease.split(".")) if prerelease else (),
        )

    def _rank(self) -> tuple[object, ...]:
        if not self.prerelease:
            return (1,)
        identifiers = tuple(
            (0, int(part)) if part.isdigit() else (1, part)
            for part in self.prerelease
        )
        return (0, identifiers)

    def __lt__(self, other: Version) -> bool:
        return (self.major, self.minor, self.patch, self._rank()) < (
            other.major,
            other.minor,
            other.patch,
            other._rank(),
        )

    def __str__(self) -> str:
        base = f"{self.major}.{self.minor}.{self.patch}"
        if self.prerelease:
            return f"{base}-{'.'.join(self.prerelease)}"
        return base


def main() -> None:
    raw = [
        "1.0.0",
        "1.0.0-alpha",
        "1.0.0-alpha.1",
        "1.0.0-beta",
        "1.0.0-rc.1",
        "1.10.0",
        "2.0.0",
    ]
    ordered = [str(version) for version in sorted(Version.parse(item) for item in raw)]
    print(" < ".join(ordered))
    assert ordered == [
        "1.0.0-alpha",
        "1.0.0-alpha.1",
        "1.0.0-beta",
        "1.0.0-rc.1",
        "1.0.0",
        "1.10.0",
        "2.0.0",
    ]
    assert Version.parse("1.0.0+build.9") == Version.parse("1.0.0")
    try:
        Version.parse("1.0")
    except ValueError as exc:
        print(f"rejected: {exc}")


if __name__ == "__main__":
    main()