OverviewFilesCommitsIssuesDocsReleasesPeers

nvidia-rtx-godot / modules / mono / build_scripts / build_assemblies.py

459 lines · python ref:
#!/usr/bin/env python3

from __future__ import annotations

import os
import os.path
import shlex
import subprocess
from dataclasses import dataclass


def find_dotnet_cli():
    if os.name == "nt":
        for hint_dir in os.environ["PATH"].split(os.pathsep):
            hint_dir = hint_dir.strip('"')
            hint_path = os.path.join(hint_dir, "dotnet")
            if os.path.isfile(hint_path) and os.access(hint_path, os.X_OK):
                return hint_path
            if os.path.isfile(hint_path + ".exe") and os.access(hint_path + ".exe", os.X_OK):
                return hint_path + ".exe"
    else:
        for hint_dir in os.environ["PATH"].split(os.pathsep):
            hint_dir = hint_dir.strip('"')
            hint_path = os.path.join(hint_dir, "dotnet")
            if os.path.isfile(hint_path) and os.access(hint_path, os.X_OK):
                return hint_path


def find_msbuild_standalone_windows():
    msbuild_tools_path = find_msbuild_tools_path_reg()

    if msbuild_tools_path:
        return os.path.join(msbuild_tools_path, "MSBuild.exe")

    return None


def find_msbuild_mono_windows(mono_prefix):
    assert mono_prefix is not None

    mono_bin_dir = os.path.join(mono_prefix, "bin")
    msbuild_mono = os.path.join(mono_bin_dir, "msbuild.bat")

    if os.path.isfile(msbuild_mono):
        return msbuild_mono

    return None


def find_msbuild_mono_unix():
    import sys

    hint_dirs = []
    if sys.platform == "darwin":
        hint_dirs[:0] = [
            "/Library/Frameworks/Mono.framework/Versions/Current/bin",
            "/usr/local/var/homebrew/linked/mono/bin",
        ]

    for hint_dir in hint_dirs:
        hint_path = os.path.join(hint_dir, "msbuild")
        if os.path.isfile(hint_path):
            return hint_path
        elif os.path.isfile(hint_path + ".exe"):
            return hint_path + ".exe"

    for hint_dir in os.environ["PATH"].split(os.pathsep):
        hint_dir = hint_dir.strip('"')
        hint_path = os.path.join(hint_dir, "msbuild")
        if os.path.isfile(hint_path) and os.access(hint_path, os.X_OK):
            return hint_path
        if os.path.isfile(hint_path + ".exe") and os.access(hint_path + ".exe", os.X_OK):
            return hint_path + ".exe"

    return None


def find_msbuild_tools_path_reg():
    import subprocess

    program_files = os.getenv("PROGRAMFILES(X86)")
    if not program_files:
        program_files = os.getenv("PROGRAMFILES")
    vswhere = os.path.join(program_files, "Microsoft Visual Studio", "Installer", "vswhere.exe")

    vswhere_args = ["-latest", "-products", "*", "-requires", "Microsoft.Component.MSBuild"]

    try:
        lines = subprocess.check_output([vswhere] + vswhere_args).splitlines()

        for line in lines:
            parts = line.decode("utf-8").split(":", 1)

            if len(parts) < 2 or parts[0] != "installationPath":
                continue

            val = parts[1].strip()

            if not val:
                raise ValueError("Value of `installationPath` entry is empty")

            # Since VS2019, the directory is simply named "Current"
            return os.path.join(val, "MSBuild", "Current", "Bin")

        raise ValueError("Cannot find `installationPath` entry")
    except ValueError as e:
        print("Error reading output from vswhere: " + str(e))
    except OSError:
        pass  # Fine, vswhere not found
    except (subprocess.CalledProcessError, OSError):
        pass


@dataclass
class ToolsLocation:
    dotnet_cli: str = ""
    msbuild_standalone: str = ""
    msbuild_mono: str = ""
    mono_bin_dir: str = ""


def find_any_msbuild_tool(mono_prefix):
    # Preference order: dotnet CLI > Standalone MSBuild > Mono's MSBuild

    # Find dotnet CLI
    dotnet_cli = find_dotnet_cli()
    if dotnet_cli:
        return ToolsLocation(dotnet_cli=dotnet_cli)

    # Find standalone MSBuild
    if os.name == "nt":
        msbuild_standalone = find_msbuild_standalone_windows()
        if msbuild_standalone:
            return ToolsLocation(msbuild_standalone=msbuild_standalone)

    if mono_prefix:
        # Find Mono's MSBuild
        if os.name == "nt":
            msbuild_mono = find_msbuild_mono_windows(mono_prefix)
            if msbuild_mono:
                return ToolsLocation(msbuild_mono=msbuild_mono)
        else:
            msbuild_mono = find_msbuild_mono_unix()
            if msbuild_mono:
                return ToolsLocation(msbuild_mono=msbuild_mono)

    return None


def run_msbuild(tools: ToolsLocation, sln: str, chdir_to: str, msbuild_args: list[str] | None = None):
    using_msbuild_mono = False

    # Preference order: dotnet CLI > Standalone MSBuild > Mono's MSBuild
    if tools.dotnet_cli:
        args = [tools.dotnet_cli, "msbuild"]
    elif tools.msbuild_standalone:
        args = [tools.msbuild_standalone]
    elif tools.msbuild_mono:
        args = [tools.msbuild_mono]
        using_msbuild_mono = True
    else:
        raise RuntimeError("Path to MSBuild or dotnet CLI not provided.")

    args += [sln]

    if msbuild_args:
        args += msbuild_args

    print("Running MSBuild: ", " ".join(shlex.quote(arg) for arg in args), flush=True)

    msbuild_env = os.environ.copy()

    # Needed when running from Developer Command Prompt for VS
    if "PLATFORM" in msbuild_env:
        del msbuild_env["PLATFORM"]

    if using_msbuild_mono:
        # The (Csc/Vbc/Fsc)ToolExe environment variables are required when
        # building with Mono's MSBuild. They must point to the batch files
        # in Mono's bin directory to make sure they are executed with Mono.
        msbuild_env.update({
            "CscToolExe": os.path.join(tools.mono_bin_dir, "csc.bat"),
            "VbcToolExe": os.path.join(tools.mono_bin_dir, "vbc.bat"),
            "FscToolExe": os.path.join(tools.mono_bin_dir, "fsharpc.bat"),
        })

    # We want to control cwd when running msbuild, because that's where the search for global.json begins.
    return subprocess.call(args, env=msbuild_env, cwd=chdir_to)


def build_godot_api(msbuild_tool, module_dir, output_dir, push_nupkgs_local, precision, no_deprecated, werror):
    target_filenames = [
        "GodotSharp.dll",
        "GodotSharp.pdb",
        "GodotSharp.xml",
        "GodotSharpEditor.dll",
        "GodotSharpEditor.pdb",
        "GodotSharpEditor.xml",
        "GodotPlugins.dll",
        "GodotPlugins.pdb",
        "GodotPlugins.runtimeconfig.json",
    ]

    for build_config in ["Debug", "Release"]:
        editor_api_dir = os.path.join(output_dir, "GodotSharp", "Api", build_config)

        targets = [os.path.join(editor_api_dir, filename) for filename in target_filenames]

        args = ["/restore", "/t:Build", "/p:Configuration=" + build_config, "/p:NoWarn=1591"]
        if push_nupkgs_local:
            args += ["/p:ClearNuGetLocalCache=true", "/p:PushNuGetToLocalSource=" + push_nupkgs_local]
        if precision == "double":
            args += ["/p:GodotFloat64=true"]
        if no_deprecated:
            args += ["/p:GodotNoDeprecated=true"]
        if werror:
            args += ["/p:TreatWarningsAsErrors=true"]

        sln = os.path.join(module_dir, "glue/GodotSharp/GodotSharp.sln")
        exit_code = run_msbuild(msbuild_tool, sln=sln, chdir_to=module_dir, msbuild_args=args)
        if exit_code != 0:
            return exit_code

        # Copy targets

        core_src_dir = os.path.abspath(os.path.join(sln, os.pardir, "GodotSharp", "bin", build_config))
        editor_src_dir = os.path.abspath(os.path.join(sln, os.pardir, "GodotSharpEditor", "bin", build_config))
        plugins_src_dir = os.path.abspath(os.path.join(sln, os.pardir, "GodotPlugins", "bin", build_config, "net8.0"))

        if not os.path.isdir(editor_api_dir):
            assert not os.path.isfile(editor_api_dir)
            os.makedirs(editor_api_dir)

        def copy_target(target_path):
            from shutil import copy

            filename = os.path.basename(target_path)

            src_path = os.path.join(core_src_dir, filename)
            if not os.path.isfile(src_path):
                src_path = os.path.join(editor_src_dir, filename)
            if not os.path.isfile(src_path):
                src_path = os.path.join(plugins_src_dir, filename)

            print(f"Copying assembly to {target_path}...")
            copy(src_path, target_path)

        for scons_target in targets:
            copy_target(scons_target)

    return 0


def generate_sdk_package_versions():
    # I can't believe importing files in Python is so convoluted when not
    # following the golden standard for packages/modules.
    import os
    import sys
    from os.path import dirname

    # We want ../../../methods.py.
    script_path = dirname(os.path.abspath(__file__))
    root_path = dirname(dirname(dirname(script_path)))

    sys.path.insert(0, root_path)
    from methods import get_version_info

    version_info = get_version_info("")
    sys.path.remove(root_path)

    version_status = version_info["status"]
    godotsharp_version_str = "{major}.{minor}.{patch}".format(**version_info)
    godot_dotnet_version_str = "{major}.{minor}.{patch}".format(**version_info)
    if version_status == "stable":
        # For stable versions, use the latest revision version available
        # of the Godot .NET packages.
        godot_dotnet_version_str = ".*"
    else:
        # Pre-releases and development builds.

        # If version was overridden to be e.g. "beta3", we insert a dot between
        # "beta" and "3" to follow SemVer 2.0.
        import re

        match = re.search(r"[\d]+$", version_status)
        if match:
            pos = match.start()
            godotsharp_version_status = version_status[:pos]
            godot_dotnet_version_status = version_status[:pos]

            # "dev" pre-releases always use the "alpha" label in the Godot .NET packages.
            if godot_dotnet_version_status == "dev":
                godot_dotnet_version_status = "alpha"

            godotsharp_version_status += f".{version_status[pos:]}"
            godot_dotnet_version_status += f".{version_status[pos:]}"
        else:
            # If the version status is not numbered, it must be a development build.
            # Development builds always use the "dev" label in the Godot .NET packages.
            godot_dotnet_version_status = "dev"
            godotsharp_version_status = version_status

        godotsharp_version_str += f"-{godotsharp_version_status}"
        godot_dotnet_version_str += f"-{godot_dotnet_version_status}"

    import version

    version_defines = (
        [
            f"GODOT{version.major}",
            f"GODOT{version.major}_{version.minor}",
            f"GODOT{version.major}_{version.minor}_{version.patch}",
        ]
        + [f"GODOT{v}_OR_GREATER" for v in range(4, version.major + 1)]
        + [f"GODOT{version.major}_{v}_OR_GREATER" for v in range(0, version.minor + 1)]
        + [f"GODOT{version.major}_{version.minor}_{v}_OR_GREATER" for v in range(0, version.patch + 1)]
    )

    props = f"""<Project>
  <PropertyGroup>
    <PackageVersion_GodotSharp>{godotsharp_version_str}</PackageVersion_GodotSharp>
    <PackageVersion_Godot_NET_Sdk>{godotsharp_version_str}</PackageVersion_Godot_NET_Sdk>
    <PackageVersion_Godot_SourceGenerators>{godotsharp_version_str}</PackageVersion_Godot_SourceGenerators>
    <PackageVersion_GodotDotNet>{godot_dotnet_version_str}</PackageVersion_GodotDotNet>
    <_GodotVersionConstants>{";".join(version_defines)}</_GodotVersionConstants>
  </PropertyGroup>
</Project>
"""

    # We write in ../SdkPackageVersions.props.
    with open(os.path.join(dirname(script_path), "SdkPackageVersions.props"), "w", encoding="utf-8", newline="\n") as f:
        f.write(props)

    # Also write the versioned docs URL to a constant for the Source Generators.

    constants = """namespace Godot.SourceGenerators
{{
// TODO: This is currently disabled because of https://github.com/dotnet/roslyn/issues/52904
#pragma warning disable IDE0040 // Add accessibility modifiers.
    partial class Common
    {{
        public const string VersionDocsUrl = "https://docs.godotengine.org/en/{docs_branch}";
    }}
}}
""".format(**version_info)

    generators_dir = os.path.join(
        dirname(script_path),
        "editor",
        "Godot.NET.Sdk",
        "Godot.SourceGenerators",
        "Generated",
    )
    os.makedirs(generators_dir, exist_ok=True)

    with open(os.path.join(generators_dir, "Common.Constants.cs"), "w", encoding="utf-8", newline="\n") as f:
        f.write(constants)


def build_all(
    msbuild_tool, module_dir, output_dir, godot_platform, dev_debug, push_nupkgs_local, precision, no_deprecated, werror
):
    # Generate SdkPackageVersions.props and VersionDocsUrl constant
    generate_sdk_package_versions()

    # Godot API
    exit_code = build_godot_api(
        msbuild_tool, module_dir, output_dir, push_nupkgs_local, precision, no_deprecated, werror
    )
    if exit_code != 0:
        return exit_code

    # GodotTools
    sln = os.path.join(module_dir, "editor/GodotTools/GodotTools.sln")
    args = ["/restore", "/t:Build", "/p:Configuration=" + ("Debug" if dev_debug else "Release")] + (
        ["/p:GodotPlatform=" + godot_platform] if godot_platform else []
    )
    if push_nupkgs_local:
        args += ["/p:ClearNuGetLocalCache=true", "/p:PushNuGetToLocalSource=" + push_nupkgs_local]
    if precision == "double":
        args += ["/p:GodotFloat64=true"]
    exit_code = run_msbuild(msbuild_tool, sln=sln, chdir_to=module_dir, msbuild_args=args)
    if exit_code != 0:
        return exit_code

    # Godot.NET.Sdk
    args = ["/restore", "/t:Build", "/p:Configuration=Release"]
    if push_nupkgs_local:
        args += ["/p:ClearNuGetLocalCache=true", "/p:PushNuGetToLocalSource=" + push_nupkgs_local]
    if precision == "double":
        args += ["/p:GodotFloat64=true"]
    if no_deprecated:
        args += ["/p:GodotNoDeprecated=true"]
    sln = os.path.join(module_dir, "editor/Godot.NET.Sdk/Godot.NET.Sdk.sln")
    exit_code = run_msbuild(msbuild_tool, sln=sln, chdir_to=module_dir, msbuild_args=args)
    if exit_code != 0:
        return exit_code

    return 0


def main():
    import argparse
    import sys

    parser = argparse.ArgumentParser(description="Builds all Godot .NET solutions")
    parser.add_argument("--godot-output-dir", type=str, required=True)
    parser.add_argument(
        "--dev-debug",
        action="store_true",
        default=False,
        help="Build GodotTools and Godot.NET.Sdk with 'Configuration=Debug'",
    )
    parser.add_argument("--godot-platform", type=str, default="")
    parser.add_argument("--mono-prefix", type=str, default="")
    parser.add_argument("--push-nupkgs-local", type=str, default="")
    parser.add_argument(
        "--precision", type=str, default="single", choices=["single", "double"], help="Floating-point precision level"
    )
    parser.add_argument(
        "--no-deprecated",
        action="store_true",
        default=False,
        help="Build GodotSharp without using deprecated features. This is required, if the engine was built with 'deprecated=no'.",
    )
    parser.add_argument("--werror", action="store_true", default=False, help="Treat compiler warnings as errors.")

    args = parser.parse_args()

    this_script_dir = os.path.dirname(os.path.realpath(__file__))
    module_dir = os.path.abspath(os.path.join(this_script_dir, os.pardir))

    output_dir = os.path.abspath(args.godot_output_dir)

    push_nupkgs_local = os.path.abspath(args.push_nupkgs_local) if args.push_nupkgs_local else None

    msbuild_tool = find_any_msbuild_tool(args.mono_prefix)

    if msbuild_tool is None:
        print("Unable to find MSBuild")
        sys.exit(1)

    exit_code = build_all(
        msbuild_tool,
        module_dir,
        output_dir,
        args.godot_platform,
        args.dev_debug,
        push_nupkgs_local,
        args.precision,
        args.no_deprecated,
        args.werror,
    )
    sys.exit(exit_code)


if __name__ == "__main__":
    main()