# Copyright 2021 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

# Build/install the iree-base-compiler python package.
# Note that this includes a relatively large build of LLVM (~2400 C++ files)
# and can take a considerable amount of time, especially with defaults.
# To install:
#   pip install .
# To build a wheel:
#   pip wheel .
#
# It is recommended to build with Ninja and ccache. To do so, set environment
# variables by prefixing to above invocations:
#   CMAKE_C_COMPILER_LAUNCHER=ccache CMAKE_CXX_COMPILER_LAUNCHER=ccache
#
# On CIs, it is often advantageous to re-use/control the CMake build directory.
# This can be set with the IREE_COMPILER_API_CMAKE_BUILD_DIR env var.
#
# If building from a development tree and aiming to get an "editable" install,
# use the environment option CMAKE_INSTALL_MODE=ABS_SYMLINK on your
# `pip install -e .` invocation.

from gettext import install
import json
from multiprocessing.spawn import prepare
from pathlib import Path
import os
import platform
import re
import shutil
import subprocess
import sys
import sysconfig

from distutils.command.build import build as _build
from setuptools import find_namespace_packages, setup, Extension
from setuptools.command.build_ext import build_ext as _build_ext
from setuptools.command.build_py import build_py as _build_py
from setuptools.command.egg_info import egg_info

# Detect whether we should build an abi3 (Stable ABI) wheel.
# This applies to CPython 3.12+ when not in free-threaded mode.
_is_abi3_build = sys.version_info >= (3, 12) and not sysconfig.get_config_var(
    "Py_GIL_DISABLED"
)


def check_pip_version():
    from packaging import version

    # Pip versions < 22.0.3 default to out of tree builds, which is quite
    # incompatible with what we do (and has other issues). Pip >= 22.0.4
    # removed this option entirely and are only in-tree builds. Since the
    # old behavior can silently produce unworking installations, we aggressively
    # suppress it.
    try:
        import pip
    except ModuleNotFoundError:
        # If pip not installed, we are obviously not trying to package via pip.
        pass
    else:
        if version.parse(pip.__version__) < version.parse("21.3"):
            print("ERROR: pip version >= 21.3 required")
            print("Upgrade: pip install pip --upgrade")
            sys.exit(2)


check_pip_version()

# This file can be run directly from the source tree or it can be CMake
# configured so it can run from the build tree with an already existing
# build tree. We detect the difference based on whether the following
# are expanded by CMake.
CONFIGURED_SOURCE_DIR = "@IREE_SOURCE_DIR@"
CONFIGURED_BINARY_DIR = "@IREE_BINARY_DIR@"

IREE_SOURCE_DIR = None
IREE_BINARY_DIR = None

# We must do the intermediate installation to a fixed location that agrees
# between what we pass to setup() and cmake. So hard-code it here.
# Note that setup() needs a relative path (to the setup.py file).
# We keep the path short ('i' instead of 'install') for platforms like Windows
# that have file length limits.
SETUPPY_DIR = os.path.realpath(os.path.dirname(__file__))
CMAKE_INSTALL_DIR_REL = os.path.join("build", "i")
CMAKE_INSTALL_DIR_ABS = os.path.join(SETUPPY_DIR, CMAKE_INSTALL_DIR_REL)

IS_CONFIGURED = CONFIGURED_SOURCE_DIR[0] != "@"
if IS_CONFIGURED:
    IREE_SOURCE_DIR = CONFIGURED_SOURCE_DIR
    IREE_BINARY_DIR = CONFIGURED_BINARY_DIR
    print(
        f"Running setup.py from build tree: "
        f"SOURCE_DIR = {IREE_SOURCE_DIR} "
        f"BINARY_DIR = {IREE_BINARY_DIR}",
        file=sys.stderr,
    )
else:
    IREE_SOURCE_DIR = os.path.join(SETUPPY_DIR, "..")
    IREE_BINARY_DIR = os.getenv("IREE_COMPILER_API_CMAKE_BUILD_DIR")
    if not IREE_BINARY_DIR:
        # Note that setuptools always builds into a "build" directory that
        # is a sibling of setup.py, so we just colonize a sub-directory of that
        # by default.
        IREE_BINARY_DIR = os.path.join(SETUPPY_DIR, "build", "b")
    print(
        f"Running setup.py from source tree: "
        f"SOURCE_DIR = {IREE_SOURCE_DIR} "
        f"BINARY_DIR = {IREE_BINARY_DIR}",
        file=sys.stderr,
    )

# Setup and get version information.
VERSION_FILE = os.path.join(IREE_SOURCE_DIR, "compiler/version.json")
VERSION_FILE_LOCAL = os.path.join(IREE_SOURCE_DIR, "compiler/version_local.json")


def load_version_info(version_file):
    with open(version_file, "rt") as f:
        return json.load(f)


def find_git_versions():
    revisions = {}
    try:
        revisions["IREE"] = (
            subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=IREE_SOURCE_DIR)
            .decode("utf-8")
            .strip()
        )
    except subprocess.SubprocessError as e:
        print(f"ERROR: Could not get IREE revision: {e}", file=sys.stderr)
    return revisions


def find_git_submodule_revision(submodule_path):
    try:
        data = (
            subprocess.check_output(
                ["git", "ls-tree", "HEAD", submodule_path], cwd=IREE_SOURCE_DIR
            )
            .decode("utf-8")
            .strip()
        )
        columns = re.split("\\s+", data)
        return columns[2]
    except Exception as e:
        print(
            f"ERROR: Could not get submodule revision for {submodule_path}" f" ({e})",
            file=sys.stderr,
        )
        return ""


is_dev_build = False
try:
    version_info = load_version_info(VERSION_FILE_LOCAL)
except FileNotFoundError:
    print("version_local.json not found. Using version.json defaults")
    version_info = load_version_info(VERSION_FILE)
    is_dev_build = True
git_versions = find_git_versions()

PACKAGE_SUFFIX = version_info.get("package-suffix") or ""
PACKAGE_VERSION = version_info.get("package-version")
if is_dev_build:
    PACKAGE_VERSION += f"+{git_versions.get('IREE') or '0'}"


def get_cmake_version_info_args():
    version_info_args = [
        f"-DIREE_RELEASE_VERSION:STRING={PACKAGE_VERSION}",
        f"-DIREE_RELEASE_REVISION:STRING={git_versions.get('IREE') or '0'}",
    ]
    if version_info:
        version_info_args.append("-DIREE_EMBEDDED_RELEASE_INFO=ON")
    return version_info_args


def maybe_nuke_cmake_cache():
    # From run to run under pip, we can end up with different paths to ninja,
    # which isn't great and will confuse cmake. Detect if the location of
    # ninja changes and force a cache flush.
    ninja_path = ""
    try:
        import ninja
    except ModuleNotFoundError:
        pass
    else:
        ninja_path = ninja.__file__
    expected_stamp_contents = f"{sys.executable}\n{ninja_path}"

    # In order to speed things up on CI and not rebuild everything, we nuke
    # the CMakeCache.txt file if the path to the Python interpreter changed.
    # Ideally, CMake would let us reconfigure this dynamically... but it does
    # not (and gets very confused).
    # We only do this because the compiler is so expensive to build and very
    # little of it depends on the Python version. This is a hack.
    PYTHON_STAMP_FILE = os.path.join(IREE_BINARY_DIR, "python_stamp.txt")
    if os.path.exists(PYTHON_STAMP_FILE):
        with open(PYTHON_STAMP_FILE, "rt") as f:
            actual_stamp_contents = f.read()
            if actual_stamp_contents == expected_stamp_contents:
                # All good.
                return

    # Mismatch or not found. Clean it.
    cmake_cache_file = os.path.join(IREE_BINARY_DIR, "CMakeCache.txt")
    if os.path.exists(cmake_cache_file):
        print("Removing CMakeCache.txt because Python version changed", file=sys.stderr)
        os.remove(cmake_cache_file)

    # Also clean the install directory. This avoids version specific pileups
    # of binaries that can occur with repeated builds against different
    # Python versions.
    if os.path.exists(CMAKE_INSTALL_DIR_ABS):
        print(
            f"Removing CMake install dir because Python version changed: "
            f"{CMAKE_INSTALL_DIR_ABS}",
            file=sys.stderr,
        )
        shutil.rmtree(CMAKE_INSTALL_DIR_ABS)

    # And write.
    with open(PYTHON_STAMP_FILE, "wt") as f:
        f.write(expected_stamp_contents)


def get_env_cmake_option(name: str, default_value: str = "OFF") -> str:
    svalue = os.getenv(name)
    if not svalue:
        svalue = default_value
    return f"-D{name}={svalue}"


def add_env_cmake_setting(args, env_name: str, cmake_name=None) -> str:
    svalue = os.getenv(env_name)
    if svalue is not None:
        if not cmake_name:
            cmake_name = env_name
        args.append(f"-D{cmake_name}={svalue}")


def prepare_installation():
    version_py_content = generate_version_py()
    print(f"Generating version.py:\n{version_py_content}", file=sys.stderr)

    cfg = os.getenv("IREE_CMAKE_BUILD_TYPE", "Release")
    strip_install = cfg == "Release"

    if not IS_CONFIGURED:
        # Build from source tree.
        subprocess.check_call(["cmake", "--version"])
        os.makedirs(IREE_BINARY_DIR, exist_ok=True)
        maybe_nuke_cmake_cache()
        print(f"CMake build dir: {IREE_BINARY_DIR}", file=sys.stderr)
        print(f"CMake install dir: {CMAKE_INSTALL_DIR_ABS}", file=sys.stderr)
        cmake_args = [
            "-GNinja",
            "--log-level=VERBOSE",
            "-DIREE_BUILD_PYTHON_BINDINGS=ON",
            "-DIREE_BUILD_SAMPLES=OFF",
            "-DIREE_BUILD_TESTS=OFF",
            # Disable .so.0 style symlinking. Python wheels don't preserve links,
            # so this ~doubles the binary size if not disabled (yikes!).
            "-DCMAKE_PLATFORM_NO_VERSIONED_SONAME=ON",
            "-DPython3_EXECUTABLE={}".format(sys.executable),
            "-DCMAKE_BUILD_TYPE={}".format(cfg),
            # TODO(scotttodd): include IREE_TARGET_BACKEND_WEBGPU_SPIRV here (and in env)
            get_env_cmake_option("IREE_TARGET_BACKEND_ROCM", "OFF"),
            get_env_cmake_option("IREE_TARGET_BACKEND_CUDA", "OFF"),
            get_env_cmake_option("IREE_ENABLE_LLD", "OFF"),
        ]
        if _is_abi3_build:
            cmake_args.append("-DIREE_ENABLE_PYTHON_STABLE_ABI=ON")
        cmake_args.extend(get_cmake_version_info_args())

        # These usually flow through the environment, but we add them explicitly
        # so that they show clearly in logs (getting them wrong can have bad
        # outcomes).
        add_env_cmake_setting(cmake_args, "CMAKE_OSX_ARCHITECTURES")
        add_env_cmake_setting(
            cmake_args, "MACOSX_DEPLOYMENT_TARGET", "CMAKE_OSX_DEPLOYMENT_TARGET"
        )

        # Only do a from-scratch configure if not already configured.
        cmake_cache_file = os.path.join(IREE_BINARY_DIR, "CMakeCache.txt")
        if not os.path.exists(cmake_cache_file):
            print(f"Configuring with: {cmake_args}", file=sys.stderr)
            subprocess.check_call(
                ["cmake", IREE_SOURCE_DIR] + cmake_args, cwd=IREE_BINARY_DIR
            )
        else:
            print(f"Not re-configuring (already configured)", file=sys.stderr)

        # Build.
        subprocess.check_call(["cmake", "--build", "."], cwd=IREE_BINARY_DIR)
        print("Build complete.", file=sys.stderr)

    # Perform installation on the entire compiler/ tree as this is guaranteed
    # to have all of our installation targets.
    install_subdirectory = os.path.join(IREE_BINARY_DIR)
    install_args = [
        f"-DCMAKE_INSTALL_PREFIX={CMAKE_INSTALL_DIR_ABS}",
        "-P",
        os.path.join(install_subdirectory, "cmake_install.cmake"),
    ]
    if strip_install:
        install_args.append("-DCMAKE_INSTALL_DO_STRIP=ON")
    print(f"Installing with: {install_args}", file=sys.stderr)
    subprocess.check_call(["cmake"] + install_args, cwd=install_subdirectory)

    # Write version.py directly into install dir.
    version_py_file = os.path.join(
        CMAKE_INSTALL_DIR_ABS,
        "python_packages",
        "iree_compiler",
        "iree",
        "compiler",
        "version.py",
    )
    os.makedirs(os.path.dirname(version_py_file), exist_ok=True)
    with open(version_py_file, "wt") as f:
        f.write(version_py_content)

    print(f"Installation prepared: {CMAKE_INSTALL_DIR_ABS}", file=sys.stderr)


class CMakeBuildPy(_build_py):
    def run(self):
        # It is critical that the target directory contain all built extensions,
        # or else setuptools will helpfully compile an empty binary for us
        # (this is the **worst** possible thing it could do). We just copy
        # everything. What's another hundred megs between friends?
        target_dir = os.path.abspath(self.build_lib)
        print(f"Building in target dir: {target_dir}", file=sys.stderr)
        os.makedirs(target_dir, exist_ok=True)
        print("Copying install to target.", file=sys.stderr)
        if os.path.exists(target_dir):
            shutil.rmtree(target_dir)
        shutil.copytree(
            os.path.join(CMAKE_INSTALL_DIR_ABS, "python_packages", "iree_compiler"),
            target_dir,
            symlinks=self.editable_mode,
        )
        print("Target populated.", file=sys.stderr)


class CustomBuild(_build):
    def run(self):
        self.run_command("build_py")
        self.run_command("build_ext")
        self.run_command("build_scripts")


class CMakeExtension(Extension):
    def __init__(self, name, sourcedir="", **kwargs):
        Extension.__init__(self, name, sources=[], **kwargs)
        self.sourcedir = os.path.abspath(sourcedir)


class NoopBuildExtension(_build_ext):
    def __init__(self, *args, **kwargs):
        assert False

    def build_extension(self, ext):
        pass


# I don't know. Something about the CMake 'install' is producing a .egg-info/
# folder, which then get picked up by the .whl. For release wheels all we need
# is a .dist-info/ folder, so delete the .egg-info/ folder.
#
# * Notes: https://github.com/iree-org/iree/issues/19155
# * Implementation inspirted by https://stackoverflow.com/a/70146326
class CleanEggInfo(egg_info):
    def run(self):
        install_dir = os.path.join(
            CMAKE_INSTALL_DIR_ABS, "python_packages", "iree_compiler"
        )
        print(f"CleanEggInfo checking install_dir '{install_dir}'")
        for d in Path(install_dir).glob("*.egg-info"):
            print(f"found egg-info path '{d}', deleting")
            shutil.rmtree(d, ignore_errors=True)

        egg_info.run(self)


# Override bdist_wheel to produce abi3 wheel tags when applicable.
_bdist_wheel_cmdclass = {}
try:
    from wheel.bdist_wheel import bdist_wheel as _bdist_wheel

    class bdist_wheel(_bdist_wheel):
        def get_tag(self):
            python, abi, plat = _bdist_wheel.get_tag(self)
            if _is_abi3_build:
                python, abi = "cp312", "abi3"
            return python, abi, plat

    _bdist_wheel_cmdclass = {"bdist_wheel": bdist_wheel}
except ImportError:
    # wheel package not available (e.g., during sdist). Not an error.
    pass


def generate_version_py():
    return f"""# Auto-generated version info.
PACKAGE_SUFFIX = "{PACKAGE_SUFFIX}"
VERSION = "{PACKAGE_VERSION}"
REVISIONS = {json.dumps(git_versions)}
"""


def find_git_versions():
    revisions = {}
    try:
        revisions["IREE"] = (
            subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=IREE_SOURCE_DIR)
            .decode("utf-8")
            .strip()
        )
    except subprocess.SubprocessError as e:
        print(f"ERROR: Could not get IREE revision: {e}", file=sys.stderr)
    revisions["LLVM_PROJECT"] = find_git_submodule_revision("third_party/llvm-project")
    revisions["STABLEHLO"] = find_git_submodule_revision("third_party/stablehlo")
    return revisions


def find_git_submodule_revision(submodule_path):
    try:
        data = (
            subprocess.check_output(
                ["git", "ls-tree", "HEAD", submodule_path], cwd=IREE_SOURCE_DIR
            )
            .decode("utf-8")
            .strip()
        )
        columns = re.split("\\s+", data)
        return columns[2]
    except Exception as e:
        print(
            f"ERROR: Could not get submodule revision for {submodule_path}" f" ({e})",
            file=sys.stderr,
        )
        return ""


prepare_installation()

packages = find_namespace_packages(
    where=os.path.join(CMAKE_INSTALL_DIR_ABS, "python_packages", "iree_compiler"),
    include=[
        "iree.compiler",
        "iree.compiler.*",
    ],
)
print(f"Found compiler packages: {packages}")

with open(
    os.path.join(
        IREE_SOURCE_DIR,
        "compiler",
        "bindings",
        "python",
        "iree",
        "compiler",
        "README.md",
    ),
    "rt",
) as f:
    README = f.read()

custom_package_suffix = os.getenv("IREE_COMPILER_CUSTOM_PACKAGE_SUFFIX", "")
custom_package_prefix = os.getenv("IREE_COMPILER_CUSTOM_PACKAGE_PREFIX", "")

setup(
    name=f"{custom_package_prefix}iree-base-compiler{custom_package_suffix}{PACKAGE_SUFFIX}",
    version=f"{PACKAGE_VERSION}",
    author="IREE Authors",
    author_email="iree-technical-discussion@lists.lfaidata.foundation",
    description="IREE Python Compiler API",
    long_description=README,
    long_description_content_type="text/markdown",
    license="Apache-2.0",
    classifiers=[
        "Development Status :: 3 - Alpha",
        "License :: OSI Approved :: Apache Software License",
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.10",
        "Programming Language :: Python :: 3.11",
        "Programming Language :: Python :: 3.12",
        "Programming Language :: Python :: 3.13",
    ],
    project_urls={
        "homepage": "https://iree.dev/",
        "repository": "https://github.com/iree-org/iree",
        "documentation": "https://iree.dev/reference/bindings/python/",
    },
    ext_modules=[
        CMakeExtension("iree.compiler._mlir_libs._mlir", py_limited_api=_is_abi3_build),
        # TODO: MHLO has been broken for a while so disabling. If re-enabling,
        # it also needs to be enabled on the build side.
        # CMakeExtension("iree.compiler._mlir_libs._mlirHlo"),
        CMakeExtension(
            "iree.compiler._mlir_libs._mlirLinalgPasses", py_limited_api=_is_abi3_build
        ),
        CMakeExtension(
            "iree.compiler._mlir_libs._mlirGPUPasses", py_limited_api=_is_abi3_build
        ),
        CMakeExtension(
            "iree.compiler._mlir_libs._site_initialize_0", py_limited_api=_is_abi3_build
        ),
    ],
    cmdclass=dict(
        {
            "build": CustomBuild,
            "built_ext": NoopBuildExtension,
            "build_py": CMakeBuildPy,
            "egg_info": CleanEggInfo,
        },
        **_bdist_wheel_cmdclass,
    ),
    zip_safe=False,
    package_dir={
        # Note: Must be relative path, so we line this up with the absolute
        # path built above. Note that this must exist prior to the call.
        "": f"{CMAKE_INSTALL_DIR_REL}/python_packages/iree_compiler",
    },
    packages=packages,
    entry_points={
        "console_scripts": [
            "iree-build = iree.build.__main__:main",
            "iree-compile = iree.compiler.tools.scripts.iree_compile.__main__:main",
            "iree-import-onnx = iree.compiler.tools.import_onnx.__main__:_cli_main",
            "iree-ir-tool = iree.compiler.tools.ir_tool.__main__:_cli_main",
            "iree-link = iree.compiler.tools.scripts.iree_link.__main__:main",
            "iree-opt = iree.compiler.tools.scripts.iree_opt.__main__:main",
        ],
    },
    install_requires=[
        "numpy",
        "sympy",
    ],
    extras_require={
        "onnx": [
            "onnx>=1.16.0",
        ],
    },
)
