From b7f5780403b568f98d3debe7273d1c9ab55ff28b Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 6 Aug 2026 13:26:20 -0700 Subject: [PATCH 1/5] Add optional manually specified version --- .ado/publish.yml | 7 +++++++ set_version.py | 39 ++++++++++++++++++++++++++++++++++++++- test_set_version.py | 42 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index 8c27783f..04bdf1c6 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -20,6 +20,11 @@ parameters: - stable default: "dev" + - name: Version + displayName: Version (leave blank to compute automatically) + type: string + default: "" + - name: Publish_Python_Package_To_Build_Artifacts displayName: Publish Python package to Build's Artifacts type: boolean @@ -103,6 +108,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 +208,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..1965337f 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,36 @@ def get_build_version(version_type: str, build_type: str) -> str: return build_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. + + :param version_type: SYMVER type ("major"/"minor"/"patch"); ignored when a version + is specified. + :param build_type: Build type ("stable"/"dev"/"rc"); ignored when a version is + specified. + :param version: Exact version to use, or "" to compute automatically. + :return: The version to ship. + :rtype: str + """ + specified_version = (version or "").strip() + if specified_version: + 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\"." + ) + 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) + 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..0e3a1bf0 100644 --- a/test_set_version.py +++ b/test_set_version.py @@ -8,6 +8,7 @@ _get_build_version, _version_sort_key, get_build_version, + resolve_build_version, VERSION_RE, ) @@ -131,4 +132,43 @@ 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") + + +@pytest.mark.parametrize( + "version", + ["1.2.3", "1.2.3.dev0", "10.20.30.rc5"], +) +def test_resolve_build_version_uses_specified_version(monkeypatch, version): + # A valid specified version 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", version) == version + + +@pytest.mark.parametrize( + "blank", + ["", " ", None], +) +def test_resolve_build_version_falls_back_when_blank(monkeypatch, blank): + # A blank/whitespace/None 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", blank) == "computed-minor-rc" + + +@pytest.mark.parametrize( + "version", + ["1.2", "1.2.3.4", "1.2.3.beta0", "1.2.3.dev", "v1.2.3"], +) +def test_resolve_build_version_rejects_invalid_version(monkeypatch, version): + # A malformed version fails loud rather than shipping a bad version. + monkeypatch.setattr( + set_version, "get_build_version", lambda *a, **k: "should-not-be-used" + ) + with pytest.raises(ValueError): + resolve_build_version("patch", "dev", version) \ No newline at end of file From 46e0a7ba213e09615033b65faefd47e5494a9ca2 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 6 Aug 2026 13:43:15 -0700 Subject: [PATCH 2/5] optional --- .ado/publish.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index 04bdf1c6..a0c8ab74 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -23,7 +23,11 @@ parameters: - name: Version displayName: Version (leave blank to compute automatically) type: string - default: "" + # 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 From be8216d95ae473cd6e7951a13cc8cb0950d5996c Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Thu, 6 Aug 2026 13:44:58 -0700 Subject: [PATCH 3/5] validate that manual version agrees with build type --- set_version.py | 27 ++++++++++++++++++++++++-- test_set_version.py | 47 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/set_version.py b/set_version.py index 1965337f..1f7e5a6f 100644 --- a/set_version.py +++ b/set_version.py @@ -214,10 +214,14 @@ def resolve_build_version(version_type: str, build_type: str, version: str = "") 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"); 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 @@ -229,6 +233,25 @@ def resolve_build_version(version_type: str, build_type: str, version: str = "") 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}\"." + ) + print(f"Using manually specified version: {specified_version}") return specified_version diff --git a/test_set_version.py b/test_set_version.py index 0e3a1bf0..ed4fa212 100644 --- a/test_set_version.py +++ b/test_set_version.py @@ -146,7 +146,13 @@ 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", version) == version + # Pick a build type that matches each version so the agreement check passes. + build_type = "stable" + if ".dev" in version: + build_type = "dev" + elif ".rc" in version: + build_type = "rc" + assert resolve_build_version("patch", build_type, version) == version @pytest.mark.parametrize( @@ -171,4 +177,41 @@ def test_resolve_build_version_rejects_invalid_version(monkeypatch, version): set_version, "get_build_version", lambda *a, **k: "should-not-be-used" ) with pytest.raises(ValueError): - resolve_build_version("patch", "dev", version) \ No newline at end of file + resolve_build_version("patch", "dev", version) + + +@pytest.mark.parametrize( + "build_type,version", + [ + # Build type expects a suffix the version doesn't carry (or vice versa). + ("dev", "1.2.3"), + ("dev", "1.2.3.rc0"), + ("rc", "1.2.3"), + ("rc", "1.2.3.dev0"), + ("stable", "1.2.3.dev0"), + ("stable", "1.2.3.rc0"), + ], +) +def test_resolve_build_version_rejects_build_type_mismatch(monkeypatch, build_type, version): + # A specified version whose suffix disagrees with the build type fails loud. + monkeypatch.setattr( + set_version, "get_build_version", lambda *a, **k: "should-not-be-used" + ) + with pytest.raises(ValueError): + resolve_build_version("patch", build_type, version) + + +@pytest.mark.parametrize( + "build_type,version", + [ + ("dev", "1.2.3.dev0"), + ("rc", "1.2.3.rc7"), + ("stable", "1.2.3"), + ], +) +def test_resolve_build_version_accepts_matching_build_type(monkeypatch, build_type, version): + # A specified version whose suffix matches the build type is accepted. + monkeypatch.setattr( + set_version, "get_build_version", lambda *a, **k: "should-not-be-used" + ) + assert resolve_build_version("patch", build_type, version) == version \ No newline at end of file From db167ce08771dfee9e6eae1cb3582144b7b68e1b Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 7 Aug 2026 15:54:23 -0700 Subject: [PATCH 4/5] try validating earlier --- .ado/publish.yml | 7 ++++ set_version.py | 82 +++++++++++++++++++++++++++++++-------------- test_set_version.py | 40 +++++++++++++++++++++- 3 files changed, 103 insertions(+), 26 deletions(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index a0c8ab74..9a438d1f 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -93,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: diff --git a/set_version.py b/set_version.py index 1f7e5a6f..caddf839 100644 --- a/set_version.py +++ b/set_version.py @@ -207,6 +207,53 @@ 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. @@ -226,32 +273,8 @@ def resolve_build_version(version_type: str, build_type: str, version: str = "") :return: The version to ship. :rtype: str """ - specified_version = (version or "").strip() + specified_version = validate_specified_version(build_type, version) if specified_version: - 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}\"." - ) - print(f"Using manually specified version: {specified_version}") return specified_version @@ -259,6 +282,15 @@ def resolve_build_version(version_type: str, build_type: str, version: str = "") if __name__ == "__main__": + 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 ed4fa212..e40a1564 100644 --- a/test_set_version.py +++ b/test_set_version.py @@ -9,6 +9,7 @@ _version_sort_key, get_build_version, resolve_build_version, + validate_specified_version, VERSION_RE, ) @@ -214,4 +215,41 @@ def test_resolve_build_version_accepts_matching_build_type(monkeypatch, build_ty monkeypatch.setattr( set_version, "get_build_version", lambda *a, **k: "should-not-be-used" ) - assert resolve_build_version("patch", build_type, version) == version \ No newline at end of file + assert resolve_build_version("patch", build_type, version) == version + + +@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"), + ("dev", " 1.2.3.dev0 "), + ], +) +def test_validate_specified_version_returns_stripped(build_type, version): + # A valid version is returned stripped of surrounding whitespace. + assert validate_specified_version(build_type, version) == version.strip() + + +@pytest.mark.parametrize( + "build_type,version", + [ + ("dev", "1.2"), + ("dev", "v1.2.3"), + ("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 From d886b37518598e2c0c31a61e16c79f94fccbc54d Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Mon, 10 Aug 2026 12:45:11 -0700 Subject: [PATCH 5/5] clean up tests --- test_set_version.py | 86 +++++++-------------------------------------- 1 file changed, 13 insertions(+), 73 deletions(-) diff --git a/test_set_version.py b/test_set_version.py index e40a1564..0d40b441 100644 --- a/test_set_version.py +++ b/test_set_version.py @@ -136,86 +136,22 @@ def test_get_build_version_existing_version_raises(monkeypatch): get_build_version("patch", "stable") -@pytest.mark.parametrize( - "version", - ["1.2.3", "1.2.3.dev0", "10.20.30.rc5"], -) -def test_resolve_build_version_uses_specified_version(monkeypatch, version): - # A valid specified version is returned as-is and the automated computation is - # skipped entirely (so the package index is never contacted). +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) - # Pick a build type that matches each version so the agreement check passes. - build_type = "stable" - if ".dev" in version: - build_type = "dev" - elif ".rc" in version: - build_type = "rc" - assert resolve_build_version("patch", build_type, version) == version + assert resolve_build_version("patch", "dev", "1.2.3.dev0") == "1.2.3.dev0" -@pytest.mark.parametrize( - "blank", - ["", " ", None], -) -def test_resolve_build_version_falls_back_when_blank(monkeypatch, blank): - # A blank/whitespace/None version falls back to the automated computation. +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", blank) == "computed-minor-rc" - - -@pytest.mark.parametrize( - "version", - ["1.2", "1.2.3.4", "1.2.3.beta0", "1.2.3.dev", "v1.2.3"], -) -def test_resolve_build_version_rejects_invalid_version(monkeypatch, version): - # A malformed version fails loud rather than shipping a bad version. - monkeypatch.setattr( - set_version, "get_build_version", lambda *a, **k: "should-not-be-used" - ) - with pytest.raises(ValueError): - resolve_build_version("patch", "dev", version) - - -@pytest.mark.parametrize( - "build_type,version", - [ - # Build type expects a suffix the version doesn't carry (or vice versa). - ("dev", "1.2.3"), - ("dev", "1.2.3.rc0"), - ("rc", "1.2.3"), - ("rc", "1.2.3.dev0"), - ("stable", "1.2.3.dev0"), - ("stable", "1.2.3.rc0"), - ], -) -def test_resolve_build_version_rejects_build_type_mismatch(monkeypatch, build_type, version): - # A specified version whose suffix disagrees with the build type fails loud. - monkeypatch.setattr( - set_version, "get_build_version", lambda *a, **k: "should-not-be-used" - ) - with pytest.raises(ValueError): - resolve_build_version("patch", build_type, version) - - -@pytest.mark.parametrize( - "build_type,version", - [ - ("dev", "1.2.3.dev0"), - ("rc", "1.2.3.rc7"), - ("stable", "1.2.3"), - ], -) -def test_resolve_build_version_accepts_matching_build_type(monkeypatch, build_type, version): - # A specified version whose suffix matches the build type is accepted. - monkeypatch.setattr( - set_version, "get_build_version", lambda *a, **k: "should-not-be-used" - ) - assert resolve_build_version("patch", build_type, version) == version + assert resolve_build_version("minor", "rc", "") == "computed-minor-rc" @pytest.mark.parametrize("blank", ["", " ", None]) @@ -231,19 +167,23 @@ def test_validate_specified_version_blank_returns_empty(blank): ("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_returns_stripped(build_type, version): - # A valid version is returned stripped of surrounding whitespace. +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"),