功能说明
这段代码实现 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()