Skip to content
Merged
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: 17 additions & 0 deletions docs/platforms/python-version-acquisition.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,20 @@ tasks.register<Exec>("compileExt") {
commandLine("cc", "-I${inc.get().asFile}", "-c", "ext.c")
}
```

### Link libraries

The same extension exposes the library to link against (issue #56):
`libraryDir(target, flavour)` (`Provider<Directory>`, the `-L` directory) and
`libraryFile(target, flavour)` (`Provider<RegularFile>`), both carrying the `downloadPython_*` task.

| Target | Link library (under `<ver>/`) |
|---|---|
| `android-aarch64`, `android-x86_64` | `<target>/prefix/lib/libpython3.14.so` |
| `windows-x86_64` | `windows-x86_64/python/libs/python314.lib` (`python3.lib` is the stable-ABI one) |
| `windows-x86_64` free-threaded | `windows-x86_64-freethreaded/python/libs/python314t.lib` |
| macOS, Linux, iOS | none -- `isLinkRequired(target)` is false; `libraryDir`/`libraryFile` throw `GradleException` |

Android has no free-threaded build, so that combination throws. `isLinkAvailable(target, flavour)`
tests without throwing; the other flavour fails naming `-PpythonFreeThreaded`, as for `includeDir`.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package python.multiplatform.gradle

import org.gradle.api.GradleException
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider

Expand Down Expand Up @@ -55,6 +56,38 @@ object CPythonIncludeLayout {
else -> "ios/Python.xcframework/$target/include/python$tag"
}
}

// ---- link libraries (issue #56) ----

/** Android links `libpython3.x.so`, Windows links the import library; macOS/Linux/iOS need none. */
fun isLinkRequired(target: String): Boolean = target in androidTargets || target == "windows-x86_64"

/** Free-threaded link libraries exist on Windows only (`python314t.lib`); Android has no free-threaded build. */
fun supportsLinkLibrary(target: String, flavour: CPythonFlavour): Boolean =
isLinkRequired(target) && (flavour == CPythonFlavour.GIL || target == "windows-x86_64")

/** Directory holding the link library, relative to the version-keyed extraction root. */
fun linkLibraryDirRelativePath(target: String, flavour: CPythonFlavour): String {
require(supportsLinkLibrary(target, flavour)) { "no $flavour link library for target '$target'" }
return when (target) {
"windows-x86_64" ->
(if (flavour == CPythonFlavour.FREE_THREADED) "windows-x86_64-freethreaded" else "windows-x86_64") +
"/python/libs"
else -> "$target/prefix/lib"
}
}

/** File name of the link library, e.g. `libpython3.14.so` or `python314.lib` (`python314t.lib` free-threaded). */
fun linkLibraryFileName(target: String, flavour: CPythonFlavour, pythonVersion: String): String {
require(supportsLinkLibrary(target, flavour)) { "no $flavour link library for target '$target'" }
val parts = pythonVersion.split('.')
require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" }
return if (target == "windows-x86_64") {
"python${parts[0]}${parts[1]}" + (if (flavour == CPythonFlavour.FREE_THREADED) "t" else "") + ".lib"
} else {
"libpython${parts[0]}.${parts[1]}.so"
}
}
}

/**
Expand Down Expand Up @@ -96,4 +129,39 @@ class CPythonIncludeDirectories(
// `TaskProvider.map` keeps the task as the producer, so the dependency travels with the value.
return task.map { extractedRoot.dir(relative) }
}

/** Whether linking against CPython is needed at all for [target] (false on macOS, Linux and iOS). */
fun isLinkRequired(target: String): Boolean = CPythonIncludeLayout.isLinkRequired(target)

/** Whether [libraryDir]/[libraryFile] would succeed for this pair in this build. */
fun isLinkAvailable(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Boolean =
CPythonIncludeLayout.supportsLinkLibrary(target, flavour) && extractionTask(target, flavour) != null

private fun linkTask(target: String, flavour: CPythonFlavour): TaskProvider<*> {
if (!CPythonIncludeLayout.supportsLinkLibrary(target, flavour)) {
throw GradleException(
"No $flavour CPython link library for '$target'. Only android-* and windows-x86_64 link against " +
"CPython; macOS, Linux and iOS need none (isLinkRequired=false); free-threaded exists on Windows only.",
)
}
return extractionTask(target, flavour) ?: throw GradleException(
"The $flavour CPython for '$target' is not extracted by this build. " +
"Only the flavour selected with -PpythonFreeThreaded is downloaded.",
)
}

/** The directory holding the link library (the `-L` directory); resolving it runs the extraction task first. */
fun libraryDir(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Provider<Directory> {
val task = linkTask(target, flavour)
val relative = CPythonIncludeLayout.linkLibraryDirRelativePath(target, flavour)
return task.map { extractedRoot.dir(relative) }
}

/** The link library file itself (`libpython3.14.so` / `python314.lib`); carries the extraction task. */
fun libraryFile(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Provider<RegularFile> {
val task = linkTask(target, flavour)
val relative = CPythonIncludeLayout.linkLibraryDirRelativePath(target, flavour) + "/" +
CPythonIncludeLayout.linkLibraryFileName(target, flavour, pythonVersion)
return task.map { extractedRoot.file(relative) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,76 @@ class CPythonIncludeDirectoriesTest {
assertTrue(dirs.isAvailable("linux-x86_64", CPythonFlavour.FREE_THREADED))
assertTrue(dirs.includeDir("linux-x86_64", CPythonFlavour.FREE_THREADED).get().asFile.path.endsWith("python3.14t"))
}

// ---- Issue #56: link-library directory ----

@Test
fun linkLibraryLayoutMatchesTheObservedTree() {
val l = CPythonIncludeLayout
assertEquals("android-aarch64/prefix/lib", l.linkLibraryDirRelativePath("android-aarch64", CPythonFlavour.GIL))
assertEquals("android-x86_64/prefix/lib", l.linkLibraryDirRelativePath("android-x86_64", CPythonFlavour.GIL))
assertEquals("windows-x86_64/python/libs", l.linkLibraryDirRelativePath("windows-x86_64", CPythonFlavour.GIL))
assertEquals("windows-x86_64-freethreaded/python/libs", l.linkLibraryDirRelativePath("windows-x86_64", CPythonFlavour.FREE_THREADED))
assertEquals("libpython3.14.so", l.linkLibraryFileName("android-aarch64", CPythonFlavour.GIL, version))
assertEquals("python314.lib", l.linkLibraryFileName("windows-x86_64", CPythonFlavour.GIL, version))
assertEquals("python314t.lib", l.linkLibraryFileName("windows-x86_64", CPythonFlavour.FREE_THREADED, version))
}

@Test
fun linkLibraryIsRequiredOnlyOnAndroidAndWindows() {
val l = CPythonIncludeLayout
for (t in listOf("android-aarch64", "android-x86_64", "windows-x86_64")) assertTrue(l.isLinkRequired(t), t)
for (t in listOf("macos-aarch64", "macos-x86_64", "linux-x86_64", "ios-arm64", "ios-arm64_x86_64-simulator")) {
assertFalse(l.isLinkRequired(t), t)
}
}

@Test
fun linkLibraryFreeThreadedOnlyOnWindows() {
val l = CPythonIncludeLayout
assertTrue(l.supportsLinkLibrary("windows-x86_64", CPythonFlavour.FREE_THREADED))
assertFalse(l.supportsLinkLibrary("android-aarch64", CPythonFlavour.FREE_THREADED))
assertFalse(l.supportsLinkLibrary("macos-aarch64", CPythonFlavour.GIL))
assertFailsWith<IllegalArgumentException> { l.linkLibraryDirRelativePath("macos-aarch64", CPythonFlavour.GIL) }
}

@Test
fun linkLibraryProvidersCarryTheExtractionTaskAndResolve() {
val (project, dirs) = fixture(CPythonFlavour.GIL)
val root = project.layout.projectDirectory.dir("extracted/$version")
for (t in dirs.targets.filter { dirs.isLinkRequired(it) }) {
val producer = project.tasks.getByName("fake_${t.replace('-', '_')}")
val dirProvider = dirs.libraryDir(t)
val fileProvider = dirs.libraryFile(t)
val consumer = project.tasks.register("link_${t.replace('-', '_')}") { inputs.dir(dirProvider) }.get()
assertTrue(producer in consumer.taskDependencies.getDependencies(consumer), "$t: libraryDir lacks task")
val fileConsumer = project.tasks.register("linkf_${t.replace('-', '_')}") { inputs.file(fileProvider) }.get()
assertTrue(producer in fileConsumer.taskDependencies.getDependencies(fileConsumer), "$t: libraryFile lacks task")
// Simulate the extraction creating the library.
val expected = root.dir(CPythonIncludeLayout.linkLibraryDirRelativePath(t, CPythonFlavour.GIL)).asFile
expected.mkdirs()
File(expected, CPythonIncludeLayout.linkLibraryFileName(t, CPythonFlavour.GIL, version)).writeText("lib")
assertEquals(expected, dirProvider.get().asFile)
assertEquals(expected.resolve(CPythonIncludeLayout.linkLibraryFileName(t, CPythonFlavour.GIL, version)), fileProvider.get().asFile)
}
}

@Test
fun linkLibraryFailsLoudlyWhenNotRequiredOrWrongFlavour() {
val (_, dirs) = fixture(CPythonFlavour.GIL)
assertFalse(dirs.isLinkRequired("macos-aarch64"))
assertFailsWith<GradleException> { dirs.libraryDir("macos-aarch64") }
assertFailsWith<GradleException> { dirs.libraryFile("ios-arm64") }
assertFalse(dirs.isLinkAvailable("windows-x86_64", CPythonFlavour.FREE_THREADED))
val e = assertFailsWith<GradleException> { dirs.libraryDir("windows-x86_64", CPythonFlavour.FREE_THREADED) }
assertTrue("pythonFreeThreaded" in e.message.orEmpty())
assertFailsWith<GradleException> { dirs.libraryDir("android-aarch64", CPythonFlavour.FREE_THREADED) }
}

@Test
fun linkLibraryWindowsFreeThreadedWhenConfigured() {
val (_, dirs) = fixture(CPythonFlavour.FREE_THREADED)
assertTrue(dirs.isLinkAvailable("windows-x86_64", CPythonFlavour.FREE_THREADED))
assertTrue(dirs.libraryFile("windows-x86_64", CPythonFlavour.FREE_THREADED).get().asFile.name == "python314t.lib")
}
}
Loading