Source code
Revision control
Copy as Markdown
Other Tools
#!/usr/bin/python3
#
# Copyright (c) 2019 Martin Storsjo
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
import argparse
import functools
import hashlib
import os
import multiprocessing.pool
try:
import simplejson
except ModuleNotFoundError:
import json as simplejson
import six
import shutil
import socket
import subprocess
import sys
import tempfile
import zipfile
def getArgsParser():
parser = argparse.ArgumentParser(description = "Download and install Visual Studio")
parser.add_argument("--manifest", metavar="manifest", help="A predownloaded manifest file")
parser.add_argument("--save-manifest", const=True, action="store_const", help="Store the downloaded manifest to a file")
parser.add_argument("--major", default=17, metavar="version", help="The major version to download (defaults to 17)")
parser.add_argument("--preview", dest="type", default="release", const="pre", action="store_const", help="Download the preview version instead of the release version")
parser.add_argument("--cache", metavar="dir", help="Directory to use as a persistent cache for downloaded files")
parser.add_argument("--dest", metavar="dir", help="Directory to install into")
parser.add_argument("package", metavar="package", help="Package to install. If omitted, installs the default command line tools.", nargs="*")
parser.add_argument("--ignore", metavar="component", help="Package to skip", action="append")
parser.add_argument("--accept-license", const=True, action="store_const", help="Don't prompt for accepting the license")
parser.add_argument("--print-version", const=True, action="store_const", help="Stop after fetching the manifest")
parser.add_argument("--list-workloads", const=True, action="store_const", help="List high level workloads")
parser.add_argument("--list-components", const=True, action="store_const", help="List available components")
parser.add_argument("--list-packages", const=True, action="store_const", help="List all individual packages, regardless of type")
parser.add_argument("--include-optional", const=True, action="store_const", help="Include all optional dependencies")
parser.add_argument("--skip-recommended", const=True, action="store_const", help="Don't include recommended dependencies")
parser.add_argument("--print-deps-tree", const=True, action="store_const", help="Print a tree of resolved dependencies for the given selection")
parser.add_argument("--print-reverse-deps", const=True, action="store_const", help="Print a tree of packages that depend on the given selection")
parser.add_argument("--print-selection", const=True, action="store_const", help="Print a list of the individual packages that are selected to be installed")
parser.add_argument("--only-download", const=True, action="store_const", help="Stop after downloading package files")
parser.add_argument("--only-unpack", const=True, action="store_const", help="Unpack the selected packages and keep all files, in the layout they are unpacked, don't restructure and prune files other than what's needed for MSVC CLI tools")
parser.add_argument("--keep-unpack", const=True, action="store_const", help="Keep the unpacked files that aren't otherwise selected as needed output")
parser.add_argument("--msvc-version", metavar="version", help="Install a specific MSVC toolchain version")
parser.add_argument("--sdk-version", metavar="version", help="Install a specific Windows SDK version")
return parser
def setPackageSelectionMSVC16(args, packages, userversion, sdk, toolversion, defaultPackages):
if findPackage(packages, "Microsoft.VisualStudio.Component.VC." + toolversion + ".x86.x64", None, warn=False):
args.package.extend(["Win10SDK_" + sdk, "Microsoft.VisualStudio.Component.VC." + toolversion + ".x86.x64", "Microsoft.VisualStudio.Component.VC." + toolversion + ".ARM", "Microsoft.VisualStudio.Component.VC." + toolversion + ".ARM64"])
else:
# Options for toolchains for specific versions. The latest version in
# each manifest isn't available as a pinned version though, so if that
# version is requested, try the default version.
print("Didn't find exact version packages for " + userversion + ", assuming this is provided by the default/latest version")
args.package.extend(defaultPackages)
def setPackageSelectionMSVC15(args, packages, userversion, sdk, toolversion, defaultPackages):
if findPackage(packages, "Microsoft.VisualStudio.Component.VC.Tools." + toolversion, None, warn=False):
args.package.extend(["Win10SDK_" + sdk, "Microsoft.VisualStudio.Component.VC.Tools." + toolversion])
else:
# Options for toolchains for specific versions. The latest version in
# each manifest isn't available as a pinned version though, so if that
# version is requested, try the default version.
print("Didn't find exact version packages for " + userversion + ", assuming this is provided by the default/latest version")
args.package.extend(defaultPackages)
def setPackageSelection(args, packages):
# If no packages are selected, install these versionless packages, which
# gives the latest/recommended version for the current manifest.
defaultPackages = ["Microsoft.VisualStudio.Workload.VCTools", "Microsoft.VisualStudio.Component.VC.Tools.ARM", "Microsoft.VisualStudio.Component.VC.Tools.ARM64"]
# Note, that in the manifest for MSVC version X.Y, only version X.Y-1
# exists with a package name like "Microsoft.VisualStudio.Component.VC."
# + toolversion + ".x86.x64".
if args.msvc_version == "16.0":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.17763", "14.20", defaultPackages)
elif args.msvc_version == "16.1":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.21", defaultPackages)
elif args.msvc_version == "16.2":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.22", defaultPackages)
elif args.msvc_version == "16.3":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.23", defaultPackages)
elif args.msvc_version == "16.4":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.24", defaultPackages)
elif args.msvc_version == "16.5":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.25", defaultPackages)
elif args.msvc_version == "16.6":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.26", defaultPackages)
elif args.msvc_version == "16.7":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.27", defaultPackages)
elif args.msvc_version == "16.8":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.18362", "14.28", defaultPackages)
elif args.msvc_version == "16.9":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.19041", "14.28.16.9", defaultPackages)
elif args.msvc_version == "16.10":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.19041", "14.29.16.10", defaultPackages)
elif args.msvc_version == "16.11":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.19041", "14.29.16.11", defaultPackages)
elif args.msvc_version == "17.0":
setPackageSelectionMSVC16(args, packages, args.msvc_version, "10.0.19041", "14.30.17.0", defaultPackages)
elif args.msvc_version == "15.4":
setPackageSelectionMSVC15(args, packages, args.msvc_version, "10.0.16299", "14.11", defaultPackages)
elif args.msvc_version == "15.5":
setPackageSelectionMSVC15(args, packages, args.msvc_version, "10.0.16299", "14.12", defaultPackages)
elif args.msvc_version == "15.6":
setPackageSelectionMSVC15(args, packages, args.msvc_version, "10.0.16299", "14.13", defaultPackages)
elif args.msvc_version == "15.7":
setPackageSelectionMSVC15(args, packages, args.msvc_version, "10.0.17134", "14.14", defaultPackages)
elif args.msvc_version == "15.8":
setPackageSelectionMSVC15(args, packages, args.msvc_version, "10.0.17134", "14.15", defaultPackages)
elif args.msvc_version == "15.9":
setPackageSelectionMSVC15(args, packages, args.msvc_version, "10.0.17763", "14.16", defaultPackages)
elif args.msvc_version != None:
print("Unsupported MSVC toolchain version " + args.msvc_version)
sys.exit(1)
if len(args.package) == 0:
args.package = defaultPackages
if args.sdk_version != None:
for key in packages:
if key.startswith("win10sdk") or key.startswith("win11sdk"):
base = key[0:8]
sdkname = base + "_" + args.sdk_version
if key == sdkname:
args.package.append(key)
else:
args.ignore.append(key)
p = packages[key][0]
def lowercaseIgnores(args):
ignore = []
if args.ignore != None:
for i in args.ignore:
ignore.append(i.lower())
args.ignore = ignore
def getManifest(args):
if args.manifest == None:
print("Fetching %s" % (url))
manifest = simplejson.loads(six.moves.urllib.request.urlopen(url).read())
print("Got toplevel manifest for %s" % (manifest["info"]["productDisplayVersion"]))
for item in manifest["channelItems"]:
if "type" in item and item["type"] == "Manifest":
args.manifest = item["payloads"][0]["url"]
if args.manifest == None:
print("Unable to find an intaller manifest!")
sys.exit(1)
if not args.manifest.startswith("http"):
args.manifest = "file:" + args.manifest
manifestdata = six.moves.urllib.request.urlopen(args.manifest).read()
manifest = simplejson.loads(manifestdata)
print("Loaded installer manifest for %s" % (manifest["info"]["productDisplayVersion"]))
if args.save_manifest:
filename = "%s.manifest" % (manifest["info"]["productDisplayVersion"])
if os.path.isfile(filename):
oldfile = open(filename, "rb").read()
if oldfile != manifestdata:
print("Old saved manifest in \"%s\" differs from newly downloaded one, not overwriting!" % (filename))
else:
print("Old saved manifest in \"%s\" is still current" % (filename))
else:
f = open(filename, "wb")
f.write(manifestdata)
f.close()
print("Saved installer manifest to \"%s\"" % (filename))
return manifest
def prioritizePackage(a, b):
if "chip" in a and "chip" in b:
ax64 = a["chip"].lower() == "x64"
bx64 = b["chip"].lower() == "x64"
if ax64 and not bx64:
return -1
elif bx64 and not ax64:
return 1
if "language" in a and "language" in b:
aeng = a["language"].lower().startswith("en-")
beng = b["language"].lower().startswith("en-")
if aeng and not beng:
return -1
if beng and not aeng:
return 1
return 0
def getPackages(manifest):
packages = {}
for p in manifest["packages"]:
id = p["id"].lower()
if not id in packages:
packages[id] = []
packages[id].append(p)
for key in packages:
packages[key] = sorted(packages[key], key=functools.cmp_to_key(prioritizePackage))
return packages
def listPackageType(packages, type):
if type != None:
type = type.lower()
ids = []
for key in packages:
p = packages[key][0]
if type == None:
ids.append(p["id"])
elif "type" in p and p["type"].lower() == type:
ids.append(p["id"])
for id in sorted(ids):
print(id)
def findPackage(packages, id, chip, warn=True):
origid = id
id = id.lower()
candidates = None
if not id in packages:
if warn:
print("WARNING: %s not found" % (origid))
return None
candidates = packages[id]
if chip != None:
chip = chip.lower()
for a in candidates:
if "chip" in a and a["chip"].lower() == chip:
return a
return candidates[0]
def printDepends(packages, target, deptype, chip, indent, args):
chipstr = ""
if chip != None:
chipstr = " (" + chip + ")"
deptypestr = ""
if deptype != "":
deptypestr = " (" + deptype + ")"
ignorestr = ""
ignore = False
if target.lower() in args.ignore:
ignorestr = " (Ignored)"
ignore = True
print(indent + target + chipstr + deptypestr + ignorestr)
if deptype == "Optional" and not args.include_optional:
return
if deptype == "Recommended" and args.skip_recommended:
return
if ignore:
return
p = findPackage(packages, target, chip)
if p == None:
return
if "dependencies" in p:
deps = p["dependencies"]
for key in deps:
dep = deps[key]
type = ""
if "type" in dep:
type = dep["type"]
chip = None
if "chip" in dep:
chip = dep["chip"]
printDepends(packages, key, type, chip, indent + " ", args)
def printReverseDepends(packages, target, deptype, indent, args):
deptypestr = ""
if deptype != "":
deptypestr = " (" + deptype + ")"
print(indent + target + deptypestr)
if deptype == "Optional" and not args.include_optional:
return
if deptype == "Recommended" and args.skip_recommended:
return
target = target.lower()
for key in packages:
p = packages[key][0]
if "dependencies" in p:
deps = p["dependencies"]
for k in deps:
if k.lower() != target:
continue
dep = deps[k]
type = ""
if "type" in dep:
type = dep["type"]
printReverseDepends(packages, p["id"], type, indent + " ", args)
def getPackageKey(p):
packagekey = p["id"]
if "version" in p:
packagekey = packagekey + "-" + p["version"]
if "chip" in p:
packagekey = packagekey + "-" + p["chip"]
return packagekey
def aggregateDepends(packages, included, target, chip, args):
if target.lower() in args.ignore:
return []
p = findPackage(packages, target, chip)
if p == None:
return []
packagekey = getPackageKey(p)
if packagekey in included:
return []
ret = [p]
included[packagekey] = True
if "dependencies" in p:
deps = p["dependencies"]
for key in deps:
dep = deps[key]
if "type" in dep:
deptype = dep["type"]
if deptype == "Optional" and not args.include_optional:
continue
if deptype == "Recommended" and args.skip_recommended:
continue
chip = None
if "chip" in dep:
chip = dep["chip"]
ret.extend(aggregateDepends(packages, included, key, chip, args))
return ret
def getSelectedPackages(packages, args):
ret = []
included = {}
for i in args.package:
ret.extend(aggregateDepends(packages, included, i, None, args))
return ret
def sumInstalledSize(l):
sum = 0
for p in l:
if "installSizes" in p:
sizes = p["installSizes"]
for location in sizes:
sum = sum + sizes[location]
return sum
def sumDownloadSize(l):
sum = 0
for p in l:
if "payloads" in p:
for payload in p["payloads"]:
if "size" in payload:
sum = sum + payload["size"]
return sum
def formatSize(s):
if s > 900*1024*1024:
return "%.1f GB" % (s/(1024*1024*1024))
if s > 900*1024:
return "%.1f MB" % (s/(1024*1024))
if s > 1024:
return "%.1f KB" % (s/1024)
return "%d bytes" % (s)
def printPackageList(l):
for p in sorted(l, key=lambda p: p["id"]):
s = p["id"]
if "type" in p:
s = s + " (" + p["type"] + ")"
if "chip" in p:
s = s + " (" + p["chip"] + ")"
if "language" in p:
s = s + " (" + p["language"] + ")"
s = s + " " + formatSize(sumInstalledSize([p]))
print(s)
def makedirs(dir):
try:
os.makedirs(dir)
except OSError:
pass
def sha256File(file):
sha256Hash = hashlib.sha256()
with open(file, "rb") as f:
for byteBlock in iter(lambda: f.read(4096), b""):
sha256Hash.update(byteBlock)
return sha256Hash.hexdigest()
def getPayloadName(payload):
name = payload["fileName"]
if "\\" in name:
name = name.split("\\")[-1]
if "/" in name:
name = name.split("/")[-1]
return name
def downloadPackages(selected, cache, allowHashMismatch = False):
pool = multiprocessing.Pool(5)
tasks = []
makedirs(cache)
for p in selected:
if not "payloads" in p:
continue
dir = os.path.join(cache, getPackageKey(p))
makedirs(dir)
for payload in p["payloads"]:
name = getPayloadName(payload)
destname = os.path.join(dir, name)
fileid = os.path.join(getPackageKey(p), name)
args = (payload, destname, fileid, allowHashMismatch)
tasks.append(pool.apply_async(_downloadPayload, args))
downloaded = sum(task.get() for task in tasks)
pool.close()
print("Downloaded %s in total" % (formatSize(downloaded)))
def _downloadPayload(payload, destname, fileid, allowHashMismatch):
attempts = 5
for attempt in range(attempts):
try:
if os.access(destname, os.F_OK):
if "sha256" in payload:
if sha256File(destname).lower() != payload["sha256"].lower():
six.print_("Incorrect existing file %s, removing" % (fileid), flush=True)
os.remove(destname)
else:
six.print_("Using existing file %s" % (fileid), flush=True)
return 0
else:
return 0
size = 0
if "size" in payload:
size = payload["size"]
six.print_("Downloading %s (%s)" % (fileid, formatSize(size)), flush=True)
six.moves.urllib.request.urlretrieve(payload["url"], destname)
if "sha256" in payload:
if sha256File(destname).lower() != payload["sha256"].lower():
if allowHashMismatch:
six.print_("WARNING: Incorrect hash for downloaded file %s" % (fileid), flush=True)
else:
raise Exception("Incorrect hash for downloaded file %s, aborting" % fileid)
return size
except Exception as e:
if attempt == attempts - 1:
raise
six.print_("%s: %s" % (type(e).__name__, e), flush=True)
def mergeTrees(src, dest):
if not os.path.isdir(src):
return
if not os.path.isdir(dest):
shutil.move(src, dest)
return
names = os.listdir(src)
destnames = {}
for n in os.listdir(dest):
destnames[n.lower()] = n
for n in names:
srcname = os.path.join(src, n)
destname = os.path.join(dest, n)
if os.path.isdir(srcname):
if os.path.isdir(destname):
mergeTrees(srcname, destname)
elif n.lower() in destnames:
mergeTrees(srcname, os.path.join(dest, destnames[n.lower()]))
else:
shutil.move(srcname, destname)
else:
shutil.move(srcname, destname)
def unzipFiltered(zip, dest):
tmp = os.path.join(dest, "extract")
for f in zip.infolist():
name = six.moves.urllib.parse.unquote(f.filename)
if "/" in name:
sep = name.rfind("/")
dir = os.path.join(dest, name[0:sep])
makedirs(dir)
extracted = zip.extract(f, tmp)
shutil.move(extracted, os.path.join(dest, name))
shutil.rmtree(tmp)
def unpackVsix(file, dest, listing):
temp = os.path.join(dest, "vsix")
makedirs(temp)
with zipfile.ZipFile(file, 'r') as zip:
unzipFiltered(zip, temp)
with open(listing, "w") as f:
for n in zip.namelist():
f.write(n + "\n")
contents = os.path.join(temp, "Contents")
if os.access(contents, os.F_OK):
mergeTrees(contents, dest)
shutil.rmtree(temp)
def unpackWin10SDK(src, payloads, dest):
# We could try to unpack only the MSIs we need here.
# Note, this extracts some files into Program Files/..., and some
# files directly in the root unpack directory. The files we need
# are under Program Files/... though.
for payload in payloads:
name = getPayloadName(payload)
if name.endswith(".msi"):
print("Extracting " + name)
srcfile = os.path.join(src, name)
if sys.platform == "win32":
cmd = ["msiexec", "/a", srcfile, "/qn", "TARGETDIR=" + os.path.abspath(dest)]
else:
cmd = ["msiextract", "-C", dest, srcfile]
with open(os.path.join(dest, "WinSDK-" + getPayloadName(payload) + "-listing.txt"), "w") as log:
subprocess.check_call(cmd, stdout=log)
def extractPackages(selected, cache, dest):
makedirs(dest)
for p in selected:
type = p["type"]
dir = os.path.join(cache, getPackageKey(p))
if type == "Component" or type == "Workload" or type == "Group":
continue
if type == "Vsix":
print("Unpacking " + p["id"])
for payload in p["payloads"]:
unpackVsix(os.path.join(dir, getPayloadName(payload)), dest, os.path.join(dest, getPackageKey(p) + "-listing.txt"))
elif p["id"].startswith("Win10SDK") or p["id"].startswith("Win11SDK"):
print("Unpacking " + p["id"])
unpackWin10SDK(dir, p["payloads"], dest)
else:
print("Skipping unpacking of " + p["id"] + " of type " + type)
def moveVCSDK(unpack, dest):
# Move the VC and Program Files\Windows Kits\10 directories
# out from the unpack directory, allowing the rest of unpacked
# files to be removed.
makedirs(os.path.join(dest, "kits"))
mergeTrees(os.path.join(unpack, "VC"), os.path.join(dest, "VC"))
kitsPath = unpack
# msiexec extracts to Windows Kits rather than Program Files\Windows Kits
if sys.platform != "win32":
kitsPath = os.path.join(kitsPath, "Program Files")
kitsPath = os.path.join(kitsPath, "Windows Kits", "10")
mergeTrees(kitsPath, os.path.join(dest, "kits", "10"))
# The DIA SDK isn't necessary for normal use, but can be used when e.g.
# compiling LLVM.
mergeTrees(os.path.join(unpack, "DIA SDK"), os.path.join(dest, "DIA SDK"))
if __name__ == "__main__":
parser = getArgsParser()
args = parser.parse_args()
lowercaseIgnores(args)
socket.setdefaulttimeout(15)
packages = getPackages(getManifest(args))
if args.print_version:
sys.exit(0)
if not args.accept_license:
response = six.moves.input("Do you accept the license at " + findPackage(packages, "Microsoft.VisualStudio.Product.BuildTools", None)["localizedResources"][0]["license"] + " (yes/no)? ")
while response != "yes" and response != "no":
response = six.moves.input("Do you accept the license? Answer \"yes\" or \"no\": ")
if response == "no":
sys.exit(0)
setPackageSelection(args, packages)
if args.list_components or args.list_workloads or args.list_packages:
if args.list_components:
listPackageType(packages, "Component")
if args.list_workloads:
listPackageType(packages, "Workload")
if args.list_packages:
listPackageType(packages, None)
sys.exit(0)
if args.print_deps_tree:
for i in args.package:
printDepends(packages, i, "", None, "", args)
sys.exit(0)
if args.print_reverse_deps:
for i in args.package:
printReverseDepends(packages, i, "", "", args)
sys.exit(0)
selected = getSelectedPackages(packages, args)
if args.print_selection:
printPackageList(selected)
print("Selected %d packages, for a total download size of %s, install size of %s" % (len(selected), formatSize(sumDownloadSize(selected)), formatSize(sumInstalledSize(selected))))
if args.print_selection:
sys.exit(0)
tempcache = None
if args.cache != None:
cache = os.path.abspath(args.cache)
else:
cache = tempfile.mkdtemp(prefix="vsinstall-")
tempcache = cache
if not args.only_download and args.dest == None:
print("No destination directory set!")
sys.exit(1)
try:
downloadPackages(selected, cache, allowHashMismatch=args.only_download)
if args.only_download:
sys.exit(0)
dest = os.path.abspath(args.dest)
if args.only_unpack:
unpack = dest
else:
unpack = os.path.join(dest, "unpack")
extractPackages(selected, cache, unpack)
if not args.only_unpack:
moveVCSDK(unpack, dest)
if not args.keep_unpack:
shutil.rmtree(unpack)
finally:
if tempcache != None:
shutil.rmtree(tempcache)