From 41057456147f8cd4eadbe3ea1a65c3ff14d6cac8 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 24 Jul 2026 13:08:14 -0700 Subject: [PATCH 1/6] Get version from Azure instead of PyPi (this has debug in it) --- .ado/publish.yml | 2 + set_version.py | 118 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index 8c27783fa..82bda34be 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -103,6 +103,7 @@ extends: env: BUILD_TYPE: ${{ parameters.Build_Type }} RELEASE_TYPE: ${{ parameters.Release_Type }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Set "azure-quantum" package version - script: | @@ -202,6 +203,7 @@ extends: env: BUILD_TYPE: ${{ parameters.Build_Type }} RELEASE_TYPE: ${{ parameters.Release_Type }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Set "azure-quantum" package version - task: CopyFiles@2 diff --git a/set_version.py b/set_version.py index 36b55e50e..6e7396495 100644 --- a/set_version.py +++ b/set_version.py @@ -5,13 +5,30 @@ import os from typing import List -from urllib.request import urlopen +from urllib.request import urlopen, Request +from urllib.error import HTTPError, URLError import json ALLOWED_RELEASE_TYPES = ["major", "minor", "patch"] ALLOWED_BUILD_TYPES = ["stable", "rc", "dev"] PACKAGE_NAME = "azure-quantum" -PYPI_URL = f"https://pypi.python.org/pypi/{PACKAGE_NAME}/json" + +# Published versions are read from the Azure Artifacts feed's Packaging REST API +# rather than directly from PyPI, so the build complies with the CFS network-isolation +# policy (only Azure DevOps hosts are reachable from the agent). The feed mirrors the +# full azure-quantum version history from its PyPI upstream. The request is authorized +# with the build identity's OAuth token, provided via the SYSTEM_ACCESSTOKEN env var +# (the pipeline maps $(System.AccessToken) into the step). The org/project/feed can be +# overridden via env vars for other environments. +FEED_ORG = os.environ.get("AZURE_ARTIFACTS_ORG") or "ms-azurequantum" +FEED_PROJECT = os.environ.get("AZURE_ARTIFACTS_PROJECT") or "AzureQuantum" +FEED_NAME = os.environ.get("AZURE_ARTIFACTS_FEED") or "azure-quantum" +FEED_PACKAGES_URL = ( + f"https://feeds.dev.azure.com/{FEED_ORG}/{FEED_PROJECT}" + f"/_apis/packaging/Feeds/{FEED_NAME}/packages" + f"?protocolType=PyPi&packageNameQuery={PACKAGE_NAME}" + f"&includeAllVersions=true&api-version=7.1-preview.1" +) RELEASE_TYPE = os.environ.get("RELEASE_TYPE") or "patch" @@ -62,13 +79,66 @@ def _get_build_version(version_type: str, build_type: str, package_versions: Lis raise RuntimeError(f"Build version could not be determined for version type \"{version_type}\" and build type \"{build_type}\"") +def _fetch_feed_versions() -> List[str]: + """Fetch all published versions of the package from the Azure Artifacts feed's + Packaging REST API, newest first. + + The request is authorized with the build identity's OAuth token, read from the + SYSTEM_ACCESSTOKEN environment variable. The token is never printed. + """ + token = os.environ.get("SYSTEM_ACCESSTOKEN") + if not token: + raise RuntimeError( + "The SYSTEM_ACCESSTOKEN environment variable is not set. The pipeline must " + "map the build identity's OAuth access token into this step's env so the " + "script can authenticate to the Azure Artifacts feed." + ) + + # The token is added via add_header (never interpolated into a URL, log line, or + # error message) so it cannot appear in output or tracebacks. + request = Request(FEED_PACKAGES_URL) + request.add_header("Authorization", "Bearer " + token) + try: + with urlopen(request) as response: + if response.status != 200: + raise RuntimeError(f"Request \"GET:{FEED_PACKAGES_URL}\" failed. Status code: \"{response.status}\"") + data = json.loads(response.read().decode("utf-8")) + except HTTPError as error: + # Only the status code is surfaced; the response body/headers are not echoed. + raise RuntimeError(f"Request \"GET:{FEED_PACKAGES_URL}\" failed. Status code: \"{error.code}\"") from None + except URLError as error: + raise RuntimeError(f"Request \"GET:{FEED_PACKAGES_URL}\" failed: {error.reason}") from None + + versions: List[str] = [] + for package in data.get("value", []): + # packageNameQuery is a substring match, so confirm we have the exact package. + if str(package.get("name", "")).lower() != PACKAGE_NAME.lower(): + continue + # Sort by publish date (newest first) to match the previous PyPI ordering, so + # _get_build_version finds the most recently released stable version first. + package_versions_sorted = sorted( + package.get("versions", []), + key=lambda version: version.get("publishDate", ""), + reverse=True, + ) + versions = [version["version"] for version in package_versions_sorted] + break + + # Diagnostic output to confirm the feed returns the full published history. The + # OAuth token is never included. (Safe to remove once verified in the pipeline.) + print(f"Retrieved {len(versions)} version(s) of \"{PACKAGE_NAME}\" from feed \"{FEED_NAME}\".") + print(f"Versions (newest first): {versions}") + + return versions + + def get_build_version(version_type: str, build_type: str) -> str: - """Get build version by analysing released versions in PyPi and figuring out the next version. + """Get build version by analyzing released versions in the Azure Artifacts feed and figuring out the next version. Example: - - If the last stable version in PyPi was "1.1.0" and version_type = "major" and build_type = "stable", then returned version will be "2.0.0". - - If the last stable version in PyPi was "1.1.0" and the last dev version was "1.2.0.dev0" and version_type = "patch" and build_type = "dev", + - If the last stable version was "1.1.0" and version_type = "major" and build_type = "stable", then returned version will be "2.0.0". + - If the last stable version was "1.1.0" and the last dev version was "1.2.0.dev0" and version_type = "patch" and build_type = "dev", then returned version will be "1.1.1.dev0". - - If the last stable version in PyPi was "1.1.0" and the last dev version was "1.2.0.dev0" and version_type = "minor" and build_type = "dev", + - If the last stable version was "1.1.0" and the last dev version was "1.2.0.dev0" and version_type = "minor" and build_type = "dev", then returned version will be "1.2.0.dev1". :param version_type: SYMVER type ("major"/"minor"/"patch") @@ -79,23 +149,29 @@ def get_build_version(version_type: str, build_type: str) -> str: :rtype: str """ - # get all releases from PyPi - with urlopen(PYPI_URL) as response: - if response.status == 200: - response_content = response.read() - response = json.loads(response_content.decode("utf-8")) - else: - raise RuntimeError(f"Request \"GET:{PYPI_URL}\" failed. Status code: \"{response.status}\"") - - # Note: assuming versions are SYMVER (major.minor.patch[.dev0|.rc0]) and in chronological order: + # Note: assuming versions are SYMVER (major.minor.patch[.dev0|.rc0]). # "1.0.0", "1.0.1", "1.1.0", "1.1.0.dev0", "1.1.0.dev1", "1.1.0.rc0" # The next "rc" and "dev" version must follow the last "stable" version. - - # sorting by time in reverse order to find the last releases, so we could assume the next version of certain "build_type" - package_versions_sorted = sorted(response["releases"].items(), key=lambda k: k[1][0]["upload_time_iso_8601"], reverse=True) - package_versions = [version[0] for version in package_versions_sorted] - - return _get_build_version(version_type, build_type, package_versions) + package_versions = _fetch_feed_versions() + + # Guard: refuse to compute a version from an empty list. That would silently + # produce a low version number that likely collides with an existing release. + if not package_versions: + raise RuntimeError( + f"No published versions of \"{PACKAGE_NAME}\" were returned by feed \"{FEED_NAME}\". " + f"Refusing to compute a version from an empty list." + ) + + build_version = _get_build_version(version_type, build_type, package_versions) + + # Guard: never hand back a version that already exists in the published list. + if build_version in package_versions: + raise RuntimeError( + f"Computed version \"{build_version}\" already exists in feed \"{FEED_NAME}\". " + f"Aborting to avoid republishing an existing version." + ) + + return build_version if __name__ == "__main__": From 3dd372cfba6aec19aeaa4bf9ba3708d3fb4fe08d Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 24 Jul 2026 13:45:33 -0700 Subject: [PATCH 2/6] try to use PEP 503 HTML parsing instead (this has debug in it) --- .ado/publish.yml | 2 - set_version.py | 156 +++++++++++++++++++++++++---------------------- 2 files changed, 83 insertions(+), 75 deletions(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index 82bda34be..8c27783fa 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -103,7 +103,6 @@ extends: env: BUILD_TYPE: ${{ parameters.Build_Type }} RELEASE_TYPE: ${{ parameters.Release_Type }} - SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Set "azure-quantum" package version - script: | @@ -203,7 +202,6 @@ extends: env: BUILD_TYPE: ${{ parameters.Build_Type }} RELEASE_TYPE: ${{ parameters.Release_Type }} - SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Set "azure-quantum" package version - task: CopyFiles@2 diff --git a/set_version.py b/set_version.py index 6e7396495..05cc1c241 100644 --- a/set_version.py +++ b/set_version.py @@ -4,30 +4,27 @@ # Licensed under the MIT License. import os +import re +import base64 from typing import List from urllib.request import urlopen, Request -from urllib.error import HTTPError, URLError -import json +from urllib.parse import urlsplit, urlunsplit, unquote ALLOWED_RELEASE_TYPES = ["major", "minor", "patch"] ALLOWED_BUILD_TYPES = ["stable", "rc", "dev"] PACKAGE_NAME = "azure-quantum" - -# Published versions are read from the Azure Artifacts feed's Packaging REST API -# rather than directly from PyPI, so the build complies with the CFS network-isolation -# policy (only Azure DevOps hosts are reachable from the agent). The feed mirrors the -# full azure-quantum version history from its PyPI upstream. The request is authorized -# with the build identity's OAuth token, provided via the SYSTEM_ACCESSTOKEN env var -# (the pipeline maps $(System.AccessToken) into the step). The org/project/feed can be -# overridden via env vars for other environments. -FEED_ORG = os.environ.get("AZURE_ARTIFACTS_ORG") or "ms-azurequantum" -FEED_PROJECT = os.environ.get("AZURE_ARTIFACTS_PROJECT") or "AzureQuantum" -FEED_NAME = os.environ.get("AZURE_ARTIFACTS_FEED") or "azure-quantum" -FEED_PACKAGES_URL = ( - f"https://feeds.dev.azure.com/{FEED_ORG}/{FEED_PROJECT}" - f"/_apis/packaging/Feeds/{FEED_NAME}/packages" - f"?protocolType=PyPi&packageNameQuery={PACKAGE_NAME}" - f"&includeAllVersions=true&api-version=7.1-preview.1" +# Public PyPI simple-index fallback, used only for local runs where PIP_INDEX_URL is +# not set. In CI, PipAuthenticate@1 sets PIP_INDEX_URL to the Azure Artifacts feed so +# this script never contacts pypi.org directly (network-isolation / CFS policy). +DEFAULT_INDEX_URL = "https://pypi.org/simple" +# Matches distribution filenames like "azure_quantum-1.2.3-..." or +# "azure-quantum-1.2.3.dev0.tar.gz", so only this package's own versions are captured +# and never a version-like token appearing elsewhere on the index page. The trailing +# boundary (archive suffix or the wheel tag separator) ensures unexpected version +# formats (e.g. legacy 4-part "0.11.2004.2825" or "...b1") are skipped, not truncated. +VERSION_RE = re.compile( + r"azure[-_]quantum-(\d+\.\d+\.\d+(?:\.(?:dev|rc)\d+)?)(?:-|\.tar\.gz|\.zip)", + re.IGNORECASE, ) @@ -79,61 +76,62 @@ def _get_build_version(version_type: str, build_type: str, package_versions: Lis raise RuntimeError(f"Build version could not be determined for version type \"{version_type}\" and build type \"{build_type}\"") -def _fetch_feed_versions() -> List[str]: - """Fetch all published versions of the package from the Azure Artifacts feed's - Packaging REST API, newest first. +def _version_sort_key(version: str): + """Return a tuple that orders versions per PEP 440 for the subset used here + (major.minor.patch optionally followed by ".devN" or ".rcN"). - The request is authorized with the build identity's OAuth token, read from the - SYSTEM_ACCESSTOKEN environment variable. The token is never printed. + Ordering within the same major.minor.patch is: dev < rc < final. """ - token = os.environ.get("SYSTEM_ACCESSTOKEN") - if not token: - raise RuntimeError( - "The SYSTEM_ACCESSTOKEN environment variable is not set. The pipeline must " - "map the build identity's OAuth access token into this step's env so the " - "script can authenticate to the Azure Artifacts feed." - ) + parts = version.split(".") + major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) + if len(parts) == 3: + # A final release sorts after any pre-release of the same number. + phase, suffix_num = 2, 0 + else: + match = re.match(r"(dev|rc)(\d+)", parts[3]) + phase = 0 if match.group(1) == "dev" else 1 + suffix_num = int(match.group(2)) + return (major, minor, patch, phase, suffix_num) - # The token is added via add_header (never interpolated into a URL, log line, or - # error message) so it cannot appear in output or tracebacks. - request = Request(FEED_PACKAGES_URL) - request.add_header("Authorization", "Bearer " + token) - try: - with urlopen(request) as response: - if response.status != 200: - raise RuntimeError(f"Request \"GET:{FEED_PACKAGES_URL}\" failed. Status code: \"{response.status}\"") - data = json.loads(response.read().decode("utf-8")) - except HTTPError as error: - # Only the status code is surfaced; the response body/headers are not echoed. - raise RuntimeError(f"Request \"GET:{FEED_PACKAGES_URL}\" failed. Status code: \"{error.code}\"") from None - except URLError as error: - raise RuntimeError(f"Request \"GET:{FEED_PACKAGES_URL}\" failed: {error.reason}") from None - - versions: List[str] = [] - for package in data.get("value", []): - # packageNameQuery is a substring match, so confirm we have the exact package. - if str(package.get("name", "")).lower() != PACKAGE_NAME.lower(): - continue - # Sort by publish date (newest first) to match the previous PyPI ordering, so - # _get_build_version finds the most recently released stable version first. - package_versions_sorted = sorted( - package.get("versions", []), - key=lambda version: version.get("publishDate", ""), - reverse=True, - ) - versions = [version["version"] for version in package_versions_sorted] - break - # Diagnostic output to confirm the feed returns the full published history. The - # OAuth token is never included. (Safe to remove once verified in the pipeline.) - print(f"Retrieved {len(versions)} version(s) of \"{PACKAGE_NAME}\" from feed \"{FEED_NAME}\".") - print(f"Versions (newest first): {versions}") +def _get_index_url() -> str: + """Build the PEP 503 simple-index URL for the package. + + Uses PIP_INDEX_URL (set by PipAuthenticate@1 to the Azure Artifacts feed in CI) + and falls back to public PyPI for local runs where it is not set. + """ + index = os.environ.get("PIP_INDEX_URL") or DEFAULT_INDEX_URL + return index.rstrip("/") + "/" + PACKAGE_NAME + "/" + + +def _fetch_versions(index_url: str) -> List[str]: + """Fetch all published versions of the package from a PEP 503 simple index. + + Credentials embedded in the index URL (as PipAuthenticate@1 provides) are moved + into an Authorization header, since urllib does not use URL userinfo directly. + """ + parsed = urlsplit(index_url) + netloc = parsed.hostname or "" + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + sanitized_url = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, "")) + + request = Request(sanitized_url) + if parsed.username: + credentials = f"{unquote(parsed.username)}:{unquote(parsed.password or '')}" + encoded = base64.b64encode(credentials.encode("utf-8")).decode("ascii") + request.add_header("Authorization", f"Basic {encoded}") + + with urlopen(request) as response: + if response.status != 200: + raise RuntimeError(f"Request \"GET:{sanitized_url}\" failed. Status code: \"{response.status}\"") + html = response.read().decode("utf-8") - return versions + return list(set(VERSION_RE.findall(html))) def get_build_version(version_type: str, build_type: str) -> str: - """Get build version by analyzing released versions in the Azure Artifacts feed and figuring out the next version. + """Get build version by analyzing released versions in the package index and figuring out the next version. Example: - If the last stable version was "1.1.0" and version_type = "major" and build_type = "stable", then returned version will be "2.0.0". - If the last stable version was "1.1.0" and the last dev version was "1.2.0.dev0" and version_type = "patch" and build_type = "dev", @@ -149,25 +147,37 @@ def get_build_version(version_type: str, build_type: str) -> str: :rtype: str """ - # Note: assuming versions are SYMVER (major.minor.patch[.dev0|.rc0]). - # "1.0.0", "1.0.1", "1.1.0", "1.1.0.dev0", "1.1.0.dev1", "1.1.0.rc0" - # The next "rc" and "dev" version must follow the last "stable" version. - package_versions = _fetch_feed_versions() + # Get all releases from the package index (the Azure Artifacts feed in CI, which + # proxies the full version list from its PyPI upstream). + package_versions_all = _fetch_versions(_get_index_url()) # Guard: refuse to compute a version from an empty list. That would silently # produce a low version number that likely collides with an existing release. - if not package_versions: + if not package_versions_all: raise RuntimeError( - f"No published versions of \"{PACKAGE_NAME}\" were returned by feed \"{FEED_NAME}\". " - f"Refusing to compute a version from an empty list." + f"No published versions of \"{PACKAGE_NAME}\" were returned from the package " + f"index. Refusing to compute a version from an empty list." ) + # Note: assuming versions are SYMVER (major.minor.patch[.dev0|.rc0]). + # "1.0.0", "1.0.1", "1.1.0", "1.1.0.dev0", "1.1.0.dev1", "1.1.0.rc0" + # The next "rc" and "dev" version must follow the last "stable" version. + + # Sort by version (descending) so the most recent releases come first, which is + # the ordering _get_build_version expects. + package_versions = sorted(package_versions_all, key=_version_sort_key, reverse=True) + + # Diagnostic output to confirm the index returns the full published history. + # (Safe to remove once verified in the pipeline.) + print(f"Retrieved {len(package_versions)} version(s) of \"{PACKAGE_NAME}\" from the package index.") + print(f"Versions (newest first): {package_versions}") + build_version = _get_build_version(version_type, build_type, package_versions) # Guard: never hand back a version that already exists in the published list. if build_version in package_versions: raise RuntimeError( - f"Computed version \"{build_version}\" already exists in feed \"{FEED_NAME}\". " + f"Computed version \"{build_version}\" already exists in the package index. " f"Aborting to avoid republishing an existing version." ) From a4a12eb885a6cecc981d47992a16f7ca7393696b Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 24 Jul 2026 14:14:38 -0700 Subject: [PATCH 3/6] update diagnostic info printed in logs --- set_version.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/set_version.py b/set_version.py index 05cc1c241..159040f1d 100644 --- a/set_version.py +++ b/set_version.py @@ -149,7 +149,8 @@ def get_build_version(version_type: str, build_type: str) -> str: # Get all releases from the package index (the Azure Artifacts feed in CI, which # proxies the full version list from its PyPI upstream). - package_versions_all = _fetch_versions(_get_index_url()) + index_url = _get_index_url() + package_versions_all = _fetch_versions(index_url) # Guard: refuse to compute a version from an empty list. That would silently # produce a low version number that likely collides with an existing release. @@ -167,10 +168,11 @@ def get_build_version(version_type: str, build_type: str) -> str: # the ordering _get_build_version expects. package_versions = sorted(package_versions_all, key=_version_sort_key, reverse=True) - # Diagnostic output to confirm the index returns the full published history. - # (Safe to remove once verified in the pipeline.) + # Diagnostic output: the index host, the number of versions found, and the most + # recent one, to confirm the index returned a sane published history. + print(f"Package index host: {urlsplit(index_url).hostname}") print(f"Retrieved {len(package_versions)} version(s) of \"{PACKAGE_NAME}\" from the package index.") - print(f"Versions (newest first): {package_versions}") + print(f"Most recent version: {package_versions[0]}") build_version = _get_build_version(version_type, build_type, package_versions) From 8c58f4e92392d50b3546a6ad21aeb13efc27ea24 Mon Sep 17 00:00:00 2001 From: Scott Carda <55811729+ScottCarda-MS@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:31:18 -0700 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- set_version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/set_version.py b/set_version.py index 159040f1d..3f3161b37 100644 --- a/set_version.py +++ b/set_version.py @@ -88,7 +88,11 @@ def _version_sort_key(version: str): # A final release sorts after any pre-release of the same number. phase, suffix_num = 2, 0 else: - match = re.match(r"(dev|rc)(\d+)", parts[3]) + match = re.fullmatch(r"(dev|rc)(\d+)", parts[3]) + if not match: + raise ValueError( + f"Unsupported pre-release segment in version '{version}'. Expected '.devN' or '.rcN'." + ) phase = 0 if match.group(1) == "dev" else 1 suffix_num = int(match.group(2)) return (major, minor, patch, phase, suffix_num) From ff9e8652c63bc36416a4d2f4cfb0550184fda0bf Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 24 Jul 2026 14:48:16 -0700 Subject: [PATCH 5/6] add tests --- test_set_version.py | 108 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/test_set_version.py b/test_set_version.py index 397cf9d89..1e9ee7293 100644 --- a/test_set_version.py +++ b/test_set_version.py @@ -1,7 +1,15 @@ # Tests for "set_version.py" module. # !! Don't forget to run this test in case you change "set_version.py" to make sure the asserts still pass !! -from set_version import _get_build_version +import pytest + +import set_version +from set_version import ( + _get_build_version, + _version_sort_key, + get_build_version, + VERSION_RE, +) def test_set_version(): assert "1.0.0" == _get_build_version("major", "stable", []) @@ -27,4 +35,100 @@ def test_set_version(): assert "0.2.0.dev0" == _get_build_version("minor", "dev", ["1.0.0.rc0", "1.0.0.dev0", "0.1.0", "0.0.1"]) assert "0.1.2.dev0" == _get_build_version("patch", "dev", ["0.1.1", "0.0.1"]) assert "0.1.1.dev0" == _get_build_version("patch", "dev", ["1.0.0.rc0", "0.1.0.dev0", "0.1.0", "0.0.1"]) - assert "0.1.1.dev1" == _get_build_version("patch", "dev", ["1.0.0.rc0", "0.1.1.dev0", "0.1.0", "0.0.1"]) \ No newline at end of file + assert "0.1.1.dev1" == _get_build_version("patch", "dev", ["1.0.0.rc0", "0.1.1.dev0", "0.1.0", "0.0.1"]) + + +def test_version_regex_matches_wheel_and_sdist_filenames(): + # Representative distribution filenames as they appear on a PEP 503 simple index, + # covering wheels, sdists (.tar.gz / .zip), the "-"/"_" name normalization, and + # the .devN / .rcN pre-release suffixes. + html = ( + 'azure_quantum-3.10.0-py3-none-any.whl' + 'azure-quantum-3.10.0.tar.gz' + 'azure_quantum-1.1.0.dev0-py3-none-any.whl' + 'azure-quantum-2.0.0.rc0.tar.gz' + 'azure-quantum-1.0.0.zip' + ) + assert sorted(set(VERSION_RE.findall(html))) == [ + "1.0.0", + "1.1.0.dev0", + "2.0.0.rc0", + "3.10.0", + ] + + +def test_version_regex_skips_unsupported_filenames(): + # Legacy 4-part and alpha/beta ("bN") versions must be skipped entirely rather + # than silently truncated to a bogus 3-part version, and unrelated tokens on the + # page must never match. + html = ( + 'azure_quantum-0.11.2004.2825-py3-none-any.whl' + 'azure-quantum-0.11.2004.2825.tar.gz' + 'azure_quantum-0.13.2011.119705b1-py3-none-any.whl' + 'some-other-package-9.9.9-py3-none-any.whl' + '1.2.3' + ) + assert VERSION_RE.findall(html) == [] + + +def test_version_sort_key_orders_prereleases_before_final(): + # Within the same major.minor.patch: dev < rc < final, with numeric (not + # lexical) ordering of the pre-release number. + unsorted = [ + "1.1.0", + "1.1.0.rc0", + "1.1.0.dev10", + "1.1.0.dev2", + "1.0.0", + ] + assert sorted(unsorted, key=_version_sort_key) == [ + "1.0.0", + "1.1.0.dev2", + "1.1.0.dev10", + "1.1.0.rc0", + "1.1.0", + ] + + +def test_version_sort_key_rejects_unsupported_segment(): + with pytest.raises(ValueError): + _version_sort_key("0.13.2011.119705b1") + + +def test_get_build_version_sorts_before_selecting(monkeypatch): + # _fetch_versions returns versions in arbitrary order; get_build_version must + # sort them (descending) before picking the latest stable to bump from. If the + # sort were skipped, the first 3-part entry ("1.0.0") would be chosen instead of + # the true latest stable ("1.1.0"). + unsorted = [ + "1.0.0.dev0", + "1.0.0", + "1.1.0", + "1.0.0.rc0", + "2.0.0.dev1", + "1.1.0.dev0", + ] + monkeypatch.setattr(set_version, "_fetch_versions", lambda index_url: list(unsorted)) + + # The latest *stable* (3-part final) version is 1.1.0. 2.0.0 exists only as a + # pre-release ("2.0.0.dev1"), so it is not a release that can be patched from. + assert get_build_version("patch", "stable") == "1.1.1" + assert get_build_version("patch", "dev") == "1.1.1.dev0" + assert get_build_version("minor", "dev") == "1.2.0.dev0" + # 2.0.0.dev1 exists but 2.0.0.dev0 does not, so dev0 is the next major dev build. + assert get_build_version("major", "dev") == "2.0.0.dev0" + + +def test_get_build_version_empty_list_raises(monkeypatch): + monkeypatch.setattr(set_version, "_fetch_versions", lambda index_url: []) + with pytest.raises(RuntimeError): + get_build_version("patch", "dev") + + +def test_get_build_version_existing_version_raises(monkeypatch): + # Guard: if the computed version already exists in the published list, abort + # rather than risk republishing an existing version. + 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 From 031c460ee85c4a6cda06122d2e7a8f49da1ba908 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Fri, 24 Jul 2026 14:52:43 -0700 Subject: [PATCH 6/6] better urlopen handling --- set_version.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/set_version.py b/set_version.py index 3f3161b37..67e177944 100644 --- a/set_version.py +++ b/set_version.py @@ -8,6 +8,7 @@ import base64 from typing import List from urllib.request import urlopen, Request +from urllib.error import HTTPError, URLError from urllib.parse import urlsplit, urlunsplit, unquote ALLOWED_RELEASE_TYPES = ["major", "minor", "patch"] @@ -126,10 +127,17 @@ def _fetch_versions(index_url: str) -> List[str]: encoded = base64.b64encode(credentials.encode("utf-8")).decode("ascii") request.add_header("Authorization", f"Basic {encoded}") - with urlopen(request) as response: - if response.status != 200: - raise RuntimeError(f"Request \"GET:{sanitized_url}\" failed. Status code: \"{response.status}\"") - html = response.read().decode("utf-8") + # urlopen raises HTTPError for non-2xx responses and URLError for connection + # problems, so failures surface as exceptions rather than a checkable status. + # Re-raise as a RuntimeError with the sanitized URL (never the credentialed one) + # and the status/reason for a clear, consistent message. + try: + with urlopen(request) as response: + html = response.read().decode("utf-8") + except HTTPError as error: + raise RuntimeError(f"Request \"GET:{sanitized_url}\" failed. Status code: \"{error.code}\"") from None + except URLError as error: + raise RuntimeError(f"Request \"GET:{sanitized_url}\" failed. Reason: \"{error.reason}\"") from None return list(set(VERSION_RE.findall(html)))