Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions kson-lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ tasks.register<PixiExecTask>("buildWithGraalVmNativeImage") {
val ksonCoreJarTask = project.rootProject.tasks.named<Jar>("jvmJar")
dependsOn(ksonLibJarTask, ksonCoreJarTask, "generateJniBindingsJvm")

val nativeImageOutputDir = project.projectDir.resolve("build/kotlin/compileGraalVmNativeImage")
val jniConfig = project.projectDir.resolve("build/kotlin/krossover/metadata/jni-config.json")

// ksonLibJarTask is this project's own JAR (not in its own runtime classpath).
// ksonCoreJarTask (root project) is a dependency, so it's covered by jvmRuntimeClasspath.
inputs.files(ksonLibJarTask.map { it.archiveFile })
inputs.files(configurations.named("jvmRuntimeClasspath"))
inputs.file(jniConfig)
// Declare the specific binary rather than the whole directory, since krossover's
// generateJniBindingsJvm also writes jni_simplified.h into nativeImageOutputDir.
outputs.file(nativeImageOutputDir.resolve(BinaryArtifactPaths.binaryFileName()))

// Configure the command at configuration time using providers
command.set(provider {
val graalHome = GraalVmHelper.getGraalVMHome()
Expand All @@ -277,7 +289,7 @@ tasks.register<PixiExecTask>("buildWithGraalVmNativeImage") {
}

// Ensure build dir exists
val buildDir = project.projectDir.resolve("build/kotlin/compileGraalVmNativeImage").toPath()
val buildDir = nativeImageOutputDir.toPath()
buildDir.createDirectories()
val buildArtifactPath = buildDir.resolve(BinaryArtifactPaths.binaryFileNameWithoutExtension()).toAbsolutePath().pathString

Expand Down Expand Up @@ -305,9 +317,6 @@ tasks.register<PixiExecTask>("buildWithGraalVmNativeImage") {
}
val classPath = jars.joinToString(cpSeparator)

// The `jniConfig` file tells graal which classes should be publicly exposed
val jniConfig = project.projectDir.resolve("build/kotlin/krossover/metadata/jni-config.json")

listOf(
nativeImageExe.absolutePath,
"--shared",
Expand Down
1 change: 1 addition & 0 deletions lib-python/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ kson-sdist
*.h
*.egg-info
*.pyd
LICENSE

# uvw generated files
.uv
14 changes: 7 additions & 7 deletions lib-python/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,16 @@ tasks {
exclude(".gradle/**")
}

// Copy lib-python (build files and source)
// Copy lib-python (build files and source, excluding native binaries
// which are platform-specific and must be built from source)
// Keep in sync with PLATFORM_NATIVE_LIBRARIES in build_backend.py
from(project.projectDir) {
into("lib-python")
include("build.gradle.kts")
include("src/**")
exclude("src/kson/kson.dll")
exclude("src/kson/libkson.dylib")
exclude("src/kson/libkson.so")
}
}

Expand All @@ -133,12 +138,7 @@ tasks {
errorOutput = System.err
isIgnoreExitValue = false

// Configure cibuildwheel
environment("CIBW_BUILD", "cp310-*") // Build for Python 3.10+
environment("CIBW_SKIP", "*-musllinux_*") // Skip musl Linux builds
environment("CIBW_ARCHS", "native") // Build only for native architecture
environment("CIBW_TEST_REQUIRES", "pytest") // Install pytest for testing
environment("CIBW_TEST_COMMAND", "pytest -v {project}/tests")
// cibuildwheel configuration lives in pyproject.toml under [tool.cibuildwheel]

doLast {
println("Successfully built platform-specific wheel using cibuildwheel")
Expand Down
110 changes: 68 additions & 42 deletions lib-python/build_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,31 @@
import os
import subprocess
import shutil
import sys
from pathlib import Path
from setuptools import build_meta as _orig
from setuptools.build_meta import *

# See also LIBRARY_NAMES in src/kson/__init__.py
PLATFORM_NATIVE_LIBRARIES = {
"win32": "kson.dll",
"darwin": "libkson.dylib",
"linux": "libkson.so",
}


def _platform_native_library():
"""Return the native library filename for the current platform."""
lib = PLATFORM_NATIVE_LIBRARIES.get(sys.platform)
if lib is None:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
return lib


def _required_artifacts():
"""Return the filenames that must be present for a working installation."""
return [_platform_native_library(), "jni_simplified.h"]


def _ensure_native_artifacts():
"""Build native artifacts using the bundled Gradle setup."""
Expand All @@ -18,55 +39,60 @@ def _ensure_native_artifacts():
src_dir = lib_python_dir / "src"
src_kson_dir = src_dir / "kson"

# Check if native artifacts already exist
native_files = ["kson.dll", "libkson.dylib", "libkson.so", "kson_api.h"]
artifacts_exist = any((src_kson_dir / f).exists() for f in native_files)
# Only check for the native library needed on *this* platform
required = _required_artifacts()
artifacts_exist = all((src_kson_dir / f).exists() for f in required)

if not artifacts_exist and kson_copy_dir.exists():
print("Building native artifacts with bundled Gradle setup...")
# Run gradle from the kson-sdist directory
original_dir = os.getcwd()
try:
os.chdir(kson_copy_dir)

# Run the Gradle build
gradlew = "./gradlew" if os.name != "nt" else "gradlew.bat"
result = subprocess.run(
[gradlew, "lib-python:build"], capture_output=True, text=True
)

if result.returncode != 0:
print(f"Gradle build failed:\n{result.stderr}")
raise RuntimeError("Failed to build native artifacts")

print("Native artifacts built successfully")

# Replace the entire src directory with the one from kson-sdist
kson_copy_src = kson_copy_dir / "lib-python" / "src"
if kson_copy_src.exists():
print("Replacing src directory with built artifacts...")
# Save _marker.c if it exists
marker_c = src_kson_dir / "_marker.c"
marker_c_content = None
if marker_c.exists():
marker_c_content = marker_c.read_bytes()

shutil.rmtree(src_dir, ignore_errors=True)
shutil.copytree(kson_copy_src, src_dir)

# Restore _marker.c if it existed
if marker_c_content is not None:
marker_c_new = src_kson_dir / "_marker.c"
marker_c_new.parent.mkdir(parents=True, exist_ok=True)
marker_c_new.write_bytes(marker_c_content)

finally:
os.chdir(original_dir)
print(f"Building native artifacts for {sys.platform} with bundled Gradle setup...")

gradlew = "./gradlew" if os.name != "nt" else "gradlew.bat"
result = subprocess.run(
[gradlew, "lib-python:build"],
capture_output=True,
text=True,
cwd=kson_copy_dir,
)

if result.returncode != 0:
print(f"Gradle build stdout:\n{result.stdout}")
print(f"Gradle build stderr:\n{result.stderr}")
raise RuntimeError("Failed to build native artifacts")

print("Native artifacts built successfully")

# Replace the entire src directory with the one from kson-sdist
kson_copy_src = kson_copy_dir / "lib-python" / "src"
if kson_copy_src.exists():
print("Replacing src directory with built artifacts...")
# Save _marker.c if it exists
marker_c = src_kson_dir / "_marker.c"
marker_c_content = None
if marker_c.exists():
marker_c_content = marker_c.read_bytes()

shutil.rmtree(src_dir, ignore_errors=True)
shutil.copytree(kson_copy_src, src_dir)

# Restore _marker.c if it existed
if marker_c_content is not None:
marker_c_new = src_kson_dir / "_marker.c"
marker_c_new.parent.mkdir(parents=True, exist_ok=True)
marker_c_new.write_bytes(marker_c_content)

# Clean up kson-sdist after successful build
print("Cleaning up build files...")
shutil.rmtree(kson_copy_dir, ignore_errors=True)

# Post-condition: verify all required artifacts are present
missing = [f for f in required if not (src_kson_dir / f).exists()]
if missing:
raise RuntimeError(
f"Required native artifacts missing for {sys.platform}: {', '.join(missing)}. "
f"Install from a pre-built wheel instead, or ensure a JDK is available "
f"so the Gradle build can produce them."
)


def build_sdist(sdist_directory, config_settings=None):
"""Build source distribution."""
Expand Down
18 changes: 18 additions & 0 deletions lib-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,29 @@ package-dir = {"" = "src"}
[tool.setuptools.package-data]
kson = ["kson_api.h", "kson.dll", "libkson.dylib", "libkson.so", "jni_simplified.h"]

[tool.pytest.ini_options]
pythonpath = ["."]

[tool.cibuildwheel]
# One wheel targeting 3.10 is sufficient: the abi3 stable ABI tag (see setup.py)
# makes it installable on any CPython >= 3.10.
build = "cp310-*"
skip = "*-musllinux_*"
archs = "native"
test-requires = "pytest"
test-command = "pytest -v {project}/tests --ignore={project}/tests/test_build_backend.py"

[tool.cibuildwheel.macos]
# Pin deployment target so builds don't break when the host macOS is
# newer than what the Python packaging tools recognize yet.
environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" }

[dependency-groups]
dev = [
"pyright>=1.1.403",
"pytest>=8.4.1",
"ruff>=0.12.8",
"setuptools>=61.0",
"cibuildwheel>=2.16.0",
"twine>=6.2.0",
]
137 changes: 137 additions & 0 deletions lib-python/tests/test_build_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for the custom build backend's platform-aware native artifact detection."""

import subprocess
import sys
from unittest.mock import patch

import pytest

import build_backend


class TestPlatformNativeLibrary:
def test_darwin(self):
with patch.object(sys, "platform", "darwin"):
assert build_backend._platform_native_library() == "libkson.dylib"

def test_linux(self):
with patch.object(sys, "platform", "linux"):
assert build_backend._platform_native_library() == "libkson.so"

def test_windows(self):
with patch.object(sys, "platform", "win32"):
assert build_backend._platform_native_library() == "kson.dll"

def test_unsupported_platform(self):
with patch.object(sys, "platform", "freebsd"):
with pytest.raises(RuntimeError, match="Unsupported platform: freebsd"):
build_backend._platform_native_library()


class TestEnsureNativeArtifacts:
"""Verify that _ensure_native_artifacts only considers the current platform's library."""

def _place_all_required_artifacts(self, directory):
"""Place all required artifacts for the current platform."""
for f in build_backend._required_artifacts():
(directory / f).touch()

def test_skips_build_when_current_platform_artifacts_exist(self, tmp_path):
"""Build is skipped when all required artifacts for *this* platform are present."""
src_kson = tmp_path / "src" / "kson"
src_kson.mkdir(parents=True)
kson_sdist = tmp_path / "kson-sdist"
kson_sdist.mkdir()

self._place_all_required_artifacts(src_kson)

with patch.object(build_backend, "__file__", str(tmp_path / "build_backend.py")):
with patch.object(build_backend.subprocess, "run") as mock_run:
build_backend._ensure_native_artifacts()
mock_run.assert_not_called()

def test_triggers_build_when_only_foreign_artifact_exists(self, tmp_path):
"""Build is triggered when only a *different* platform's library is present."""
src_kson = tmp_path / "src" / "kson"
src_kson.mkdir(parents=True)
kson_sdist = tmp_path / "kson-sdist"
kson_sdist.mkdir()

# Place foreign platform libraries (but not the current one)
current_lib = build_backend._platform_native_library()
for lib in build_backend.PLATFORM_NATIVE_LIBRARIES.values():
if lib != current_lib:
(src_kson / lib).touch()
(src_kson / "jni_simplified.h").touch()

with patch.object(build_backend, "__file__", str(tmp_path / "build_backend.py")):
with patch.object(build_backend.subprocess, "run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="fail",
)
with pytest.raises(RuntimeError, match="Failed to build native artifacts"):
build_backend._ensure_native_artifacts()
mock_run.assert_called_once()

def test_triggers_build_when_header_missing(self, tmp_path):
"""Build is triggered when the native lib exists but jni_simplified.h is missing."""
src_kson = tmp_path / "src" / "kson"
src_kson.mkdir(parents=True)
kson_sdist = tmp_path / "kson-sdist"
kson_sdist.mkdir()

current_lib = build_backend._platform_native_library()
(src_kson / current_lib).touch()

with patch.object(build_backend, "__file__", str(tmp_path / "build_backend.py")):
with patch.object(build_backend.subprocess, "run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="fail",
)
with pytest.raises(RuntimeError, match="Failed to build native artifacts"):
build_backend._ensure_native_artifacts()
mock_run.assert_called_once()

def test_errors_when_artifacts_missing_and_no_kson_sdist(self, tmp_path):
"""Raises when artifacts are missing and there's no kson-sdist to build from."""
src_kson = tmp_path / "src" / "kson"
src_kson.mkdir(parents=True)

with patch.object(build_backend, "__file__", str(tmp_path / "build_backend.py")):
with pytest.raises(RuntimeError, match="Required native artifacts missing"):
build_backend._ensure_native_artifacts()

def test_successful_build_replaces_src_and_preserves_marker(self, tmp_path):
"""On successful build, src is replaced with kson-sdist output and _marker.c is preserved."""
src_kson = tmp_path / "src" / "kson"
src_kson.mkdir(parents=True)
kson_sdist = tmp_path / "kson-sdist"

# Set up the kson-sdist build output that Gradle would produce
native_lib = build_backend._platform_native_library()
kson_sdist_src = kson_sdist / "lib-python" / "src" / "kson"
kson_sdist_src.mkdir(parents=True)
(kson_sdist_src / "__init__.py").write_text("# built")
(kson_sdist_src / native_lib).write_bytes(b"built-lib")
(kson_sdist_src / "jni_simplified.h").write_text("/* header */")

# Place _marker.c in the original src (should be preserved)
marker_content = b"/* platform marker */"
(src_kson / "_marker.c").write_bytes(marker_content)

with patch.object(build_backend, "__file__", str(tmp_path / "build_backend.py")):
with patch.object(build_backend.subprocess, "run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr="",
)
build_backend._ensure_native_artifacts()

# src was replaced with kson-sdist output
assert (tmp_path / "src" / "kson" / "__init__.py").read_text() == "# built"
assert (tmp_path / "src" / "kson" / native_lib).read_bytes() == b"built-lib"

# _marker.c was preserved
assert (tmp_path / "src" / "kson" / "_marker.c").read_bytes() == marker_content

# kson-sdist was cleaned up
assert not kson_sdist.exists()
Loading