'Conditionally Change a Gradle Property Based on a Provider Value

I'm writing a Gradle convention plugin that uses Gradle's Lazy Configuration APIs to configure tasks. In one case, the plugin needs to conditionally change the value of a Property, and that condition is based on the effective value of a Provider. That is, if the Provider has a certain value, update the value of the Property; else, leave the Property as-is.

If not for the Provider semantics, this would be a simple logic statement like:

if (someValue > 10) {
  property.set(someValue)
}

but, because the Provider's value is not-yet-known, this is more complicated.

I naively tried the following, but it results in a stack overflow error, because the transformer for the property includes a retrieval of that same property.

// stack overflow error
property.set(provider.map { if (it > 10) it else property.get() })

A more complete example:

val foo = objects.property(String::class).convention("foo")
val bar = objects.property(String::class).convention("bar")

bar.set(foo.map { if (it != "foo") "baz" else bar.get()})


tasks.register("print") {
    // goal is to print "baz", but it is a StackOverflowError
    logger.log(LogLevel.LIFECYCLE, bar.get())
}

Is there an API I'm missing that would allow me to conditionally update the value of a Property based on the value of a Provider?



Solution 1:[1]

In your simplified example, you don't actually need providers.

// build.gradle.kts
val foo = objects.property(String::class).convention("foo")
val bar = objects.property(String::class).convention("bar")

tasks.register("print") {
    val evaluatedFoo = if (foo.get() != "foo") "baz" else bar.get()
    logger.lifecycle("evaluatedFoo $evaluatedFoo")
}
// output:
// evaluatedFoo bar

That's because the logger is working in the configuration phase (see 'Gradle Phases Recap' below). Usually (but not always) properties and providers are to avoid computation during the configuration phase.

sidenote: property vs provider

The difference is akin to Kotlin's var and val. property should be used for letting a user set a custom value, provider is for read-only values, like environment variables providers.environmentVariable("HOME").

Why use providers?

Because you're using 'register', the task isn't configured until it's required. So I'll tweak your example to make things a bit worse, to see how ugly it gets.

// build.gradle.kts
val foo: Provider<String> = providers.provider {
    // pretend we're doing some heavy work, like an API call
    Thread.sleep(TimeUnit.SECONDS.toMillis(10))
    "foo"
}
val bar = objects.property(String::class).convention("bar")

// change from 'register' to 'create'
tasks.create("print") {
    val evaluatedFoo = if (foo.get() != "foo") "baz" else bar.get()
    logger.lifecycle("evaluatedFoo: $evaluatedFoo")
}

Now every time Gradle loads build.gradle.kts, it takes 10 seconds! Even if we don't run a task! Or an unrelated task! That won't do. This is one good justification for using providers.

Solutions

There are a few different paths here, depending on what you actually want to achieve.

Only log in the execution phase

We can move the log statement to a doFirst {} or doLast {} block. The contents of these blocks run in the execution phase. That's the point of the providers, to delay work until this phase. So we can call .get() to evaluate them.

// build.gradle.kts
tasks.create("print") {
    doFirst {
        // now we're in the execution phase, it's okay to crack open the providers
        val evaluatedFoo = if (foo.get() != "foo") "baz" else bar.get()
        logger.lifecycle("evaluatedFoo: $evaluatedFoo")
    }
}

Now even though the task is eagerly created, foo and bar won't be evaluated until execution time - when it's okay to do work.

Combine two providers

I think this option is closer to what you were originally asking. Instead of recursively setting baz back into itself, create a new provider.

Gradle will only evaluate foo and bar when fooBarZipped.get() is called.

// build.gradle.kts
val foo = objects.property(String::class).convention("foo")
val bar = objects.property(String::class).convention("bar")

val fooBarZipped: Provider<String> = foo.zip(bar) { fooActual, barActual ->
    if (fooActual != "foo") {
        "baz"
    } else {
        barActual
    }
}

tasks.register("print") {
    logger.lifecycle("fooBarZipped: ${fooBarZipped.get()}")
}

Note that this fooBarZipped.get() will also cause bar to be evaluated, even though it might not be used! In this case we can just use map() (which is different to Kotlin's Collection<T>.map() extension function!)

Mapping providers

This one is a little more lazy.

// build.gradle.kts
val foo = objects.property(String::class).convention("foo")
val bar = objects.property(String::class).convention("bar")

val fooBarMapped: Provider<String> = foo.map { fooActual ->
    if (fooActual != "foo") {
        "baz"
    } else {
        bar.get() // bar will only be evaluated if required
    }
}

tasks.register("print") {
    logger.lifecycle("evaluatedFoo: ${fooBarMapped.get()}")
}

Custom task

Sometimes it's easier to define tasks in a build.gradle.kts, but often it's more clear to specifically make a MyPrintTask class. This part is optional - do whatever is best for your situation.

It's a lot to learn the strange Gradle style of creating tasks, so I won't dive into it all. But the point I want to make is that @get:Input is really important.

// buildSrc/main/kotlin/MyPrintTask.kt
package my.project

abstract class MyPrintTask : DefaultTask() {
    @get:Input
    abstract val taskFoo: Property<String>
    @get:Input
    abstract val taskBar: Property<String>

    @get:Internal
    val toBePrinted: Provider<String> = project.provider {
        if (taskFoo.get() != "foo") {
            "baz"
        } else {
            taskBar.get()
        }
    }

    @TaskAction
    fun print() {
        logger.quiet("[PrintTask] ${toBePrinted.get()}")
    }
}

Aside: you can also define inputs with the Kotlin DSL, but it's not quite as fluid

//build.gradle.kts
tasks.register("print") {
    val taskFoo = foo // setting the property here helps Gradle configuration cache
    inputs.property("taskFoo", foo)
    val taskBar = foo
    inputs.property("taskBar", bar)
    
    doFirst {
        val evaluated = if (taskFoo.get() != "foo") {
            "baz"
        } else {
            taskBar.get()
        }
        logger.lifecycle(evaluated)
    }
}

Now you can define the task in the build script.

// build.gradle.kts
val foo = objects.property(String::class).convention("foo")
val bar = objects.property(String::class).convention("bar")

tasks.register<MyPrintTask>("print") {
    taskFoo.set(foo)
    taskBar.set(bar)
}

It doesn't seem like there's much benefit here, but @get:Input can be really important if foo or bar are themselves mapped or zipped, or depend on the output of a task. Now Gradle will chain tasks together, so even if you just run gradle :print, it knows how to trigger a whole cascade of necessary precursor tasks!


Gradle phases recap

Gradle has 3 phases

  1. Initialization - projects are loaded, inside settings.gradle.kts. That's not interesting for us right now.
  2. Configuration - this is what happens when loading build.gradle.kts, or defining a task
  3. Execution - a task is triggered! Tasks run, providers and properties are computed.
// build.gradle.kts
println("This is executed during the configuration phase.")

tasks.register("configured") {
    println("This is also executed during the configuration phase, because :configured is used in the build.")
}

tasks.register("test") {
    doLast {
        println("This is executed during the execution phase.")
    }
}

tasks.register("testBoth") {
    doFirst {
        println("This is executed first during the execution phase.")
    }
    doLast {
        println("This is executed last during the execution phase.")
    }
    println("This is executed during the configuration phase as well, because :testBoth is used in the build.")
}

Task Configuration Avoidance

https://docs.gradle.org/current/userguide/task_configuration_avoidance.html

Why is this relevant to providers and properties? Because they ensure 2 things

  1. work is not done during the configuration phase

    When Gradle registers a task, we don't want it to actually run the task! we want to delay that until it's needed.

  2. Gradle can create a 'directed acyclic graph'

    Basically, Gradle isn't a single-track production line, with a single starting point. It's a hive-mind of little workers that have input and output, and Gradle chains the workers together based on where they get the input from.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 aSemy