diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -281,6 +281,16 @@ variantArtifacts { } ``` +Slot bodies have two assembly modes: + +- contribution-based assembly with `from(...)`, `fromVariant(...)`, + `fromRole(...)`, or `fromLayer(...)`; the plugin copies selected inputs into a + managed directory and publishes that directory; +- task-produced assembly with `producedBy(task) { outputFile }`; the mapped task + output file or directory is published directly. + +These modes are mutually exclusive for one slot. + The artifact API is still considered pre-1.0 and may change. ## Publication Status diff --git a/variant_artifacts.md b/variant_artifacts.md --- a/variant_artifacts.md +++ b/variant_artifacts.md @@ -279,6 +279,9 @@ final class VariantArtifactsRegistry imp interface ArtifactAssemblyRules { void from(Object input); + void producedBy( + TaskProvider task, + Function> output); void fromVariant(Action action); void fromRole(String roleName, Action action); void fromLayer(String layerName, Action action); @@ -339,6 +342,8 @@ This means: - slot inputs remain live; - `from(...)`, `fromVariant(...)`, `fromRole(...)`, `fromLayer(...)` may keep contributing inputs until task execution; +- `producedBy(...)` publishes an existing task output directly and does not + create the managed copy assembly for that slot; - `ArtifactAssembly` may expose live `FileCollection`, `Provider`, and task wiring; - external task outputs remain outside the control of this model and must be @@ -412,8 +417,9 @@ variantArtifacts { } slot("bundleMetadata") { - from(someTask) - from(layout.buildDirectory.file("generated/meta.json")) + producedBy(writePackageMetadata) { + outputFile + } } } } @@ -428,7 +434,10 @@ variantArtifacts { belong to the given role projection; - `fromLayer(layer) { output(...) }` selects named outputs from the compile unit of the current variant and the given layer, if such unit exists. +- `producedBy(task) { outputFile }` maps an existing producing task to the single + file or directory published for the slot. +Contribution forms and `producedBy(...)` are mutually exclusive for one slot. The DSL stores declarations, not resolved file collections. --- @@ -499,6 +508,9 @@ For one outgoing variant: - expands to one compile unit `(variant, layer)` when it exists; - `from(Object)` - bypasses `variantSources` completely. +- `producedBy(task)` + - bypasses contribution resolution and registers the task output as the slot + artifact directly. After compile units are known, the bridge asks `ctx.getSourceSets().getSourceSet(unit)` for each selected unit and resolves the diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblySpec.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblySpec.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblySpec.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblySpec.java @@ -1,6 +1,13 @@ package org.implab.gradle.variants.artifacts; +import java.util.function.Function; + import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.Task; +import org.gradle.api.file.FileSystemLocation; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.TaskProvider; import groovy.lang.Closure; import org.implab.gradle.common.core.lang.Closures; @@ -24,6 +31,45 @@ public interface ArtifactAssemblySpec { void from(Object artifact); /** + * Registers a task that directly produces the published slot artifact. + * + *

Use this method when the slot is produced as one file or directory by an + * existing task, for example generated package metadata. Unlike {@link #from(Object)} + * and topology-aware selectors, this does not copy inputs into a managed assembly + * directory. The mapped task output becomes the published artifact itself. + * + *

This mode is mutually exclusive with contribution-based assembly methods + * such as {@link #from(Object)}, {@link #fromVariant(Action)}, {@link #fromRole(String, Action)}, + * and {@link #fromLayer(String, Action)} for the same slot. + * + * @param task type + * @param task task provider producing the artifact + * @param artifact maps the producing task to its output file or directory provider + */ + void producedBy( + TaskProvider task, + Function> artifact); + + default void producedBy(TaskProvider task, Closure closure) { + producedBy(task, taskInstance -> producedArtifact(closure, taskInstance)); + } + + @SuppressWarnings("unchecked") + private static Provider producedArtifact(Closure closure, Task task) { + var c = (Closure) closure.clone(); + c.setResolveStrategy(Closure.DELEGATE_FIRST); + c.setDelegate(task); + + var artifact = c.call(task); + if (artifact instanceof Provider) { + return (Provider) artifact; + } + + throw new InvalidUserDataException("Produced artifact mapper for task '" + task.getName() + + "' must return Provider"); + } + + /** * Selects outputs from the whole variant scope. * * @param action output selection rule diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java @@ -32,7 +32,9 @@ public class ArtifactAssemblyBinder impl // Bind the primary artifact set to the root outgoing configuration. resolver.when( new ArtifactSlot(variant, primarySlot), - assembly -> outgoing.artifact(assembly.getArtifact())); + assembly -> outgoing.artifact( + assembly.getArtifact(), + artifact -> artifact.builtBy(assembly.getAssemblyTask()))); // Bind non-primary slots to Gradle secondary artifact variants. slots.all(slot -> { @@ -46,7 +48,9 @@ public class ArtifactAssemblyBinder impl // otherwise be realized only after dependency resolution starts. assembly -> outgoing.getVariants() .create(slot.getName()) - .artifact(assembly.getArtifact())); + .artifact( + assembly.getArtifact(), + artifact -> artifact.builtBy(assembly.getAssemblyTask()))); }); }); } diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java @@ -4,38 +4,57 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.Task; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.Directory; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.FileCollection; +import org.gradle.api.file.FileSystemLocation; import org.gradle.api.model.ObjectFactory; import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Sync; import org.gradle.api.tasks.TaskContainer; +import org.gradle.api.tasks.TaskProvider; import org.gradle.language.base.plugins.LifecycleBasePlugin; import org.implab.gradle.common.core.lang.FilePaths; +import org.implab.gradle.common.core.lang.Strings; import org.implab.gradle.variants.artifacts.ArtifactAssembly; import org.implab.gradle.variants.artifacts.ArtifactAssemblySpec; import org.implab.gradle.variants.artifacts.ArtifactSlot; +import org.implab.gradle.variants.artifacts.OutputSelectionSpec; +import org.implab.gradle.variants.core.Layer; +import org.implab.gradle.variants.core.Role; import org.implab.gradle.variants.sources.CompileUnit; import org.implab.gradle.variants.sources.CompileUnitsView; import org.implab.gradle.variants.sources.RoleProjectionsView; import org.implab.gradle.variants.sources.SourceSetMaterializer; /** - * Adapts slot contribution declarations to materialized {@link ArtifactAssembly} + * Adapts slot contribution declarations to materialized + * {@link ArtifactAssembly} * handles. * - *

The handler creates one {@link Sync} task per {@link ArtifactSlot}. The task - * copies all collected slot inputs into a single output directory. That output - * directory is then registered in {@link ArtifactAssemblyRegistry} as the - * published artifact for the slot. + *

+ * Contribution-based assemblies create one {@link Sync} task per + * {@link ArtifactSlot}. The task copies all collected slot inputs into a single + * output directory. That output directory is then registered in + * {@link ArtifactAssemblyRegistry} as the published artifact for the slot. * - *

Input collection uses {@link SlotContributionVisitor}. Each contribution is - * converted to a {@link SlotInputKey}; duplicate keys are ignored so that repeated + *

+ * Task-produced assemblies bypass the managed copy task. The producer task is + * registered directly in {@link ArtifactAssemblyRegistry}, and its mapped output + * file or directory becomes the published slot artifact. + * + *

+ * Input collection uses {@link SlotContributionVisitor}. Each contribution is + * converted to a {@link SlotInputKey}; duplicate keys are ignored so that + * repeated * topology-based selections do not add the same input twice. */ @NonNullByDefault @@ -56,6 +75,8 @@ public class ArtifactAssemblyHandler { private final Map slotInputs = new HashMap<>(); + private final Map assemblyModes = new HashMap<>(); + public ArtifactAssemblyHandler( ObjectFactory objects, TaskContainer tasks, @@ -78,18 +99,21 @@ public class ArtifactAssemblyHandler { } public void configureAssembly(ArtifactSlot artifactSlot, Action action) { - var visitor = contributionVisitor(artifactSlot); - var spec = new DefaultArtifactAssemblySpec(objects, c -> c.accept(visitor)); + var spec = new DefaultArtifactAssemblySpec(artifactSlot); action.execute(spec); } - public SlotContributionVisitor contributionVisitor(ArtifactSlot artifactSlot) { - var assembly = slotInputs.computeIfAbsent(artifactSlot, this::createSlotAssembly); - return new ContributionVisitor(artifactSlot, assembly); + private void useAssemblyMode(ArtifactSlot artifactSlot, AssemblyMode mode) { + var previous = assemblyModes.putIfAbsent(artifactSlot, mode); + if (previous != null && previous != mode) { + throw new InvalidUserDataException("Artifact slot '" + artifactSlot + + "' cannot mix task-produced artifact and contribution-based assembly"); + } } /** - * Creates the assembly task for the given slot and registers its output artifact. + * Creates the assembly task for the given slot and registers its output + * artifact. */ private SlotAssembly createSlotAssembly(ArtifactSlot artifactSlot) { var assembly = new SlotAssembly(); @@ -199,4 +223,99 @@ public class ArtifactAssemblyHandler { return inputs; } } + + private enum AssemblyMode { + CONTRIBUTIONS, + TASK_PRODUCER + } + + /** + * Default DSL facade for collecting {@link SlotContribution} declarations. + * + *

+ * The spec does not validate topology references immediately. It translates DSL + * calls to contribution objects and passes them to the supplied consumer; + * semantic + * validation happens later when the assembly handler resolves contributions + * against the finalized source model. + */ + class DefaultArtifactAssemblySpec implements ArtifactAssemblySpec { + + private final ArtifactSlot artifactSlot; + + DefaultArtifactAssemblySpec(ArtifactSlot artifactSlot) { + this.artifactSlot = artifactSlot; + } + + @Override + public void from(Object artifact) { + contribute(new DirectContribution(artifact)); + } + + @Override + public void producedBy( + TaskProvider task, + Function> artifact) { + registerProducedArtifact(task, artifact); + } + + @Override + public void fromVariant(Action action) { + contribute(new VariantOutputsContribution(outputs(action))); + } + + @Override + public void fromRole(String roleName, Action action) { + + contribute(new RoleOutputsContribution( + objects.named(Role.class, roleName), + outputs(action))); + } + + @Override + public void fromLayer(String layerName, Action action) { + contribute(new LayerOutputsContribution( + objects.named(Layer.class, layerName), + outputs(action))); + } + + private static Set outputs(Action action) { + var spec = new OutputsSetSpec(); + action.execute(spec); + return spec.outputs(); + } + + void contribute(SlotContribution contribution) { + useAssemblyMode(artifactSlot, AssemblyMode.CONTRIBUTIONS); + var assembly = slotInputs.computeIfAbsent(artifactSlot, ArtifactAssemblyHandler.this::createSlotAssembly); + var contributionVisitor = new ContributionVisitor(artifactSlot, assembly); + contribution.accept(contributionVisitor); + } + + void registerProducedArtifact( + TaskProvider task, + Function> artifact) { + useAssemblyMode(artifactSlot, AssemblyMode.TASK_PRODUCER); + assemblyRegistry.register(artifactSlot, task, artifact); + } + + } + + /** Simple implementation of {@link OutputSelectionSpec}. */ + static class OutputsSetSpec implements OutputSelectionSpec { + private final Set outputs = new HashSet<>(); + + @Override + public void output(String name, String... extra) { + Stream.concat(Stream.of(name), Stream.of(extra)) + .map(Strings::requireNonBlank) + .forEach(outputs::add); + } + + Set outputs() { + return Set.copyOf(outputs); + } + + } + } diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java deleted file mode 100644 --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.implab.gradle.variants.artifacts.internal; - -import java.util.HashSet; -import java.util.Set; -import java.util.function.Consumer; -import java.util.stream.Stream; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.gradle.api.Action; -import org.gradle.api.model.ObjectFactory; -import org.implab.gradle.common.core.lang.Strings; -import org.implab.gradle.variants.artifacts.ArtifactAssemblySpec; -import org.implab.gradle.variants.core.Layer; -import org.implab.gradle.variants.core.Role; -import org.implab.gradle.variants.artifacts.OutputSelectionSpec; - -/** - * Default DSL facade for collecting {@link SlotContribution} declarations. - * - *

The spec does not validate topology references immediately. It translates DSL - * calls to contribution objects and passes them to the supplied consumer; semantic - * validation happens later when the assembly handler resolves contributions - * against the finalized source model. - */ -@NonNullByDefault -final class DefaultArtifactAssemblySpec implements ArtifactAssemblySpec { - private final Consumer consumer; - private final ObjectFactory objectFactory; - - DefaultArtifactAssemblySpec(ObjectFactory objectFactory, Consumer consumer) { - this.consumer = consumer; - this.objectFactory = objectFactory; - } - - @Override - public void from(Object artifact) { - consumer.accept(new DirectContribution(artifact)); - } - - @Override - public void fromVariant(Action action) { - consumer.accept(new VariantOutputsContribution(outputs(action))); - } - - @Override - public void fromRole(String roleName, Action action) { - - consumer.accept(new RoleOutputsContribution( - objectFactory.named(Role.class, roleName), - outputs(action))); - } - - @Override - public void fromLayer(String layerName, Action action) { - consumer.accept(new LayerOutputsContribution( - objectFactory.named(Layer.class, layerName), - outputs(action))); - } - - private static Set outputs(Action action) { - var spec = new OutputsSetSpec(); - action.execute(spec); - return spec.outputs(); - } - - private static class OutputsSetSpec implements OutputSelectionSpec { - private final Set outputs = new HashSet<>(); - - @Override - public void output(String name, String... extra) { - Stream.concat(Stream.of(name), Stream.of(extra)) - .map(Strings::requireNonBlank) - .forEach(outputs::add); - } - - Set outputs() { - return Set.copyOf(outputs); - } - - } -} diff --git a/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java b/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java --- a/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java +++ b/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java @@ -422,6 +422,170 @@ class VariantArtifactsPluginFunctionalTe } @Test + void publishesTaskProducedFileArtifactDirectly() throws Exception { + writeFile("settings.gradle", """ + rootProject.name = 'variant-artifacts-task-produced-file' + include 'producer', 'consumer' + """); + writeBuildFile(""" + import org.gradle.api.DefaultTask + import org.gradle.api.attributes.Attribute + import org.gradle.api.file.RegularFileProperty + import org.gradle.api.tasks.OutputFile + import org.gradle.api.tasks.TaskAction + + def variantAttr = Attribute.of('test.variant', String) + def slotAttr = Attribute.of('test.slot', String) + + abstract class WritePackageMetadata extends DefaultTask { + @OutputFile + abstract RegularFileProperty getOutputFile() + + @TaskAction + void write() { + def file = outputFile.get().asFile + file.parentFile.mkdirs() + file.text = '{"name":"demo"}\\n' + } + } + + project(':producer') { + apply plugin: org.implab.gradle.variants.VariantArtifactsPlugin + + variants.layers.create('main') + variants.roles.create('main') + variants.variant('browser') { + role('main') { + layers('main') + } + } + + def writePackageMetadata = tasks.register('writePackageMetadata', WritePackageMetadata) { + outputFile.set(layout.buildDirectory.file('generated/package.json')) + } + + variantArtifacts { + variant('browser') { + primarySlot('packageMetadata') { + producedBy(writePackageMetadata) { + outputFile + } + } + } + + whenOutgoingConfiguration { publication -> + publication.configuration { + attributes.attribute(variantAttr, publication.variant.name) + } + } + + whenOutgoingSlot { publication -> + publication.artifactAttributes { + attribute(slotAttr, publication.artifactSlot.slot.name) + } + } + } + + tasks.register('checkNoManagedAssembly') { + doLast { + def assemblyTasks = tasks.names + .findAll { it.startsWith('assembleVariantArtifactSlot') } + .sort() + println('producerAssemblyTasks=' + assemblyTasks.join(',')) + assert assemblyTasks.empty + } + } + } + + project(':consumer') { + configurations { + compileView { + canBeResolved = true + canBeConsumed = false + canBeDeclared = true + attributes { + attribute(variantAttr, 'browser') + attribute(slotAttr, 'packageMetadata') + } + } + } + + dependencies { + compileView project(':producer') + } + + tasks.register('probe') { + dependsOn configurations.compileView + dependsOn ':producer:checkNoManagedAssembly' + + doLast { + def files = configurations.compileView.files + println('resolvedFiles=' + files.collect { it.name }.sort().join(',')) + println('metadata=' + files.iterator().next().text.trim()) + } + } + } + """); + + BuildResult result = runner(":consumer:probe").build(); + + assertTrue(result.getOutput().contains("producerAssemblyTasks=")); + assertTrue(result.getOutput().contains("resolvedFiles=package.json")); + assertTrue(result.getOutput().contains("metadata={\"name\":\"demo\"}")); + assertTrue(result.task(":producer:writePackageMetadata").getOutcome() == TaskOutcome.SUCCESS); + assertTrue(result.task(":consumer:probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test + void failsWhenTaskProducedArtifactIsMixedWithContributionAssembly() throws Exception { + writeSettings("variant-artifacts-mixed-assembly-mode"); + writeFile("inputs/marker.txt", "marker\n"); + writeBuildFile(""" + import org.gradle.api.DefaultTask + import org.gradle.api.file.RegularFileProperty + import org.gradle.api.tasks.OutputFile + import org.gradle.api.tasks.TaskAction + + apply plugin: org.implab.gradle.variants.VariantArtifactsPlugin + + abstract class WritePackageMetadata extends DefaultTask { + @OutputFile + abstract RegularFileProperty getOutputFile() + + @TaskAction + void write() { + outputFile.get().asFile.text = '{}\\n' + } + } + + variants.layers.create('main') + variants.roles.create('main') + variants.variant('browser') { + role('main') { + layers('main') + } + } + + def writePackageMetadata = tasks.register('writePackageMetadata', WritePackageMetadata) { + outputFile.set(layout.buildDirectory.file('generated/package.json')) + } + + variantArtifacts { + variant('browser') { + primarySlot('packageMetadata') { + producedBy(writePackageMetadata) { + outputFile + } + from(layout.projectDirectory.file('inputs/marker.txt')) + } + } + } + """); + + assertBuildFails("cannot mix task-produced artifact and contribution-based assembly", "help"); + } + + @Test void combinesDirectAndTopologyAwareSlotInputs() throws Exception { writeSettings("variant-artifacts-combined-inputs"); writeFile("inputs/base.js", "console.log('base')\n");