diff --git a/set_version.py b/set_version.py index 36b55e50..67e17794 100644 --- a/set_version.py +++ b/set_version.py @@ -4,14 +4,29 @@ # Licensed under the MIT License. import os +import re +import base64 from typing import List -from urllib.request import urlopen -import json +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"] ALLOWED_BUILD_TYPES = ["stable", "rc", "dev"] PACKAGE_NAME = "azure-quantum" -PYPI_URL = f"https://pypi.python.org/pypi/{PACKAGE_NAME}/json" +# 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, +) RELEASE_TYPE = os.environ.get("RELEASE_TYPE") or "patch" @@ -62,13 +77,78 @@ 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 _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"). + + Ordering within the same major.minor.patch is: dev < rc < final. + """ + 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.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) + + +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}") + + # 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))) + + 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 package index 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 +159,43 @@ 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: + # Get all releases from the package index (the Azure Artifacts feed in CI, which + # proxies the full version list from its PyPI upstream). + 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. + if not package_versions_all: + raise RuntimeError( + 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. - # 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] + # 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: 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"Most recent version: {package_versions[0]}") + + 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 the package index. " + f"Aborting to avoid republishing an existing version." + ) - return _get_build_version(version_type, build_type, package_versions) + return build_version if __name__ == "__main__": diff --git a/test_set_version.py b/test_set_version.py index 397cf9d8..1e9ee729 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