diff --git a/.ado/publish.yml b/.ado/publish.yml index 8c27783f..9a438d1f 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -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 @@ -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: @@ -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: | @@ -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 diff --git a/set_version.py b/set_version.py index 67e17794..caddf839 100644 --- a/set_version.py +++ b/set_version.py @@ -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: @@ -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}") diff --git a/test_set_version.py b/test_set_version.py index 1e9ee729..0d40b441 100644 --- a/test_set_version.py +++ b/test_set_version.py @@ -8,6 +8,8 @@ _get_build_version, _version_sort_key, get_build_version, + resolve_build_version, + validate_specified_version, VERSION_RE, ) @@ -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") \ No newline at end of file + 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) \ No newline at end of file