Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add non-isolated mode #13

Merged
merged 1 commit into from
Sep 3, 2024
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
28 changes: 26 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
.idea
.kotlin
.fleet
.gradle
build

# Build outputs
build

# Idea
**/.idea/*
!**/.idea/codeStyles
!**/.idea/icon.png
!**/.idea/runConfigurations
!**/.idea/scopes
!**/.idea/dictionaries
*.iml

# Place where the Android SDK path is set
local.properties

# XCode
xcuserdata
project.xcworkspace

# Mac OS Finder
.DS_Store
Thumbs.db

18 changes: 18 additions & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .idea/codeStyles/codeStyleConfig.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

233 changes: 191 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,34 @@

Gratatouille is an opinionated framework to build Gradle plugins. Write pure Kotlin functions and the Gratatouille KSP processor generates tasks, workers, and wiring code for you.

Gratatouille enforces a clear separation between your plugin logic (**implementation**) and your plugin wiring (**gradle-plugin**) making your plugin immune to [classloader issues](https://github.com/square/kotlinpoet/issues/1730#issuecomment-1819118527) 🛡️
When used in classloader isolation mode, Gratatouille enforces a clear separation between your plugin logic (**implementation**) and your plugin wiring (**api**) making your plugin immune to [classloader issues](https://github.com/square/kotlinpoet/issues/1730#issuecomment-1819118527) 🛡️

**Key Features**:

* [Pure functions](#pure-functions)
* [Kotlinx serialization support](#built-in-kotlinxserialization-support)
* [Comprehensive input/output types](#supported-input-and-output-types)
* [Non overlapping task outputs](#non-overlapping-task-outputs-by-default)
* [Classloader isolation](#classloader-isolation-by-default)
* [Build cache](#build-cache-by-default)
* [Documentation](#easy-documentation)
* [Parallel execution](#parallel-task-execution-by-default)
* [Compile-time task wiring](#compile-time-task-wiring)
* [Classloader isolation](#classloader-isolation-optional) (optional)

Check out the [sample-plugin](sample-plugin) and [sample-app](sample-app).

## Quick Start

### Step 1/2: `com.gradleup.gratatouille.implementation`

Create an `implementation` module for your plugin implementation and apply the `com.gradleup.gratatouille.implementation` plugin:
Apply the `com.gradleup.gratatouille` plugin:

```kotlin
// implementation/build.gradle.kts
plugins {
id("com.gradleup.gratatouille.implementation").version("0.0.1")
}

dependencies {
// Add the gratatouille annotations
implementation("com.gradleup.gratatouille:gratatouille-core:0.0.1")
// Add other dependencies
implementation("com.squareup:kotlinpoet:1.14.2")
implementation("org.ow2.asm:asm-commons:9.6")
// do **not** add gradleApi() here
id("java-gradle-plugin")
id("com.gradleup.gratatouille").version("0.0.2")
}
```

Write your task action as a pure top-level Kotlin function annotated with `@GTaskAction`:
Define your task action using `@GTaskAction`:

```kotlin
@GTaskAction
Expand All @@ -65,35 +54,113 @@ Gratatouille automatically maps function parameters to Gradle inputs and the ret

Gratatouille generates entry points, tasks, workers and Gradle wiring code that you can then use to cook your plugin.

### Step 2/2 `com.gradleup.gratatouille.plugin`
<details>
<summary>Generated code</summary>

To use the generated code in your plugin, create a `gradle-plugin` module next to your `implementation` module.
```kotlin
internal fun Project.registerPrepareIngredientsTask(
taskName: String = "prepareIngredients",
taskDescription: String? = null,
taskGroup: String? = null,
persons: Provider<Int>,
): TaskProvider<PrepareIngredientsTask> {
val configuration = this@registerPrepareIngredientsTask.configurations.detachedConfiguration()
configuration.dependencies.add(dependencies.create("sample-plugin:implementation:0.0.1"))
return tasks.register(taskName,PrepareIngredientsTask::class.java) {
it.description = taskDescription
it.group = taskGroup
it.classpath.from(configuration)
// infrastructure
// inputs
it.persons.set(persons)
// outputs
it.outputFile.set([email protected]("gtask/${taskName}/outputFile"))
}
}

> [!IMPORTANT]
> By using two different modules, Gratatouille ensures that Gradle classes do not leak in your plugin implementation and vice-versa.
@CacheableTask
internal abstract class PrepareIngredientsTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
public abstract val classpath: ConfigurableFileCollection

@get:Input
public abstract val persons: Property<Int>

@get:OutputFile
public abstract val outputFile: RegularFileProperty

@Inject
public abstract fun getWorkerExecutor(): WorkerExecutor

private fun <T> T.isolate(): T {
@kotlin.Suppress("UNCHECKED_CAST")
when (this) {
is Set<*> -> {
return this.map { it.isolate() }.toSet() as T
}

is List<*> -> {
return this.map { it.isolate() } as T
}

is Map<*, *> -> {
return entries.map { it.key.isolate() to it.value.isolate() }.toMap() as T
}

else -> {
return this
}
}
}

@TaskAction
public fun taskAction() {
getWorkerExecutor().noIsolation().submit(PrepareIngredientsWorkAction::class.java) {
it.classpath = classpath.files.isolate()
it.persons = persons.get().isolate()
it.outputFile = outputFile.asFile.get().isolate()
}
}
}

Apply the `com.gradleup.gratatouille.plugin` plugin in your `gradle-plugin` module:
private interface PrepareIngredientsWorkParameters : WorkParameters {
public var classpath: Set<File>

```kotlin
// gradle-plugin/build.gradle.kts
plugins {
id("java-gradle-plugin")
id("com.gradleup.gratatouille.plugin").version("0.0.1")
public var persons: Int

public var outputFile: File
}

dependencies {
// Add your implementation module to the "gratatouille" configuration.
// This does not add `:implementation` to your plugin classpath.
// Instead, the generated code uses reflection and a separate classloader to run
// your implementation
gratatouille(project(":implementation"))
private abstract class PrepareIngredientsWorkAction : WorkAction<PrepareIngredientsWorkParameters> {
override fun execute() {
with(parameters) {
URLClassLoader(
classpath.map { it.toURI().toURL() }.toTypedArray(),
ClassLoader.getPlatformClassLoader()
).loadClass("recipes.PrepareIngredientsEntryPoint")
.declaredMethods.single()
.invoke(
null,
persons,
outputFile,
)
}
}
}

// Create your plugin as usual, see https://docs.gradle.org/current/userguide/java_gradle_plugin.html
gradlePlugin {
// ...
public class PrepareIngredientsEntryPoint {
public companion object {
@JvmStatic
public fun run(persons: Int, outputFile: File) {
prepareIngredients(
persons = persons,
).encodeJsonTo(outputFile)
}
}
}
```
</details>

In your plugin code, use `Project.register${TaskAction}Task()` to register the task:

Expand All @@ -115,6 +182,8 @@ override fun apply(project: Project) {
}
```

No need to implement `DefaultTask`, no risk of forgetting `@Cacheable`, etc... Gratatouille provides good defaults making it easier to write plugins.

## Features

### Pure functions
Expand Down Expand Up @@ -185,12 +254,6 @@ project.registerCookTask(
)
```

### Classloader isolation by default

Gratatouille creates a separate classloader for each task and calls your pure functions using reflection.

This means your plugin can depend on popular dependencies such as the Kotlin stdlib, KotlinPoet or ASM without risking conflicts with other plugins or the Gradle classpath itself.

### Build cache by default

`@CacheableTask` is added by default. All input files use `PathSensitivity.RELATIVE` making your tasks relocatable.
Expand Down Expand Up @@ -227,3 +290,89 @@ Finally, Gratatouille encourages exposing extensions to users instead of task cl

When a task has a high number of inputs, it can become hard to track which ones have been wired and which ones haven't. By using a central registration point, Gratatouille enforces at build time that all inputs/outputs have been properly wired.

## Classloader isolation (optional)

Gradle uses [multiple classloaders](https://dev.to/autonomousapps/build-compile-run-a-crash-course-in-classpaths-f4g), and it's notoriously really hard to understand where a given class is loaded from.

Especially, `buildSrc`/`build-logic` dependencies [leak in the main classpath](https://github.com/gradle/gradle/issues/4741) and override any dependencies from other plugin without conflict resolution. There are multiple workarounds such as declaring all plugins in `buildSrc` or in the top level `build.gradle[.kts]` file but the situation is confusing to Gradle newcomers and hard to debug.

To guard against those issues, Gratatouille provides a "classloader isolation" mode where your task actions use a separate classloader.

This means your plugin can depend on popular dependencies such as the Kotlin stdlib, KotlinPoet or ASM without risking conflicts with other plugins or the Gradle classpath itself.

For classloader isolation to work, your plugin needs 2 modules:
* The **implementation** module is where the task actions are defined and the work is done. This module can add dependencies.
* The **api** module contains the glue code and Gradle API that calls the **implementation** module through reflection. This module must not add dependencies.

### Step 1/2: `com.gradleup.gratatouille.implementation`

Create an `implementation` module for your plugin implementation and apply the `com.gradleup.gratatouille.implementation` plugin:

```kotlin
// implementation/build.gradle.kts
plugins {
id("com.gradleup.gratatouille.implementation").version("0.0.1")
}

dependencies {
// Add other dependencies
implementation("com.squareup:kotlinpoet:1.14.2")
implementation("org.ow2.asm:asm-commons:9.6")
// do **not** add gradleApi() here
}
```

Write your task action as a pure top-level Kotlin function annotated with `@GTaskAction`:

```kotlin
@GTaskAction
internal fun prepareIngredients(persons: Int): Ingredients {
return Ingredients(
tomatoes = (persons * 0.75).roundToInt(),
zucchinis = (persons * 0.3).roundToInt(),
eggplants = (persons * 0.3).roundToInt(),
)
}

// kotlinx.serialization is supported out of the box
@Serializable
internal data class Ingredients(
val tomatoes: Int,
val zucchinis: Int,
val eggplants: Int,
)
```

When using this mode, the plugin wiring code is generated as resources that are included by the `com.gradleup.gratatouille.api` plugin.

### Step 2/2 `com.gradleup.gratatouille.plugin`

To use the generated code in your plugin, create an `api` module next to your `implementation` module.

> [!IMPORTANT]
> By using two different modules, Gratatouille ensures that Gradle classes do not leak in your plugin implementation and vice-versa.

Apply the `com.gradleup.gratatouille.api` plugin in your `api` module:

```kotlin
// gradle-plugin/build.gradle.kts
plugins {
id("java-gradle-plugin")
id("com.gradleup.gratatouille.api").version("0.0.1")
}

dependencies {
// Add your implementation module to the "gratatouille" configuration.
// This does not add `:implementation` to your plugin classpath.
// Instead, the generated code uses reflection and a separate classloader to run
// your implementation
gratatouille(project(":implementation"))
}

// Create your plugin as usual, see https://docs.gradle.org/current/userguide/java_gradle_plugin.html
gradlePlugin {
// ...
}
```

In your plugin code, use `Project.register${TaskAction}Task()` to register the task
1 change: 1 addition & 0 deletions build-logic/.idea/codeStyles

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import com.gradleup.librarian.gradle.configureJavaCompatibility

plugins {
`embedded-kotlin`
alias(libs.plugins.librarian).apply(false)
}

dependencies {
Expand All @@ -13,3 +16,9 @@ dependencies {
}

group = "build-logic"

/**
* Ideally would use Runtime.version().feature() but the current Gradle still ships with Kotlin 1.9
* that doesn't know about Java 22 🤷‍♂️
*/
configureJavaCompatibility(17)
1 change: 1 addition & 0 deletions build-logic/gradle/wrapper
Binary file removed build-logic/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 0 additions & 7 deletions build-logic/gradle/wrapper/gradle-wrapper.properties

This file was deleted.

Loading