Skip to content

Gradle/KSP plugin to compile Kotlin/Native to Python C API

License

Notifications You must be signed in to change notification settings

Martmists-GH/kpy-plugin

Repository files navigation

KPy Plugin

The KPy gradle plugin allows you to write Kotlin/Native code and use it from python.

Note: Modules built with KPy still require XCode when building on macOS, this is a Kotlin/Native limitation.

A huge thank you to the indygreg/python-build-standalone project for providing prebuilt python binaries to build against. This project would be impossible to maintain without it.

Features

Implemented

  • Export Kotlin/Native functions and classes without having to touch the Python API directly
  • Convert between Kotlin and Python types with .toPython() and .toKotlin()
  • Conversions handled mostly automatically
  • Class inheritance mapped to python
  • Generate Python stubs
  • Catch Kotlin exceptions and raise them as Python exceptions

Setup

Change your gradle version to 7.5 (nightly builds only as of writing) Enable the plugin in your build.gradle.kts file:

plugins {
    kotlin("multiplatform") version "2.0.0"
    id("com.martmists.kpy.kpy-plugin") version "1.0.1"
}

kotlin {
    val hostOs = System.getProperty("os.name")
    val isMingwX64 = hostOs.startsWith("Windows")
    val isArm64 = System.getProperty("os.arch") == "aarch64"
    // You can rename the target from `native` to something else, 
    // but make sure to also change setup.py to match this change!
    val nativeTarget = when {
        hostOs == "Mac OS X" && !isArm64 -> macosX64("native")
        hostOs == "Linux" && !isArm64 -> linuxX64("native")
        hostOs == "Mac OS X" && isArm64 -> macosArm64("native")
        hostOs == "Linux" && isArm64 -> linuxArm64("native")
        isMingwX64 -> mingwX64("native")
        else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
    }
}

Use the following setup.py template (note: may be outdated, see kpy-sample for an up-to-date example):

from os.path import dirname, abspath
from platform import system
from setuptools import setup, Extension, find_packages
from subprocess import Popen, PIPE

osname = system()
debug = False  # Debug currently has some issues
dir_name = dirname(abspath(__file__))

if osname == "Linux" or osname == "Darwin":
    gradle_bin = "./gradlew"
else:
    gradle_bin = ".\\gradlew.bat"

# Build the project
proc = Popen([gradle_bin, "build"])
if proc.wait() != 0:
    raise Exception("Build failed")

# Fetch configuration from gradle task
proc = Popen([gradle_bin, "setupMetadata"], stdout=PIPE)
if proc.wait() != 0:
    raise Exception("Failed to fetch metadata")
output = proc.stdout.read().decode()
real_output = output.split("===METADATA START===")[1].split("===METADATA END===")[0]

exec(real_output, globals(), locals())
# Types of variables from gradle metadata
has_stubs: bool
project_name: str
module_name: str
project_version: str
build_dir: str
root_dir: str
target: str

print("name: " + project_name)
print("version: " + project_version)


def snake_case(name):
    return name.replace("-", "_").lower()


def extensions():
    folder = "debugStatic" if debug else "releaseStatic"
    prefix = "_" if has_stubs else ""
    native = Extension(prefix + module_name,
                       sources=[f'{build_dir}/generated/ksp/{target}/{target}Main/resources/entrypoint.cpp'],
                       include_dirs=[f"{build_dir}/bin/{target}/{folder}/"],
                       library_dirs=[f"{build_dir}/bin/{target}/{folder}/"],
                       libraries=[project_name])

    return [native]


with open("README.md", "r") as fh:
    long_description = fh.read()


attrs = {}

if has_stubs:
    stub_root = f'{build_dir}/generated/ksp/{target}/{target}Main/resources'
    attrs["packages"] = find_packages(where=stub_root)
    attrs["package_dir"] = {"": stub_root}
else:
    attrs["packages"] = []

setup(
    name=module_name,
    version=project_version,
    description=long_description,
    ext_modules=extensions(),
    **attrs
)

Configuration

To configure the plugin, you can use the kpy configuration.

kpy {
    // Pass properties to setup.py, the exec() command will pass them to the context
    // Note: the second parameter is an expression, and must be valid python.
    metadata("my_key", "'my' + 'value'")  // in setup.py you can now use my_key and it evaluates to 'myvalue'

    // Specify the python version to build against.
    // Currently supported: [3.9, 3.10]
    pyVersion.set(PythonVersion.Py310)

    // Generate python stubs for the native sources
    // These are stored to `build/generated/ksp/<target>/<target>Main/resources/`
    // Note: these will be overwritten every time you build the project
    generateStubs.set(true)
}