Search code examples
gradlegradle-plugingradle-kotlin-dsl

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

  • 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.