Skip to content

Instantly share code, notes, and snippets.

@ZakTaccardi
Created January 3, 2022 16:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ZakTaccardi/a4e1b62b31a7e1069c309a2a169b1058 to your computer and use it in GitHub Desktop.
Save ZakTaccardi/a4e1b62b31a7e1069c309a2a169b1058 to your computer and use it in GitHub Desktop.
`local.properties` and project level gradle properties support for Configuration Cache
import org.gradle.api.Project
import org.gradle.api.file.ProjectLayout
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.provider.ValueSource
import org.gradle.api.provider.ValueSourceParameters
import org.gradle.kotlin.dsl.of
import java.io.StringReader
import java.util.Properties
/**
* Like [ProviderFactory.gradleProperty] - but respects `local.properties` and project level `gradle.properties`
*
* Workaround for the lack of support here:
* * https://github.com/gradle/gradle/issues/12283
* * https://github.com/gradle/gradle/issues/13302
*
* Properties are loaded in the following order:
*
* 1. `-PcommandLineProperty=..`
* 2. `subproject/local.properties`
* 3. `local.properties` (root)
* 4. `subproject/gradle.properties`
* 5. `gradle.properties` (root)
*
* Note - configuration cache support is currently blocked by:
* * https://github.com/gradle/gradle/issues/19474
*/
fun ProviderFactory.gradleProperty(
project: Project,
propertyName: String
): Provider<String> = of(
LocalPropertySupportValueSource::class,
) {
parameters {
this.rootGradleProperty.set(
rootGradleProperty(propertyName).unwrap()
)
this.rootLocalProperty.set(
rootLocalProperty(project, propertyName).unwrap()
)
this.subprojectGradleProperty.set(
subprojectGradleProperty(project, propertyName).unwrap()
)
this.projectLevelLocalProperty.set(
projectLevelLocalProperty(project, propertyName).unwrap()
)
// note: this property does not yet support the configuration cache due to
// https://github.com/gradle/gradle/issues/19474
this.startParameterGradleProperty.set(
startParameterGradleProperty(project, propertyName).unwrap()
)
}
}
private val localPropertiesFileName = "local.properties"
private fun ProviderFactory.rootGradleProperty(
propertyName: String
): Provider<PropertyOrNull> = gradleProperty(propertyName)
.map<PropertyOrNull> { PropertyOrNull.NonNull(it) }
.orElse(PropertyOrNull.Null)
// value from root `local.properties`
private fun ProviderFactory.rootLocalProperty(
project: Project,
propertyName: String
): Provider<PropertyOrNull> = loadFromPropertiesFile(
layout = project.rootProject.layout,
propertyName = propertyName,
propertyFileName = localPropertiesFileName
)
// value from project level `local.properties`
private fun ProviderFactory.projectLevelLocalProperty(
project: Project,
propertyName: String
): Provider<PropertyOrNull> = loadFromPropertiesFile(
layout = project.layout,
propertyName = propertyName,
propertyFileName = localPropertiesFileName
)
// value from project level `gradle.properties`
private fun ProviderFactory.subprojectGradleProperty(
project: Project,
propertyName: String
): Provider<PropertyOrNull> = loadFromPropertiesFile(
layout = project.layout,
propertyName = propertyName,
propertyFileName = "gradle.properties"
)
// value from gradle start parameter
private fun ProviderFactory.startParameterGradleProperty(
project: Project,
propertyName: String
): Provider<PropertyOrNull> {
return provider<PropertyOrNull?> {
val startParameter = project.gradle.startParameter
val value = startParameter.projectProperties
.get(propertyName)
PropertyOrNull.create(value)
}
}
private fun ProviderFactory.loadFromPropertiesFile(
layout: ProjectLayout,
propertyName: String,
propertyFileName: String
): Provider<PropertyOrNull> {
val providers = this
return providers.fileContents(
layout.projectDirectory.file(propertyFileName)
)
.asText
.map { stringContents ->
Properties().apply {
load(StringReader(stringContents))
}
}
.map<PropertyOrNull> { props ->
PropertyOrNull.create(props.getProperty(propertyName))
}
.orElse(PropertyOrNull.Null)
}
private val Provider<PropertyOrNull>.extractOrNull: String?
get() = map<String> {
it.getOrNull.sneakyNull()
}
.orNull
private fun Provider<PropertyOrNull>.unwrap(): Provider<String> = map {
it.getOrNull.sneakyNull()
}
private sealed class PropertyOrNull {
abstract val getOrNull: String?
data class NonNull(val value: String) : PropertyOrNull() {
override val getOrNull = value
}
object Null : PropertyOrNull() {
override val getOrNull: String? = null
}
companion object {
fun create(value: String?): PropertyOrNull = if (value != null) {
NonNull(value)
} else {
Null
}
}
}
/**
* Workaround for https://github.com/gradle/gradle/issues/12388#issuecomment-643427098
*/
@SuppressWarnings("UNCHECKED_CAST")
private fun <T> T?.sneakyNull() = this as T
/**
* Suggested here:
* https://gradle-community.slack.com/archives/CAHSN3LDN/p1640939568404100?thread_ts=1640913506.403000&cid=CAHSN3LDN
*/
internal interface LocalPropertySupportValueSourceParameters : ValueSourceParameters {
val startParameterGradleProperty: Property<String>
val projectLevelLocalProperty: Property<String>
val rootLocalProperty: Property<String>
val subprojectGradleProperty: Property<String>
val rootGradleProperty: Property<String>
}
/**
* Suggested here:
* https://gradle-community.slack.com/archives/CAHSN3LDN/p1640939568404100?thread_ts=1640913506.403000&cid=CAHSN3LDN
*/
internal abstract class LocalPropertySupportValueSource : ValueSource<String, LocalPropertySupportValueSourceParameters> {
override fun obtain(): String? {
val params = parameters
val rootGradleProperty = params.rootGradleProperty.orNull
val rootLocalProperty = params.rootLocalProperty.orNull
val subprojectGradleProperty = params.subprojectGradleProperty.orNull
val projectLevelLocalProperty = params.projectLevelLocalProperty.orNull
val startParameterGradleProperty = params.startParameterGradleProperty.orNull
return listOfNotNull(
startParameterGradleProperty,
projectLevelLocalProperty,
rootLocalProperty,
subprojectGradleProperty,
rootGradleProperty
)
.firstOrNull()
}
}
import org.assertj.core.api.Assertions.assertThat
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import org.jetbrains.kotlin.gradle.internal.ensureParentDirsCreated
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
/**
* Tests [gradleProperty]
*/
class LocalPropertySupportKtTest {
@TempDir
@JvmField
var testProjectDir: File? = null
@Test
fun `1 - startParameter has highest precedence`() = runTest(
rootGradle = rootGradle,
subprojectGradle = subprojectGradle,
rootLocal = rootLocal,
subprojectLocal = subprojectLocal,
startParameter = startParameter,
expected = startParameter,
// note: `startParameter` gradle property does not yet support the configuration cache due to
// https://github.com/gradle/gradle/issues/19474
testConfigCacheSupport = true // this will fail - set to `false` to pass
)
@Test
fun `2 - subproject local has 2nd highest precedence`() = runTest(
rootGradle = rootGradle,
subprojectGradle = subprojectGradle,
rootLocal = rootLocal,
subprojectLocal = subprojectLocal,
expected = subprojectLocal
)
@Test
fun `3 - root project local has 3rd highest precedence`() = runTest(
rootGradle = rootGradle,
subprojectGradle = subprojectGradle,
rootLocal = rootLocal,
expected = rootLocal
)
@Test
fun `4 - subproject gradle properties has 4th highest precedence`() = runTest(
rootGradle = rootGradle,
subprojectGradle = subprojectGradle,
expected = subprojectGradle
)
@Test
fun `5 - root gradle properties has 5th highest precedence`() = runTest(
rootGradle = rootGradle,
expected = rootGradle
)
private fun runTest(
rootGradle: String? = notProvided,
rootLocal: String? = notProvided,
subprojectGradle: String? = notProvided,
subprojectLocal: String? = notProvided,
startParameter: String? = notProvided,
/**
* Currently blocked due to these outstanding questions:
* * https://gradle-community.slack.com/archives/CAHSN3LDN/p1640913506403000
* * https://gradle-community.slack.com/archives/C013WEPGQF9/p1640915521055300
*/
testConfigCacheSupport: Boolean = true,
expected: String
) {
val propertyName = "testProperty"
val subprojectName = "subproject"
val testProjectDir = checkNotNull(testProjectDir) {
"`testProjectDir` was `null`"
}
val settingsFile = File(testProjectDir, "settings.gradle.kts")
val rootBuildFile = File(testProjectDir, "build.gradle.kts")
val subprojectBuildDir = File(testProjectDir, subprojectName)
.apply { mkdirs() }
val subprojectBuildFile = File(subprojectBuildDir, "build.gradle.kts")
settingsFile.writeText(
"""
rootProject.name = "local-property-test"
include(":subproject")
""".trimIndent()
)
rootBuildFile.writeText(
"""
// empty root build file
tasks.register<Exec>("listFiles") {
commandLine("tree")
}
""".trimIndent()
)
val rootGradleProperties = if (rootGradle != null) {
File(testProjectDir, "gradle.properties")
} else {
null
}
val rootLocalGradleProperties = if (rootLocal != null) {
File(testProjectDir, "local.properties")
} else {
null
}
val subprojectGradleProperties = if (subprojectGradle != null) {
File(testProjectDir, "$subprojectName/gradle.properties")
.apply {
this.parentFile.mkdirs()
}
} else {
null
}
val subprojectLocalProperties = if (subprojectLocal != null) {
File(testProjectDir, "$subprojectName/local.properties")
.apply {
this.parentFile.mkdirs()
}
} else {
null
}
@Suppress("NAME_SHADOWING")
var startParameter = startParameter?.formatAsGradleProperty(propertyName)
rootGradleProperties?.writePropertiesFile(propertyName, rootGradle)
rootLocalGradleProperties?.writePropertiesFile(propertyName, rootLocal)
subprojectGradleProperties?.writePropertiesFile(propertyName, subprojectGradle)
subprojectLocalProperties?.writePropertiesFile(propertyName, subprojectLocal)
// see https://youtrack.jetbrains.com/issue/KT-2425
val escapedTestProperty = "\$providerValue"
subprojectBuildFile.writeText(
"""
import isdk.gradleProperty
import org.gradle.api.DefaultTask
import org.gradle.api.model.ObjectFactory
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.property
import javax.inject.Inject
plugins {
id("isdk-config") apply(false)
}
val testProperty = providers.gradleProperty(project, "$propertyName")
tasks.register<PrintProviderTask>("printProperty") {
providerToPrint.set(testProperty)
}
abstract class PrintProviderTask @Inject constructor(
private val objects: ObjectFactory
): DefaultTask() {
@Input
val providerToPrint = objects.property<String>()
@TaskAction
fun doWork() {
val providerValue = providerToPrint.get()
logger.lifecycle("TEST_PROPERTY_VALUE<$escapedTestProperty>")
}
}
""".trimIndent()
)
fun GradleRunner.withUpdatedArguments(): GradleRunner {
val configCacheFlag = if (testConfigCacheSupport) {
"--configuration-cache"
} else {
"--no-configuration-cache"
}
return withArgumentsNotNull(
// uncomment this to list files - only works if you have `tree` command installed
// "listFiles",
":$subprojectName:printProperty",
startParameter, // this needs to be re-read for every build bc it may change
configCacheFlag,
"--stacktrace"
)
}
fun gradleRunner() = GradleRunner.create()
.withProjectDir(testProjectDir)
.withPluginClasspath()
gradleRunner()
.withUpdatedArguments()
.build {
assertExpectedValue(expected)
}
// run again to ensure config cache works as expected
gradleRunner()
.withUpdatedArguments()
.build {
if (testConfigCacheSupport) assertConfigCacheWasReused()
}
// change the expected value and ensure config cache is still re-used
val expectedProperty = Property.from(expected)
println("expected property is $expectedProperty")
val modifiedValue = "modified"
when (expectedProperty) {
Property.StartParameter -> startParameter = modifiedValue.formatAsGradleProperty(propertyName)
Property.SubprojectLocal -> subprojectLocalProperties!!.writePropertiesFile(propertyName, modifiedValue)
Property.RootLocal -> rootLocalGradleProperties!!.writePropertiesFile(propertyName, modifiedValue)
Property.SubprojectGradle -> subprojectGradleProperties!!.writePropertiesFile(
propertyName,
modifiedValue
)
Property.RootGradle -> rootGradleProperties!!.writePropertiesFile(propertyName, modifiedValue)
}
println("startParameter=$startParameter")
gradleRunner()
.withUpdatedArguments()
.build {
if (testConfigCacheSupport) assertConfigCacheWasReused()
// expected value should be modified
assertExpectedValue(modifiedValue)
}
}
private fun File.copyProjectDir(expected: String) {
val testProjectDir = this
val copyLocation = File("build/properties/$expected")
.apply {
println(absolutePath)
}
testProjectDir.ensureParentDirsCreated()
testProjectDir.copyRecursively(copyLocation, true)
}
private fun File.writePropertiesFile(propertyName: String, propertyValue: String?) {
val textToWrite = if (propertyValue != null) {
"""
$propertyName=$propertyValue
""".trimIndent()
} else {
" "
}
writeText(textToWrite)
println("$absolutePath contents")
println(readText())
}
}
private fun GradleRunner.withArgumentsNotNull(vararg arguments: String?): GradleRunner = withArguments(
*arguments.filterNotNull()
.toTypedArray()
)
private fun String.formatAsGradleProperty(
propertyName: String
): String = "-P$propertyName=$this"
private fun BuildResult.assertConfigCacheWasReused(expected: String? = null) = apply {
output.contains("Reusing configuration cache.")
if (expected != null) {
assertExpectedValue(expected)
}
}
private fun BuildResult.assertExpectedValue(expected: String): BuildResult = apply {
val result = this
val regex = Regex("TEST_PROPERTY_VALUE<(.*)>")
val output = result.output
println(result.output)
val actual: String = regex.find(output)!!
.groupValues.last() // should be value between the `<..>
assertThat(actual)
.isEqualTo(expected)
output.contains("Reusing configuration cache.")
}
private fun GradleRunner.build(block: BuildResult.() -> Unit) {
val result = build()
val output = result.output
println(result.output)
block.invoke(result)
}
private const val rootGradle: String = "rootGradle"
private const val rootLocal: String = "rootLocal"
private const val subprojectGradle: String = "subprojectGradle"
private const val subprojectLocal: String = "subprojectLocal"
private const val startParameter: String = "startParameter"
private val notProvided: String? = null
private sealed class Property {
abstract val propertyValue: String
object RootGradle : Property() {
override val propertyValue: String = rootGradle
}
object RootLocal : Property() {
override val propertyValue: String = rootLocal
}
object SubprojectGradle : Property() {
override val propertyValue: String = subprojectGradle
}
object SubprojectLocal : Property() {
override val propertyValue: String = subprojectLocal
}
object StartParameter : Property() {
override val propertyValue: String = startParameter
}
companion object {
private val all = listOf(
RootGradle,
RootLocal,
SubprojectGradle,
SubprojectLocal,
StartParameter
)
fun from(stringDef: String): Property {
all.forEach {
if (it.propertyValue == stringDef) {
return@from it
}
}
error("did not find value for `$stringDef`")
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment