Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .ado/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ parameters:
- stable
default: "dev"

- name: Version
displayName: Version (leave blank to compute automatically)
type: string
# Default is a single space, not "", because Azure DevOps renders a string
# parameter with an empty-string default as a *required* field in the Run panel.
# set_version.py strips the value, so a blank/whitespace entry takes the
# automatic-versioning path.
default: " "

- name: Publish_Python_Package_To_Build_Artifacts
displayName: Publish Python package to Build's Artifacts
type: boolean
Expand Down Expand Up @@ -84,6 +93,13 @@ extends:
versionSpec: "3.11"
displayName: Set Python version

- script: |
python set_version.py --validate-only
env:
BUILD_TYPE: ${{ parameters.Build_Type }}
VERSION: ${{ parameters.Version }}
displayName: Validate version input

- task: PipAuthenticate@1
displayName: Authenticate pip to Azure Artifacts feed
inputs:
Expand All @@ -103,6 +119,7 @@ extends:
env:
BUILD_TYPE: ${{ parameters.Build_Type }}
RELEASE_TYPE: ${{ parameters.Release_Type }}
VERSION: ${{ parameters.Version }}
displayName: Set "azure-quantum" package version

- script: |
Expand Down Expand Up @@ -202,6 +219,7 @@ extends:
env:
BUILD_TYPE: ${{ parameters.Build_Type }}
RELEASE_TYPE: ${{ parameters.Release_Type }}
VERSION: ${{ parameters.Version }}
displayName: Set "azure-quantum" package version

- task: CopyFiles@2
Expand Down
94 changes: 93 additions & 1 deletion set_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,19 @@
r"azure[-_]quantum-(\d+\.\d+\.\d+(?:\.(?:dev|rc)\d+)?)(?:-|\.tar\.gz|\.zip)",
re.IGNORECASE,
)
# Anchored full-string match used to validate a manually supplied version.
# Accepts the same subset the automated path produces: "major.minor.patch" optionally
# followed by ".devN" or ".rcN".
VERSION_INPUT_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.(?:dev|rc)\d+)?$")


RELEASE_TYPE = os.environ.get("RELEASE_TYPE") or "patch"
BUILD_TYPE = os.environ.get("BUILD_TYPE") or "dev"
# Optional manually specified version. When set, this exact version is used and the
# automated computation (which reads the package index) is skipped. Useful when the
# Azure Artifacts feed cache is stale relative to PyPI, e.g. during releases in quick
# succession.
VERSION = (os.environ.get("VERSION") or "").strip()


if RELEASE_TYPE not in ALLOWED_RELEASE_TYPES:
Expand Down Expand Up @@ -198,8 +207,91 @@ def get_build_version(version_type: str, build_type: str) -> str:
return build_version


def validate_specified_version(build_type: str, version: str) -> str:
"""Validate a manually specified version and return it stripped.

Returns "" when no version is specified (blank/whitespace), signalling that the
version should be computed automatically. Raises ``ValueError`` when a version is
specified but is malformed or disagrees with ``build_type``.

This performs only local checks (no network), so it is safe to run as an early
fail-fast step before any package-index access.

:param build_type: Build type ("stable"/"dev"/"rc"). Determines which pre-release
suffix a specified version must carry.
:param version: Candidate version string, or "" to compute automatically.
:return: The stripped version, or "" if none was specified.
:rtype: str
"""
specified_version = (version or "").strip()
if not specified_version:
return ""

if not VERSION_INPUT_RE.match(specified_version):
raise ValueError(
f"Version \"{specified_version}\" is not a valid version. Expected "
f"\"major.minor.patch\" optionally followed by \".devN\" or \".rcN\"."
)

# The specified version must match the selected build type, so a build tagged
# "dev"/"rc" can't ship a version that lacks (or mismatches) the suffix.
if build_type == "dev" and ".dev" not in specified_version:
raise ValueError(
f"Build type \"dev\" requires a \".devN\" version, but got "
f"\"{specified_version}\"."
)
if build_type == "rc" and ".rc" not in specified_version:
raise ValueError(
f"Build type \"rc\" requires a \".rcN\" version, but got "
f"\"{specified_version}\"."
)
if build_type == "stable" and (".dev" in specified_version or ".rc" in specified_version):
raise ValueError(
f"Build type \"stable\" requires a \"major.minor.patch\" version "
f"without a pre-release suffix, but got \"{specified_version}\"."
)

return specified_version


def resolve_build_version(version_type: str, build_type: str, version: str = "") -> str:
"""Resolve the version to ship for this run.

If ``version`` is a non-empty string, it is validated and used as-is, skipping the
automated computation that reads the package index. Otherwise the next version is
computed from the published version history.

When a version is specified, it must agree with ``build_type``: a "dev" build must
supply a ".devN" version, an "rc" build a ".rcN" version, and a "stable" build a
plain "major.minor.patch" version (no pre-release suffix).

:param version_type: SYMVER type ("major"/"minor"/"patch"); ignored when a version
is specified.
:param build_type: Build type ("stable"/"dev"/"rc"). Determines which pre-release
suffix a specified version must carry.
:param version: Exact version to use, or "" to compute automatically.
:return: The version to ship.
:rtype: str
"""
specified_version = validate_specified_version(build_type, version)
if specified_version:
print(f"Using manually specified version: {specified_version}")
return specified_version

return get_build_version(version_type, build_type)


if __name__ == "__main__":
build_version = get_build_version(RELEASE_TYPE, BUILD_TYPE)
import sys

# Early fail-fast mode: validate the manually specified version (if any) without
# touching the network, so a bad input stops the run before expensive setup.
if "--validate-only" in sys.argv:
validate_specified_version(BUILD_TYPE, VERSION)
print("Version input is valid.")
sys.exit(0)

build_version = resolve_build_version(RELEASE_TYPE, BUILD_TYPE, VERSION)

print(f"Package version: {build_version}")

Expand Down
63 changes: 62 additions & 1 deletion test_set_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
_get_build_version,
_version_sort_key,
get_build_version,
resolve_build_version,
validate_specified_version,
VERSION_RE,
)

Expand Down Expand Up @@ -131,4 +133,63 @@ def test_get_build_version_existing_version_raises(monkeypatch):
monkeypatch.setattr(set_version, "_fetch_versions", lambda index_url: ["1.0.0"])
monkeypatch.setattr(set_version, "_get_build_version", lambda *args: "1.0.0")
with pytest.raises(RuntimeError):
get_build_version("patch", "stable")
get_build_version("patch", "stable")


def test_resolve_build_version_uses_specified_version(monkeypatch):
# When a valid version is specified, it is returned as-is and the automated
# computation is skipped entirely (so the package index is never contacted).
def _should_not_be_called(*args, **kwargs):
raise AssertionError("get_build_version must not be called when a version is given")

monkeypatch.setattr(set_version, "get_build_version", _should_not_be_called)
assert resolve_build_version("patch", "dev", "1.2.3.dev0") == "1.2.3.dev0"


def test_resolve_build_version_falls_back_when_blank(monkeypatch):
# A blank version falls back to the automated computation.
monkeypatch.setattr(
set_version, "get_build_version", lambda vt, bt: f"computed-{vt}-{bt}"
)
assert resolve_build_version("minor", "rc", "") == "computed-minor-rc"


@pytest.mark.parametrize("blank", ["", " ", None])
def test_validate_specified_version_blank_returns_empty(blank):
# A blank/whitespace/None version returns "" (signalling automatic computation)
# and never touches the network.
assert validate_specified_version("dev", blank) == ""


@pytest.mark.parametrize(
"build_type,version",
[
("dev", "1.2.3.dev0"),
("rc", "1.2.3.rc7"),
("stable", "1.2.3"),
# Surrounding whitespace is stripped.
("dev", " 1.2.3.dev0 "),
],
)
def test_validate_specified_version_accepts_valid(build_type, version):
# A valid version that agrees with the build type is accepted and returned
# stripped of surrounding whitespace.
assert validate_specified_version(build_type, version) == version.strip()


@pytest.mark.parametrize(
"build_type,version",
[
# Malformed versions.
("dev", "1.2"),
("dev", "v1.2.3"),
# Build type disagrees with the version's suffix (or lack of one).
("dev", "1.2.3"),
("rc", "1.2.3.dev0"),
("stable", "1.2.3.rc0"),
],
)
def test_validate_specified_version_rejects_invalid(build_type, version):
# Malformed or build-type-mismatched versions fail loud.
with pytest.raises(ValueError):
validate_specified_version(build_type, version)
Loading