#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

"""
Make Python wheel package (`*.whl`) file from Blender built with 'WITH_PYTHON_MODULE' enabled.

Example
=======

If the "bpy" module was build on Linux using the command:

   make bpy lite

The command to package it as a wheel is:

   ./build_files/utils/make_bpy_wheel.py ../build_linux_bpy_lite/bin --output-dir=./

This will create a `*.whl` file in the current directory.

WARNING:
Python 3.9 is used on the built-bot.
Take care *not* to use features from the Python version used by Blender!

NOTE:
Some type annotations are quoted to avoid errors in Python 3.9.
These can be unquoted eventually.
"""
__all__ = (
    "main",
)

import argparse
import make_utils
import os
import re
import platform
import string
import setuptools
import sys

from typing import (
    Tuple,
    # Proxies for `collections.abc`
    Iterator,
    Sequence,
)


# ------------------------------------------------------------------------------
# Long Description

long_description = """# Blender

[Blender](https://www.blender.org) is the free and open source 3D creation suite. It supports the entirety of the 3D pipeline: modeling, rigging, animation, simulation, rendering, compositing and motion tracking, even video editing.

This package provides Blender as a Python module for use in studio pipelines, web services, scientific research, and more.

### Archived Versions

Blender versions outside the current LTS window are removed from PyPI but are available at [https://download.blender.org/pypi/bpy/](https://download.blender.org/pypi/bpy/).

These versions can still be installed manually. For example, to install version 3.6.0:

```bash
pip install bpy==3.6.0 --extra-index-url https://download.blender.org/pypi/
```

## Documentation

* [Blender Python API](https://docs.blender.org/api/current/)
* [Blender as a Python Module](https://docs.blender.org/api/current/info_advanced_blender_as_bpy.html)

## Requirements

[System requirements](https://www.blender.org/download/requirements/) are the same as Blender.

Each Blender release supports one Python version, and the package is only compatible with that version.

## Source Code

* [Releases](https://download.blender.org/source/)
* Repository: [projects.blender.org/blender/blender.git](https://projects.blender.org/blender/blender)

## Credits

Created by the [Blender developer community](https://www.blender.org/about/credits/).

Thanks to Tyler Alden Gubala for maintaining the original version of this package."""

# ------------------------------------------------------------------------------
# Generic Functions


def find_dominating_file(
    path: str,
    search: Sequence[str],
) -> str:
    while True:
        for d in search:
            if os.path.exists(os.path.join(path, d)):
                return os.path.join(path, d)
        path_next = os.path.normpath(os.path.join(path, ".."))
        if path == path_next:
            break
        path = path_next
    return ""


# ------------------------------------------------------------------------------
# CMake Cache Access

def cmake_cache_var_iter(filepath_cmake_cache: str) -> Iterator[Tuple[str, str, str]]:
    re_cache = re.compile(r"([A-Za-z0-9_\-]+)?:?([A-Za-z0-9_\-]+)?=(.*)$")
    with open(filepath_cmake_cache, "r", encoding="utf-8") as cache_file:
        for l in cache_file:
            match = re_cache.match(l.strip())
            if match is not None:
                var, type_, val = match.groups()
                yield (var, type_ or "", val)


def cmake_cache_var(filepath_cmake_cache: str, var: str) -> "str | None":
    for var_iter, _type_iter, value_iter in cmake_cache_var_iter(filepath_cmake_cache):
        if var == var_iter:
            return value_iter
    return None


def cmake_cache_var_or_exit(filepath_cmake_cache: str, var: str) -> str:
    value = cmake_cache_var(filepath_cmake_cache, var)
    if value is None:
        sys.stderr.write("Unable to find %r in %r, abort!\n" % (var, filepath_cmake_cache))
        sys.exit(1)
    return value


# ------------------------------------------------------------------------------
# Argument Parser

def argparse_create() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument(
        "install_dir",
        metavar='INSTALL_DIR',
        type=str,
        help="The installation directory containing the \"bpy\" package.",
    )
    parser.add_argument(
        "--build-dir",
        metavar='BUILD_DIR',
        default=None,
        help="The build directory containing 'CMakeCache.txt' (search parent directories of INSTALL_DIR when omitted).",
        required=False,
    )
    parser.add_argument(
        "--output-dir",
        metavar='OUTPUT_DIR',
        default=None,
        help="The destination directory for the '*.whl' file (use INSTALL_DIR when omitted).",
        required=False,
    )

    return parser


# ------------------------------------------------------------------------------
# Main Function

def main() -> None:

    # NOTE: Import inline because the built-bot runs this with Python 3.6
    # which fails to import `wheel`, the actual script should run with a newer Python.

    # `wheel.bdist_wheel` is deprecated, `setuptools >= 70.1` includes it.
    if tuple(int(x) for x in setuptools.__version__.split(".")[:2]) >= (70, 1):
        from setuptools.command.bdist_wheel import bdist_wheel
    else:
        from wheel.bdist_wheel import bdist_wheel

    # Parse arguments.
    args = argparse_create().parse_args()

    install_dir = os.path.abspath(args.install_dir)
    output_dir = os.path.abspath(args.output_dir) if args.output_dir else install_dir

    if args.build_dir:
        build_dir = os.path.abspath(args.build_dir)
        filepath_cmake_cache = os.path.join(build_dir, "CMakeCache.txt")
        del build_dir
        if not os.path.exists(filepath_cmake_cache):
            sys.stderr.write("File not found %r, abort!\n" % filepath_cmake_cache)
            sys.exit(1)
    else:
        filepath_cmake_cache = find_dominating_file(install_dir, ("CMakeCache.txt",))
        if not filepath_cmake_cache:
            # Should never fail.
            sys.stderr.write("Unable to find CMakeCache.txt in or above %r, abort!\n" % install_dir)
            sys.exit(1)

    # Get the major and minor Python version.
    python_version = cmake_cache_var_or_exit(filepath_cmake_cache, "PYTHON_VERSION")
    python_version_number = (
        tuple(int("".join(c for c in digit if c in string.digits)) for digit in python_version.split(".")) +
        # Support version without a minor version "3" (add zero).
        tuple((0, 0, 0))
    )

    # Get Blender version.
    blender_version_str = str(make_utils.parse_blender_version())

    # Set platform tag following conventions.
    if sys.platform == "darwin":
        target = cmake_cache_var_or_exit(filepath_cmake_cache, "CMAKE_OSX_DEPLOYMENT_TARGET").split(".")
        # Minor version is expected to be always zero starting with macOS 11.
        # https://github.com/pypa/packaging/issues/435
        target_major = int(target[0])
        target_minor = 0  # int(target[1])
        machine = cmake_cache_var_or_exit(filepath_cmake_cache, "CMAKE_OSX_ARCHITECTURES")
        platform_tag = "macosx_%d_%d_%s" % (target_major, target_minor, machine)
    elif sys.platform == "win32":
        # Workaround for Python process running in a virtualized environment on Windows-on-Arm:
        # use the actual processor architecture instead of the virtualized one.
        #
        # The win_arm64 matches the behavior when native WoA Python is used, and also matches
        # sysconfig.get_platform() from a native Python build (although it returns win-arm64 with a
        # dash and not underscore).
        if "ARM" in os.environ.get("PROCESSOR_IDENTIFIER", ""):
            platform_tag = "win_arm64"
        else:
            platform_tag = "win_%s" % (platform.machine().lower())
    elif sys.platform == "linux":
        glibc = os.confstr("CS_GNU_LIBC_VERSION")
        if glibc is None:
            sys.stderr.write("Unable to find \"CS_GNU_LIBC_VERSION\", abort!\n")
            sys.exit(1)
        glibc = "%s_%s" % tuple(glibc.split()[1].split(".")[:2])
        platform_tag = "manylinux_%s_%s" % (glibc, platform.machine().lower())
    else:
        sys.stderr.write("Unsupported platform: %s, abort!\n" % (sys.platform))
        sys.exit(1)

    # Manually specify, otherwise it uses the version of the executable used to run
    # this script which may not match the Blender python version.
    python_tag = "py%d%d" % (python_version_number[0], python_version_number[1])
    cpython_tag = "cp%d%d" % (python_version_number[0], python_version_number[1])

    os.chdir(install_dir)

    # Include all files recursively.
    def package_files(root_dir: str) -> list[str]:
        paths = []
        for path, dirs, files in os.walk(root_dir):
            paths += [os.path.join("..", path, f) for f in files]
        return paths

    # Ensure this wheel is marked platform specific.
    class BinaryDistribution(setuptools.dist.Distribution):
        def has_ext_modules(self) -> bool:
            return True

    # NOTE: this class is needed because:
    # - The Python used to build the wheel is the systems Python,
    #   so we can't rely on its "tag" matching Blender's.
    # - There is no way to override the "tag" using options.
    #   If this is supported at some point, this class can be removed.
    class TargetPythonBdistWheel(bdist_wheel):
        def get_tag(self) -> Tuple[str, str, str]:
            _python, _abi, plat = super().get_tag()
            return cpython_tag, cpython_tag, plat

    # Build wheel.
    sys.argv = [sys.argv[0], "bdist_wheel"]

    setuptools.setup(
        name="bpy",
        version=blender_version_str,

        install_requires=["cattrs", "cython", "numpy>=2.2,<3.0", "requests", "zstandard"],

        python_requires="==%d.%d.*" % (python_version_number[0], python_version_number[1]),
        packages=["bpy"],
        package_data={"": package_files("bpy")},
        distclass=BinaryDistribution,
        cmdclass={"bdist_wheel": TargetPythonBdistWheel},
        options={"bdist_wheel": {"plat_name": platform_tag, "python_tag": python_tag}},

        description="Blender as a Python module",
        long_description=long_description,
        long_description_content_type='text/markdown',
        license="GPL-3.0",
        author="Blender Foundation",
        url="https://www.blender.org"
    )

    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    # Move wheel to output directory.
    dist_dir = os.path.join(install_dir, "dist")
    for f in os.listdir(dist_dir):
        if f.endswith(".whl"):
            # The wheel is already tagged correctly (cpXY-cpXY-plat) by TargetPythonBdistWheel,
            # so only move it to the output directory.
            os.rename(os.path.join(dist_dir, f), os.path.join(output_dir, f))


if __name__ == "__main__":
    main()
