From 3007b273c62a54de9b523709ec00d048da88dd96 Mon Sep 17 00:00:00 2001 From: Shurong Cao <170531907+CAOShurong@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:18:12 +0800 Subject: [PATCH] Fix MSBuild property for VS2019+ Current layout and instance-derived install dir The EnvironmentInfo.MSBuild property built paths as MSBuild//bin for every version >= 15.0, but since Visual Studio 2019 (v16) the on-disk layout is the version-independent MSBuild/Current/bin. Additionally, vs_ver is a lossy major.minor float (17.12 -> "17.1"), so both the registry lookup key and the default path missed real installations; on a VS2022 17.12 machine every returned path was nonexistent. - Use MSBuild/Current/bin (+Roslyn) for vs_ver >= 16.0, keep the per-version layouts for VS2015 and VS2017. - Filter candidates to directories that exist when at least one does, so consumers do not receive dead paths. - Prefer the exact installationPath recorded in the Visual Studio installer instance state (known_vs_paths) in SystemInfo.VSInstallDir, avoiding the lossy float formatting entirely. Fixes #5275 Signed-off-by: Shurong Cao <35021@users.noreply.github.com> --- setuptools/msvc.py | 25 ++++- setuptools/tests/test_msvc_msbuild.py | 143 ++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 setuptools/tests/test_msvc_msbuild.py diff --git a/setuptools/msvc.py b/setuptools/msvc.py index 7a963baf56..14e256de8a 100644 --- a/setuptools/msvc.py +++ b/setuptools/msvc.py @@ -504,6 +504,13 @@ def VSInstallDir(self) -> str: str path """ + # Prefer the exact installation path recorded by the Visual Studio + # installer instance state (VS2017+), which avoids the lossy + # major.minor float formatting of ``vs_ver`` (e.g. 17.12 -> "17.1"). + for ver, path in sorted(self.known_vs_paths.items(), reverse=True): + if int(ver) == int(self.vs_ver): + return path + # Default path default = os.path.join( self.ProgramFilesx86, f'Microsoft Visual Studio {self.vs_ver:0.1f}' @@ -1333,14 +1340,24 @@ def MSBuild(self): base_path = self.si.VSInstallDir arch_subdir = '' - path = rf'MSBuild\{self.vs_ver:0.1f}\bin{arch_subdir}' - build = [os.path.join(base_path, path)] + if self.vs_ver < 15.0: + path = rf'MSBuild\{self.vs_ver:0.1f}\bin{arch_subdir}' + build = [os.path.join(base_path, path)] + elif self.vs_ver < 16.0: + # Visual Studio 2017 keeps the per-version layout. + path = rf'MSBuild\{self.vs_ver:0.1f}\bin' + build = [os.path.join(base_path, path)] + else: + # Since Visual Studio 2019 (v16), MSBuild lives under a + # version-independent "Current" directory instead of + # "MSBuild\". + path = r'MSBuild\Current\bin' + build = [os.path.join(base_path, path)] - if self.vs_ver >= 15.0: # Add Roslyn C# & Visual Basic Compiler build += [os.path.join(base_path, path, 'Roslyn')] - return build + return [p for p in build if os.path.isdir(p)] or build @property def HTMLHelpWorkshop(self): diff --git a/setuptools/tests/test_msvc_msbuild.py b/setuptools/tests/test_msvc_msbuild.py new file mode 100644 index 0000000000..7706f9ba31 --- /dev/null +++ b/setuptools/tests/test_msvc_msbuild.py @@ -0,0 +1,143 @@ +"""Tests for the MSBuild property of EnvironmentInfo (issue #5275).""" + +import os + +import pytest + +from ..msvc import EnvironmentInfo + + +class FakePlatformInfo: + def current_dir(self, hidex86=False): + return '' if hidex86 else '\\x86' + + +class FakeRegistryInfo: + def __init__(self, pi, vs_install_dir=None): + self.pi = pi + self._vs_install_dir = vs_install_dir + self.vs = 'vs' + + def lookup(self, key, version): + return self._vs_install_dir + + +def make_env( + monkeypatch, + tmp_path, + vs_ver, + vs_install_dir=None, + known_vs_paths=None, + program_filesx86='C:\\Program Files (x86)', +): + """Build an EnvironmentInfo whose filesystem view is rooted at tmp_path.""" + + real_isdir = os.path.isdir + + def fake_isdir(path): + path = str(path) + if path.startswith('C:\\'): + # Translate synthetic absolute paths into the temp tree. + rel = path[3:] + return real_isdir(os.path.join(tmp_path, rel)) + return real_isdir(path) + + env = object.__new__(EnvironmentInfo) + fake_si = type('SI', (), {})() + fake_si.vs_ver = vs_ver + fake_si.ProgramFilesx86 = program_filesx86 + fake_si.VSInstallDir = ( + vs_install_dir + if vs_install_dir is not None + else os.path.join( + program_filesx86, f'Microsoft Visual Studio {vs_ver:0.1f}' + ) + ) + env.si = fake_si + env.known_vs_paths = dict(known_vs_paths or {}) + env.ri = FakeRegistryInfo(FakePlatformInfo()) + env.pi = env.ri.pi + monkeypatch.setattr(os.path, 'isdir', fake_isdir) + return env + + +class TestMSBuildProperty: + def test_vs2019_and_later_use_current_layout(self, monkeypatch, tmp_path): + """VS2019+ (v16+) must use the version-independent Current layout.""" + env = make_env(monkeypatch, tmp_path, 16.0) + paths = env.MSBuild + assert paths == [ + os.path.join(env.si.VSInstallDir, r'MSBuild\Current\bin'), + os.path.join(env.si.VSInstallDir, r'MSBuild\Current\bin', 'Roslyn'), + ] + + def test_vs2022_multiline_version_gets_current_layout( + self, monkeypatch, tmp_path + ): + """Regression for #5275: vs_ver like 17.12/18.7 must not fall back to a + per-version directory that never exists on VS2019+ installs.""" + env = make_env(monkeypatch, tmp_path, 17.12) + paths = env.MSBuild + assert all(r'MSBuild\17.1\bin' not in p for p in paths) + assert any(r'MSBuild\Current\bin' in p for p in paths) + + def test_vs2026_uses_current_layout(self, monkeypatch, tmp_path): + env = make_env(monkeypatch, tmp_path, 18.7) + paths = env.MSBuild + assert any(r'MSBuild\Current\bin' in p for p in paths) + assert not any(r'MSBuild\18.' in p for p in paths) + + def test_vs2015_keeps_legacy_layout(self, monkeypatch, tmp_path): + """VS2015 and earlier keep the historical per-version layout.""" + env = make_env(monkeypatch, tmp_path, 14.0) + paths = env.MSBuild + assert paths == [ + os.path.join('C:\\Program Files (x86)', r'MSBuild\14.0\bin') + ] + + def test_vs2017_keeps_per_version_layout(self, monkeypatch, tmp_path): + """VS2017 (15.x) keeps the per-version layout under VSInstallDir.""" + env = make_env(monkeypatch, tmp_path, 15.9) + paths = env.MSBuild + assert [p for p in paths if not p.endswith('\\Roslyn')] == [ + os.path.join(env.si.VSInstallDir, r'MSBuild\15.9\bin') + ] + + def test_too_old_returns_empty(self, monkeypatch, tmp_path): + env = make_env(monkeypatch, tmp_path, 11.0) + assert env.MSBuild == [] + + def test_nonexistent_candidates_are_filtered_only_if_alternatives_exist( + self, monkeypatch, tmp_path + ): + """When no candidate directory exists on disk, the raw candidates are + returned unchanged; when Current exists it wins and Roslyn follows.""" + current_bin = tmp_path / r'MSBuild\Current\bin' + current_bin.mkdir(parents=True) + + env = make_env(monkeypatch, tmp_path, 17.12, vs_install_dir='C:\\VS') + paths = env.MSBuild + assert paths == [ + 'C:\\VS\\MSBuild\\Current\\bin', + 'C:\\VS\\MSBuild\\Current\\bin\\Roslyn', + ] + + # Nothing exists -> unfiltered candidates are returned. + current_bin.rmdir() + env2 = make_env(monkeypatch, tmp_path, 17.12, vs_install_dir='C:\\VS') + assert env2.MSBuild == [ + 'C:\\VS\\MSBuild\\Current\\bin', + 'C:\\VS\\MSBuild\\Current\\bin\\Roslyn', + ] + + @pytest.mark.skipif(os.name != 'nt', reason="Windows-only behaviour") + def test_real_environment_returns_existing_paths(self): + """On a machine with a real VS install, every returned path exists.""" + try: + env = EnvironmentInfo('amd64') + except Exception: + pytest.skip("No MSVC installation detected") + if env.vs_ver < 15.0: + pytest.skip("Legacy VS layout installed") + for path in env.MSBuild: + assert os.path.isdir(path), f"Nonexistent MSBuild path: {path}"