diff --git a/docs/platforms/python-version-acquisition.md b/docs/platforms/python-version-acquisition.md index a3b01c798..611b0bb62 100644 --- a/docs/platforms/python-version-acquisition.md +++ b/docs/platforms/python-version-acquisition.md @@ -90,3 +90,20 @@ tasks.register("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`, the `-L` directory) and +`libraryFile(target, flavour)` (`Provider`), both carrying the `downloadPython_*` task. + +| Target | Link library (under `/`) | +|---|---| +| `android-aarch64`, `android-x86_64` | `/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`. + diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt index 6dabefb47..009e09163 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt @@ -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 @@ -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" + } + } } /** @@ -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 { + 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 { + val task = linkTask(target, flavour) + val relative = CPythonIncludeLayout.linkLibraryDirRelativePath(target, flavour) + "/" + + CPythonIncludeLayout.linkLibraryFileName(target, flavour, pythonVersion) + return task.map { extractedRoot.file(relative) } + } } diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt index 2c3cfe6fc..971905c64 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt @@ -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 { 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 { dirs.libraryDir("macos-aarch64") } + assertFailsWith { dirs.libraryFile("ios-arm64") } + assertFalse(dirs.isLinkAvailable("windows-x86_64", CPythonFlavour.FREE_THREADED)) + val e = assertFailsWith { dirs.libraryDir("windows-x86_64", CPythonFlavour.FREE_THREADED) } + assertTrue("pythonFreeThreaded" in e.message.orEmpty()) + assertFailsWith { 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") + } }