From 08c445aaf459f34481c181b4b4170ae0b7c6170c Mon Sep 17 00:00:00 2001 From: a0x1ab <59631311+a0x1ab@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:40:39 +0000 Subject: [PATCH 1/2] [Packaging] Fix #34187: `az version`: Detect Homebrew casks with symlinked prefixes --- .../standalone/templates/az_launcher.sh.in | 32 +- .../standalone/tests/test_az_launcher.py | 290 ++++++++++++++++++ 2 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 scripts/release/standalone/tests/test_az_launcher.py diff --git a/scripts/release/standalone/templates/az_launcher.sh.in b/scripts/release/standalone/templates/az_launcher.sh.in index 8e221e321ea..0500e45102a 100644 --- a/scripts/release/standalone/templates/az_launcher.sh.in +++ b/scripts/release/standalone/templates/az_launcher.sh.in @@ -28,28 +28,28 @@ INSTALL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" AZURE_CLI_SITE_PACKAGES="$INSTALL_DIR/libexec/lib/python${PY_VER}/site-packages" -if [[ "$SCRIPT_PATH" == /opt/homebrew/Caskroom/* ]] || \ - [[ "$SCRIPT_PATH" == /usr/local/Caskroom/* ]] || \ - [[ "$SCRIPT_PATH" == /home/linuxbrew/.linuxbrew/Caskroom/* ]]; then +# Use the resolved cask location so symlinked prefixes stay paired with their Python. +CASK_DIR="${INSTALL_DIR%/*}" +if [[ "$CASK_DIR" == */Caskroom/azure-cli ]]; then INSTALLER="HOMEBREW_CASK" + BREW_PREFIX="${CASK_DIR%/Caskroom/azure-cli}" else INSTALLER="TARBALL" fi if [[ "$INSTALLER" == "HOMEBREW_CASK" ]]; then - if [[ -x "/opt/homebrew/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" ]]; then - PYTHON="/opt/homebrew/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" - elif [[ -x "/usr/local/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" ]]; then - PYTHON="/usr/local/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" - elif [[ -x "/opt/homebrew/bin/python${PY_VER}" ]]; then - PYTHON="/opt/homebrew/bin/python${PY_VER}" - elif [[ -x "/usr/local/bin/python${PY_VER}" ]]; then - PYTHON="/usr/local/bin/python${PY_VER}" - elif [[ -x "/home/linuxbrew/.linuxbrew/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" ]]; then - PYTHON="/home/linuxbrew/.linuxbrew/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" - elif [[ -x "/home/linuxbrew/.linuxbrew/bin/python${PY_VER}" ]]; then - PYTHON="/home/linuxbrew/.linuxbrew/bin/python${PY_VER}" - else + PYTHON="" + for PYTHON_CANDIDATE in \ + "$BREW_PREFIX/opt/python@${PY_VER}/libexec/bin/python" \ + "$BREW_PREFIX/opt/python@${PY_VER}/libexec/bin/${PY_BIN}" \ + "$BREW_PREFIX/opt/python@${PY_VER}/bin/python${PY_VER}" \ + "$BREW_PREFIX/bin/python${PY_VER}"; do + if [[ -x "$PYTHON_CANDIDATE" ]]; then + PYTHON="$PYTHON_CANDIDATE" + break + fi + done + if [[ -z "$PYTHON" ]]; then echo "Error: Python ${PY_VER} not found." >&2 echo "Install via Homebrew: brew install python@${PY_VER}" >&2 exit 1 diff --git a/scripts/release/standalone/tests/test_az_launcher.py b/scripts/release/standalone/tests/test_az_launcher.py new file mode 100644 index 00000000000..a6697faaf0b --- /dev/null +++ b/scripts/release/standalone/tests/test_az_launcher.py @@ -0,0 +1,290 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +_builder_spec = importlib.util.spec_from_file_location( + "standalone_build_binary_tar_gz", + Path(__file__).resolve().parents[1] / "build_binary_tar_gz.py", +) +builder = importlib.util.module_from_spec(_builder_spec) +_builder_spec.loader.exec_module(builder) + +PYTHON_VERSION = "3.14" +CASK_INSTALL_PATH = Path("Caskroom/azure-cli/2.85.0") +SITE_PACKAGES_PATH = Path(f"libexec/lib/python{PYTHON_VERSION}/site-packages") +PYTHON_CANDIDATES = ( + f"opt/python@{PYTHON_VERSION}/libexec/bin/python", + f"opt/python@{PYTHON_VERSION}/libexec/bin/python3", + f"opt/python@{PYTHON_VERSION}/bin/python{PYTHON_VERSION}", + f"bin/python{PYTHON_VERSION}", +) +MISSING_CASK_PYTHON = ( + f"Error: Python {PYTHON_VERSION} not found.\n" + f"Install via Homebrew: brew install python@{PYTHON_VERSION}\n" +) + + +@unittest.skipUnless(os.name == "posix", "The launcher requires Bash and POSIX symlinks.") +class TestAzLauncher(unittest.TestCase): + + def setUp(self): + temporary_directory = tempfile.TemporaryDirectory(prefix="az launcher ") + self.addCleanup(temporary_directory.cleanup) + self.root = Path(temporary_directory.name).resolve() + self.tools = self.root / "tools" + self.tools.mkdir() + for name in ("bash", "dirname", "readlink"): + executable = shutil.which(name) + if executable is None: + self.skipTest(f"The launcher requires {name}.") + try: + (self.tools / name).symlink_to(Path(executable).resolve()) + except (NotImplementedError, OSError) as error: + self.skipTest(f"Symlinks are unavailable: {error}") + self.env = { + "PATH": str(self.tools), + "HOME": str(self.root), + "LC_ALL": "C", + "PYTHONPATH": "inherited python path", + "AZ_INSTALLER": "inherited installer", + } + + def _install(self, install_dir): + bin_dir = install_dir / "libexec/bin" + bin_dir.mkdir(parents=True) + (install_dir / SITE_PACKAGES_PATH).mkdir(parents=True) + with contextlib.redirect_stdout(io.StringIO()): + builder._create_launcher_script(bin_dir=bin_dir, python_version=PYTHON_VERSION) + launcher = install_dir / "bin/az" + launcher.parent.mkdir() + launcher.symlink_to("../libexec/bin/az") + return launcher + + def _python(self, path, executable=True): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "#!/bin/sh\n" + "printf '%s\\0' \"$0\" \"${AZ_INSTALLER-}\" \"${PYTHONPATH-}\" \"$@\"\n" + "exit \"${STUB_EXIT_CODE:-0}\"\n", + encoding="utf-8", + ) + path.chmod(0o755 if executable else 0o644) + return path + + def _run(self, launcher, args=(), **environment): + return subprocess.run( + [str(launcher), *args], + cwd=self.root, + env={**self.env, **environment}, + capture_output=True, + check=False, + timeout=10, + ) + + def _assert_python(self, result, python, install_dir, installer="HOMEBREW_CASK", args=(), exit_code=0): + self.assertEqual(result.returncode, exit_code, result.stderr) + self.assertEqual(result.stderr, b"") + expected = (str(python), installer, str(install_dir / SITE_PACKAGES_PATH), "-sm", "azure.cli", *args) + self.assertEqual(result.stdout, b"".join(os.fsencode(value) + b"\0" for value in expected)) + + def _assert_error(self, result, message): + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.stdout, b"") + self.assertEqual(result.stderr, message.encode("utf-8")) + + def test_cask_prefix_layouts(self): + # Virtualize standard prefixes beneath the fixture root, without rewriting the template. + for layout in ("custom prefix", "opt/homebrew", "usr/local", "home/linuxbrew/.linuxbrew"): + with self.subTest(prefix=layout): + prefix = self.root / layout + install_dir = prefix / CASK_INSTALL_PATH + launcher = self._install(install_dir) + python = self._python(prefix / PYTHON_CANDIDATES[0]) + self._assert_python(self._run(launcher), python, install_dir) + + def test_relocated_cask_resolves_directory_and_launcher_symlinks(self): + for absolute in (False, True): + fixture = self.root / ("absolute links" if absolute else "relative links") + prefix = fixture / "relocated Homebrew prefix with spaces" + install_dir = prefix / CASK_INSTALL_PATH + self._install(install_dir) + cellar = prefix / f"Cellar/python@{PYTHON_VERSION}/3.14.0" + self._python(cellar / "libexec/bin/python") + formula = prefix / f"opt/python@{PYTHON_VERSION}" + formula.parent.mkdir() + formula.symlink_to(os.path.relpath(cellar, formula.parent), target_is_directory=True) + python = prefix / PYTHON_CANDIDATES[0] + + alias = fixture / "opt/homebrew" + alias.parent.mkdir(parents=True) + alias.symlink_to( + prefix if absolute else os.path.relpath(prefix, alias.parent), + target_is_directory=True, + ) + homebrew_launcher = prefix / "bin/az" + homebrew_launcher.parent.mkdir() + homebrew_launcher.symlink_to( + alias / CASK_INSTALL_PATH / "bin/az" if absolute else Path("..") / CASK_INSTALL_PATH / "bin/az" + ) + + for entry in ("bin/az", CASK_INSTALL_PATH / "libexec/bin/az"): + with self.subTest(absolute=absolute, entry=str(entry)): + self._assert_python( + self._run(alias / entry, args=("version",)), python, install_dir, args=("version",) + ) + + def test_cask_python_candidate_precedence(self): + prefix = self.root / "brew" + install_dir = prefix / CASK_INSTALL_PATH + launcher = self._install(install_dir) + candidates = [self._python(prefix / candidate) for candidate in PYTHON_CANDIDATES] + for python in candidates: + with self.subTest(candidate=python.relative_to(prefix)): + self._assert_python(self._run(launcher), python, install_dir) + python.unlink() + + def test_cask_skips_non_executable_python_candidates(self): + prefix = self.root / "brew" + install_dir = prefix / CASK_INSTALL_PATH + launcher = self._install(install_dir) + candidates = [self._python(prefix / candidate) for candidate in PYTHON_CANDIDATES] + for previous, python in zip(candidates, candidates[1:]): + previous.chmod(0o644) + with self.subTest(candidate=python.relative_to(prefix)): + self._assert_python(self._run(launcher), python, install_dir) + + def test_cask_requires_executable_python(self): + prefix = self.root / "brew" + launcher = self._install(prefix / CASK_INSTALL_PATH) + for state in ("missing", "non-executable"): + with self.subTest(python=state): + if state == "non-executable": + for candidate in PYTHON_CANDIDATES: + self._python(prefix / candidate, executable=False) + self._assert_error(self._run(launcher), MISSING_CASK_PYTHON) + + def test_cask_ignores_other_installations(self): + prefix = self.root / "own brew" + install_dir = prefix / CASK_INSTALL_PATH + launcher = self._install(install_dir) + python = self._python(prefix / PYTHON_CANDIDATES[0]) + unrelated = self.root / "opt/homebrew" + for candidate in PYTHON_CANDIDATES: + self._python(unrelated / candidate) + for name in ("python", "python3"): + self._python(unrelated / "bin" / name) + brew_log = self.root / "brew calls" + brew = unrelated / "bin/brew" + brew.write_text( + "#!/bin/sh\n" + "printf '%s\\0' \"$@\" >> \"$BREW_CALL_LOG\"\n" + f"printf '%s\\n' \"$HOMEBREW_PREFIX/opt/python@{PYTHON_VERSION}\"\n", + encoding="utf-8", + ) + brew.chmod(0o755) + environment = { + "PATH": os.pathsep.join((str(unrelated / "bin"), str(self.tools))), + "HOMEBREW_PREFIX": str(unrelated), + "AZ_PYTHON": str(unrelated / "bin/python3"), + "BREW_CALL_LOG": str(brew_log), + } + for available in (True, False): + with self.subTest(own_python_available=available): + if not available: + python.unlink() + result = self._run(launcher, **environment) + self.assertFalse(brew_log.exists(), "The launcher must not invoke brew from PATH.") + if available: + self._assert_python(result, python, install_dir) + else: + self._assert_error(result, MISSING_CASK_PYTHON) + + def test_tarball_requires_az_python(self): + launcher = self._install(self.root / "tarball with spaces") + for environment in ({}, {"AZ_PYTHON": ""}): + with self.subTest(environment=environment): + self._assert_error( + self._run(launcher, **environment), + "Error: AZ_PYTHON not set.\n" + f"For offline/tarball installs, set AZ_PYTHON to a Python {PYTHON_VERSION} path.\n", + ) + + def test_tarball_rejects_invalid_az_python(self): + launcher = self._install(self.root / "tarball") + for state in ("missing", "non-executable"): + with self.subTest(python=state): + python = self.root / f"{state} python" + if state == "non-executable": + self._python(python, executable=False) + self._assert_error( + self._run(launcher, AZ_PYTHON=str(python)), + f"Error: Python not found at {python}\n", + ) + + def test_tarball_paths_are_not_casks(self): + python = self._python(self.root / "explicit interpreter with spaces/python") + for layout in ( + "tarball with spaces", + "Caskroom/azure-cli-preview/2.85.0", + "not-Caskroom/azure-cli/2.85.0", + "Caskroom/azure-cli/2.85.0/nested", + "Caskroom/azure-cli", + ): + with self.subTest(install=layout): + install_dir = self.root / layout + launcher = self._install(install_dir) + result = self._run(launcher, AZ_PYTHON=str(python)) + self._assert_python(result, python, install_dir, installer="TARBALL") + + def test_execution_preserves_arguments_environment_and_exit_status(self): + args = ( + "group", "show", "--name", "value with spaces", "", + "'\"$HOME;`printf injected`", "$(printf injected)", "*", "a|b&c>d Date: Fri, 9 Oct 2026 09:35:59 +1100 Subject: [PATCH 2/2] [Packaging] Support Azure CLI preview Cask launchers --- .../standalone/templates/az_launcher.sh.in | 6 ++++-- .../standalone/tests/test_az_launcher.py | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/release/standalone/templates/az_launcher.sh.in b/scripts/release/standalone/templates/az_launcher.sh.in index 0500e45102a..5d87bb81be8 100644 --- a/scripts/release/standalone/templates/az_launcher.sh.in +++ b/scripts/release/standalone/templates/az_launcher.sh.in @@ -30,9 +30,11 @@ AZURE_CLI_SITE_PACKAGES="$INSTALL_DIR/libexec/lib/python${PY_VER}/site-packages" # Use the resolved cask location so symlinked prefixes stay paired with their Python. CASK_DIR="${INSTALL_DIR%/*}" -if [[ "$CASK_DIR" == */Caskroom/azure-cli ]]; then +if [[ "$CASK_DIR" == */Caskroom/azure-cli ]] || \ + [[ "$CASK_DIR" == */Caskroom/azure-cli-* ]]; then INSTALLER="HOMEBREW_CASK" - BREW_PREFIX="${CASK_DIR%/Caskroom/azure-cli}" + CASKROOM_DIR="${CASK_DIR%/*}" + BREW_PREFIX="${CASKROOM_DIR%/Caskroom}" else INSTALLER="TARBALL" fi diff --git a/scripts/release/standalone/tests/test_az_launcher.py b/scripts/release/standalone/tests/test_az_launcher.py index a6697faaf0b..04773a55c8f 100644 --- a/scripts/release/standalone/tests/test_az_launcher.py +++ b/scripts/release/standalone/tests/test_az_launcher.py @@ -23,6 +23,8 @@ PYTHON_VERSION = "3.14" CASK_INSTALL_PATH = Path("Caskroom/azure-cli/2.85.0") +PREVIEW_CASK_INSTALL_PATH = Path("Caskroom/azure-cli-preview/2.85.0") +TEST_CASK_INSTALL_PATH = Path("Caskroom/azure-cli-test-ci/2.85.0") SITE_PACKAGES_PATH = Path(f"libexec/lib/python{PYTHON_VERSION}/site-packages") PYTHON_CANDIDATES = ( f"opt/python@{PYTHON_VERSION}/libexec/bin/python", @@ -106,13 +108,14 @@ def _assert_error(self, result, message): def test_cask_prefix_layouts(self): # Virtualize standard prefixes beneath the fixture root, without rewriting the template. - for layout in ("custom prefix", "opt/homebrew", "usr/local", "home/linuxbrew/.linuxbrew"): - with self.subTest(prefix=layout): - prefix = self.root / layout - install_dir = prefix / CASK_INSTALL_PATH - launcher = self._install(install_dir) - python = self._python(prefix / PYTHON_CANDIDATES[0]) - self._assert_python(self._run(launcher), python, install_dir) + for cask_path in (CASK_INSTALL_PATH, PREVIEW_CASK_INSTALL_PATH, TEST_CASK_INSTALL_PATH): + for layout in ("custom prefix", "opt/homebrew", "usr/local", "home/linuxbrew/.linuxbrew"): + with self.subTest(cask=cask_path.parts[-2], prefix=layout): + prefix = self.root / layout + install_dir = prefix / cask_path + launcher = self._install(install_dir) + python = self._python(prefix / PYTHON_CANDIDATES[0]) + self._assert_python(self._run(launcher), python, install_dir) def test_relocated_cask_resolves_directory_and_launcher_symlinks(self): for absolute in (False, True): @@ -237,7 +240,6 @@ def test_tarball_paths_are_not_casks(self): python = self._python(self.root / "explicit interpreter with spaces/python") for layout in ( "tarball with spaces", - "Caskroom/azure-cli-preview/2.85.0", "not-Caskroom/azure-cli/2.85.0", "Caskroom/azure-cli/2.85.0/nested", "Caskroom/azure-cli",