blob: 63297a83eb44b5adb38625557142a39ee04e745f [file] [edit]
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""Pyrefly version-format helpers shared by the GitHub release workflows.
`version.bzl` records canonical semver (e.g. "1.2.3" or "1.2.0-dev.4").
Maturin handles the PEP 440 conversion for PyPI automatically. The VS Code
Marketplace needs a different mapping since it doesn't accept semver
pre-release identifiers:
- bare semver (M.m.p): passes through unchanged
- M.m.0-dev.N with m >= 1: M.(m-1).(9000 + N)
- M.0.0-dev.N with M > 1: (M-1).999.(9000 + N) (major boundary)
Used as a CLI from the release workflows:
python3 scripts/version_helpers.py to-marketplace 1.2.0-dev.4
python3 scripts/version_helpers.py is-prerelease 1.2.0-dev.4
"""
# pyre-strict
from __future__ import annotations
import argparse
import re
import sys
_SEMVER_RE: re.Pattern[str] = re.compile(
r"^(\d+)\.(\d+)\.(\d+)(?:-dev\.(\d+))?$",
)
def parse_semver(version: str) -> tuple[int, int, int, int | None]:
"""Parse a pyrefly semver string into (major, minor, patch, dev_n).
dev_n is None for bare semver. Raises ValueError on malformed input.
"""
match = _SEMVER_RE.match(version)
if not match:
raise ValueError(f"Not a valid pyrefly semver version: {version!r}")
for group in (match.group(1), match.group(2), match.group(3)):
if len(group) > 1 and group[0] == "0":
raise ValueError(
f"Leading zero in version component {group!r} is not allowed",
)
dev_str = match.group(4)
if dev_str is not None and len(dev_str) > 1 and dev_str[0] == "0":
raise ValueError(
f"Leading zero in dev counter {dev_str!r} is not allowed",
)
return (
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
int(dev_str) if dev_str is not None else None,
)
def format_semver(version: tuple[int, int, int, int | None]) -> str:
"""Render a parsed version tuple as canonical semver (inverse of parse_semver)."""
major, minor, patch, dev_n = version
s = f"{major}.{minor}.{patch}"
if dev_n is not None:
s += f"-dev.{dev_n}"
return s
def previous_minor(version: str) -> str:
"""Bare release tag the changelog for a stable minor/major cut diffs *from*.
A minor release's notes should span the whole minor -- everything since
the previous stable minor -- not just the last dev snapshot, so they diff
from `M.(m-1).0`. Tags are bare semver with no `v` prefix (see
publish_to_pypi.yml).
Raises ValueError unless `version` is a stable `M.m.0` with m > 0. A major
bump `M.0.0` has no previous minor in its own major line, so the caller
must supply an explicit from-ref.
"""
major, minor, patch, dev_n = parse_semver(version)
if dev_n is not None or patch != 0:
raise ValueError(
f"previous_minor expects a stable M.m.0 version, got {version!r}"
)
if minor == 0:
raise ValueError(
f"{version!r} is a major release with no previous minor in its "
"major line; supply an explicit from-ref"
)
return f"{major}.{minor - 1}.0"
def is_prerelease(version: str) -> bool:
"""Return True if the version is a pre-release (contains -dev.N)."""
_, _, _, dev_n = parse_semver(version)
return dev_n is not None
def to_marketplace(version: str) -> str:
"""Convert a pyrefly semver to its marketplace M.m.p form.
Stable (bare semver) passes through unchanged. Dev releases for a
non-zero minor map to `M.(m-1).(9000 + N)`. Dev releases at a major
boundary (M.0.0-dev.N with M > 1) map to `(M-1).999.(9000 + N)`.
>>> to_marketplace("1.2.0")
'1.2.0'
>>> to_marketplace("1.2.1")
'1.2.1'
>>> to_marketplace("1.2.0-dev.4")
'1.1.9004'
>>> to_marketplace("2.0.0-dev.1")
'1.999.9001'
"""
major, minor, patch, dev_n = parse_semver(version)
if dev_n is None:
return f"{major}.{minor}.{patch}"
if patch != 0:
# The dev form is defined as M.m.0-dev.N. Mapping ignores patch, so
# without this check 1.2.3-dev.4 and 1.2.0-dev.4 would collide on
# 1.1.9004.
raise ValueError(
f"Dev version {version!r} has nonzero patch; expected M.m.0-dev.N.",
)
if dev_n >= 1000:
raise ValueError(
f"dev counter {dev_n} is >= 1000 and would overflow the "
"marketplace mapping. Cut a stable/patch release before the "
"dev counter reaches 1000.",
)
market_patch = 9000 + dev_n
if minor == 0:
if major < 1:
raise ValueError(
f"Cannot map major-boundary dev version {version!r}: "
"major must be >= 1.",
)
return f"{major - 1}.999.{market_patch}"
return f"{major}.{minor - 1}.{market_patch}"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
p_market = sub.add_parser(
"to-marketplace",
help="Print the M.m.p form for VS Code Marketplace and Open VSX.",
)
p_market.add_argument("version")
p_pre = sub.add_parser(
"is-prerelease",
help=(
"Print 'true' or 'false'. Exits 0 if the version is a "
"pre-release, 1 otherwise. (Both shapes available so callers "
"can use whichever fits.)"
),
)
p_pre.add_argument("version")
args = parser.parse_args(argv)
try:
if args.command == "to-marketplace":
print(to_marketplace(args.version))
return 0
if args.command == "is-prerelease":
result = is_prerelease(args.version)
print("true" if result else "false")
return 0 if result else 1
except ValueError as e:
print(str(e), file=sys.stderr)
return 2
raise AssertionError(f"Unhandled command: {args.command!r}")
if __name__ == "__main__":
sys.exit(main())