text: #!/usr/bin/env python3
  
  import argparse
  import pathlib
  import requests
  import shutil
  import subprocess
  from typing import Callable, Iterator, List, Tuple
  
  # supported archive and platform endings, used to create actual archive names
  archive_endings = ["windows64.zip", "linux64.tar.xz", "macOS.dmg"]
  
  
  def add_optional_argument(option: str, help: str) -> None:
      global parser
      """Add an optional argument
  
      Args:
          option (str): Option to add
          help (str): Help description for the argument
      """
      parser.add_argument(option, help=help, action='store_const', const=1)
  
  
  def blender_archives(version: str) -> Iterator[str]:
      """Generator for Blender archives for version.
  
      Yields for items in archive_endings an archive name in the form of
      blender-{version}-{ending}.
  
      Args:
          version (str): Version string of the form 2.83.2
  
  
      Yields:
          Iterator[str]: Name in the form of blender-{version}-{ending}
      """
      global archive_endings
  
      for ending in archive_endings:
          yield f"blender-{version}-{ending}"
  
  
  def get_archive_type(archive_type: str, version: str) -> str:
      """Return the archive of given type and version.
  
      Args:
          archive_type (str): extension for archive type to check for
          version (str): Version string in the form 2.83.2
  
      Raises:
          Exception: Execption when archive type isn't found
  
      Returns:
          str: archive name for given type
      """
  
      for archive in blender_archives(version):
          if archive.endswith(archive_type):
              return archive
      raise Exception("Unknown archive type")
  
  
  def execute_command(cmd: List[str], name: str, errcode: int, cwd=".", capture_output=True) -> str:
      """Execute the given command.
  
      Returns the process stdout upon success if any.
  
      On error print message the command with name that has failed. Print stdout
      and stderr of the process if any, and then exit with given error code.
  
      Args:
          cmd (List[str]): Command in list format, each argument as their own item
          name (str): Name of command to use when printing to command-line
          errcode (int): Error code to use in case of exit()
          cwd (str, optional): Folder to use as current work directory for command
                               execution. Defaults to ".".
          capture_output (bool, optional): Whether to capture command output or not.
                                           Defaults to True.
  
      Returns:
          str: stdout if any, or empty string
      """
      cmd_process = subprocess.run(
          cmd, capture_output=capture_output, encoding="UTF-8", cwd=cwd)
      if cmd_process.returncode == 0:
          if cmd_process.stdout:
              return cmd_process.stdout
          else:
              return ""
      else:
          print(f"ERROR: {name} failed.")
          if cmd_process.stdout:
              print(cmd_process.stdout)
          if cmd_process.stderr:
              print(cmd_process.stderr)
          exit(errcode)
          return ""
  
  
  def download_archives(base_url: str, archives: Callable[[str], Iterator[str]], version: str, dst_dir: pathlib.Path):
      """Download archives from the given base_url.
  
      Archives is a generator for Blender archive names based on version.
  
      Archive names are appended to the base_url to load from, and appended to
      dst_dir to save to.
  
      Args:
          base_url (str): Base URL to load archives from
          archives (Callable[[str], Iterator[str]]): Generator for Blender archive
                                                     names based on version
          version (str): Version string in the form of 2.83.2
          dst_dir (pathlib.Path): Download destination
      """
  
      if base_url[-1] != '/':
          base_url = base_url + '/'
  
      for archive in archives(version):
          download_url = f"{base_url}{archive}"
          target_file = dst_dir.joinpath(archive)
          download_file(download_url, target_file)
  
  
  def download_file(from_url: str, to_file: pathlib.Path) -> None:
      """Download from_url as to_file.
  
      Actual downloading will be skipped if --skipdl is given on the command-line.
  
      Args:
          from_url (str): Full URL to resource to download
          to_file (pathlib.Path): Full path to save downloaded resource as
      """
      global args
  
      if not args.skipdl or not to_file.exists():
          print(f"Downloading {from_url}")
          with open(to_file, "wb") as download_zip:
              response = requests.get(from_url)
              if response.status_code != requests.codes.ok:
                  print(f"ERROR: failed to download {from_url} (status code: {response.status_code})")
                  exit(1313)
              download_zip.write(response.content)
      else:
          print(f"Downloading {from_url} skipped")
      print("   ... OK")
  
  
  def copy_contents_from_dmg_to_path(dmg_file: pathlib.Path, dst: pathlib.Path) -> None:
      """Copy the contents of the given DMG file to the destination folder.
  
      Args:
          dmg_file (pathlib.Path): Full path to DMG archive to extract from
          dst (pathlib.Path): Full path to destination to extract to
      """
      hdiutil_attach = ["hdiutil",
                        "attach",
                        "-readonly",
                        f"{dmg_file}"
                        ]
      attached = execute_command(hdiutil_attach, "hdiutil attach", 1)
  
      # Last line of output is what we want, it is of the form
      # /dev/somedisk    Apple_HFS     /Volumes/Blender
      # We want to retain the mount point, and the folder the mount is
      # created on. The mounted disk we need for detaching, the folder we
      # need to be able to copy the contents to where we can use them
      attachment_items = attached.splitlines()[-1].split()
      mounted_disk = attachment_items[0]
      source_location = pathlib.Path(attachment_items[2], "Blender.app")
  
      print(f"{source_location} -> {dst}")
  
      shutil.copytree(source_location, dst)
  
      hdiutil_detach = ["hdiutil",
                        "detach",
                        f"{mounted_disk}"
                        ]
      execute_command(hdiutil_detach, "hdiutil detach", 2)
  
  
  def create_build_script(template_name: str, vars: List[Tuple[str, str]]) -> pathlib.Path:
      """
      Create the Steam build script
  
      Use the given template and template variable tuple list.
  
      Returns pathlib.Path to the created script.
  
      Args:
          template_name (str): [description]
          vars (List[Tuple[str, str]]): [description]
  
      Returns:
          pathlib.Path: Full path to the generated script
      """
      build_script = pathlib.Path(".", template_name).read_text()
      for var in vars:
          build_script = build_script.replace(var[0], var[1])
      build_script_file = template_name.replace(".template", "")
      build_script_path = pathlib.Path(".", build_script_file)
      build_script_path.write_text(build_script)
      return build_script_path
  
  
  def clean_up() -> None:
      """Remove intermediate files depending on given command-line arguments
      """
      global content_location, args
  
      if not args.leavearch and not args.leaveextracted:
          shutil.rmtree(content_location)
  
      if args.leavearch and not args.leaveextracted:
          shutil.rmtree(content_location.joinpath(zip_extract_folder))
          shutil.rmtree(content_location.joinpath(tarxz_extract_folder))
          shutil.rmtree(content_location.joinpath(dmg_extract_folder))
  
      if args.leaveextracted and not args.leavearch:
          import os
          os.remove(content_location.joinpath(zipped_blender))
          os.remove(content_location.joinpath(tarxz_blender))
          os.remove(content_location.joinpath(dmg_blender))
  
  
  def extract_archive(archive: str, extract_folder_name: str,
                      cmd: List[str], errcode: int) -> None:
      """Extract all files from archive to given folder name.
  
      Will not extract if
      target folder already exists, or if --skipextract was given on the
      command-line.
  
      Args:
          archive (str): Archive name to extract
          extract_folder_name (str): Folder name to extract to
          cmd (List[str]): Command with arguments to use
          errcode (int): Error code to use for exit()
      """
      global args, content_location
  
      extract_location = content_location.joinpath(extract_folder_name)
  
      pre_extract = set(content_location.glob("*"))
  
      if not args.skipextract or not extract_location.exists():
          print(f"Extracting files from {archive}...")
          cmd.append(content_location.joinpath(archive))
          execute_command(cmd, cmd[0], errcode, cwd=content_location)
          # in case we use a non-release archive the naming will be incorrect.
          # simply rename to expected target name
          post_extract = set(content_location.glob("*"))
          diff_extract = post_extract - pre_extract
          if not extract_location in diff_extract:
              folder_to_rename = list(diff_extract)[0]
              folder_to_rename.rename(extract_location)
          print("   OK")
      else:
          print(f"Skipping extraction {archive}!")
  
  # ==============================================================================
  
  
  parser = argparse.ArgumentParser()
  
  parser.add_argument("--baseurl", required=True,
                      help="The base URL for files to download, "
                      "i.e. https://download.blender.org/release/Blender2.83/")
  
  parser.add_argument("--version", required=True,
                      help="The Blender version to release, in the form 2.83.3")
  
  parser.add_argument("--appid", required=True,
                      help="The Blender App ID on Steam")
  parser.add_argument("--winid", required=True,
                      help="The Windows depot ID")
  parser.add_argument("--linuxid", required=True,
                      help="The Linux depot ID")
  parser.add_argument("--macosid", required=True,
                      help="The MacOS depot ID")
  
  parser.add_argument("--steamcmd", required=True,
                      help="Path to the steamcmd")
  parser.add_argument("--steamuser", required=True,
                      help="The login for the Steam builder user")
  parser.add_argument("--steampw", required=True,
                      help="Login password for the Steam builder user")
  
  add_optional_argument("--dryrun",
                        "If set the Steam files will not be uploaded")
  add_optional_argument("--leavearch",
                        help="If set don't clean up the downloaded archives")
  add_optional_argument("--leaveextracted",
                        help="If set don't clean up the extraction folders")
  add_optional_argument("--skipdl",
                        help="If set downloading the archives is skipped if it already exists locally.")
  add_optional_argument("--skipextract",
                        help="If set skips extracting of archives. The tool assumes the archives"
                        "have already been extracted to their correct locations")
  
  args = parser.parse_args()
  
  VERSIONNODOTS = args.version.replace('.', '')
  OUTPUT = f"output{VERSIONNODOTS}"
  CONTENT = f"content{VERSIONNODOTS}"
  
  # ===== set up main locations
  
  content_location = pathlib.Path(".", CONTENT).absolute()
  output_location = pathlib.Path(".", OUTPUT).absolute()
  
  content_location.mkdir(parents=True, exist_ok=True)
  output_location.mkdir(parents=True, exist_ok=True)
  
  # ===== login
  
  # Logging into Steam once to ensure the SDK updates itself properly. If we don't
  # do that the combined +login and +run_app_build_http at the end of the tool
  # will fail.
  steam_login = [args.steamcmd,
                 "+login",
                 args.steamuser,
                 args.steampw,
                 "+quit"
                 ]
  print("Logging in to Steam...")
  execute_command(steam_login, "Login to Steam", 10)
  print("   OK")
  
  # ===== prepare Steam build scripts
  
  template_vars = [
      ("[APPID]", args.appid),
      ("[OUTPUT]", OUTPUT),
      ("[CONTENT]", CONTENT),
      ("[VERSION]", args.version),
      ("[WINID]", args.winid),
      ("[LINUXID]", args.linuxid),
      ("[MACOSID]", args.macosid),
      ("[DRYRUN]", f"{args.dryrun}" if args.dryrun else "0")
  ]
  
  blender_app_build = create_build_script(
      "blender_app_build.vdf.template", template_vars)
  create_build_script("depot_build_win.vdf.template", template_vars)
  create_build_script("depot_build_linux.vdf.template", template_vars)
  create_build_script("depot_build_macos.vdf.template", template_vars)
  
  # ===== download archives
  
  download_archives(args.baseurl, blender_archives,
                    args.version, content_location)
  
  # ===== set up file and folder names
  
  zipped_blender = get_archive_type("zip", args.version)
  zip_extract_folder = zipped_blender.replace(".zip", "")
  tarxz_blender = get_archive_type("tar.xz", args.version)
  tarxz_extract_folder = tarxz_blender.replace(".tar.xz", "")
  dmg_blender = get_archive_type("dmg", args.version)
  dmg_extract_folder = dmg_blender.replace(".dmg", "")
  
  # ===== extract
  
  unzip_cmd = ["unzip", "-q"]
  extract_archive(zipped_blender, zip_extract_folder,  unzip_cmd, 3)
  
  untarxz_cmd = ["tar", "-xf"]
  extract_archive(tarxz_blender, tarxz_extract_folder, untarxz_cmd, 4)
  
  if not args.skipextract or not content_location.joinpath(dmg_extract_folder).exists():
      print("Extracting files from Blender MacOS archive...")
      blender_dmg = content_location.joinpath(dmg_blender)
      target_location = content_location.joinpath(
          dmg_extract_folder, "Blender.app")
      copy_contents_from_dmg_to_path(blender_dmg, target_location)
      print("   OK")
  else:
      print("Skipping extraction of .dmg!")
  
  # ===== building
  
  print("Build Steam game files...")
  steam_build = [args.steamcmd,
                 "+login",
                 args.steamuser,
                 args.steampw,
                 "+run_app_build_http",
                 blender_app_build.absolute(),
                 "+quit"
                 ]
  execute_command(steam_build, "Build with steamcmd", 13)
  print("   OK")
  
  clean_up()
  