From be21c349383c5a2ac0fee1940791aee501a7f837 Mon Sep 17 00:00:00 2001 From: Jen Basch Date: Wed, 25 Feb 2026 08:03:53 -0800 Subject: [PATCH] Allow command `transformAll` functions to perform imports (#1440) --- docs/modules/pkl-cli/pages/index.adoc | 4 +- docs/modules/release-notes/pages/0.31.adoc | 2 +- .../kotlin/org/pkl/cli/CliCommandRunner.kt | 11 ++-- .../org/pkl/cli/CliCommandRunnerTest.kt | 59 +++++++++++++++++++ .../main/java/org/pkl/core/CommandSpec.java | 4 +- .../pkl/core/runtime/CommandSpecParser.java | 22 +++---- stdlib/Command.pkl | 8 +++ 7 files changed, 91 insertions(+), 19 deletions(-) diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index d0a8f2e9..8bb91adb 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -1070,10 +1070,10 @@ A property may be annotated with any type if its `@Flag` or `@Argument` annotati The `convert` property is a xref:language-reference:index.adoc#anonymous-functions[function] that overrides how _each_ raw option value is interpreted. The `transformAll` property is a function that overrides how _all_ parsed option values become the final property value. -The `convert` function may return an link:{uri-stdlib-Command-Import}[`Import`] value that is replaced during option parsing with the actual value of the module specified by its `uri` property. +The `convert` and `transformAll` functions may return an pkldoc:Import[pkl:Command] value that is replaced during option parsing with the actual value of the module specified by its `uri` property. If `glob` is `true`, the replacement value is a `Mapping`; its keys are the _absolute_ URIs of the matched modules and its values are the actual module values. When specifying glob import options on the command line, it is often necessary to quote the value to avoid it being interpreted by the shell. -If the return value of `convert` is a `List`, `Set`, `Map`, or `Pair`, each contained value (elements and entry keys/values) that are `Import` values are also replaced. +If the return value of `convert` or `transformAll` is a `List`, `Set`, `Map`, or `Pair`, each contained value (elements and entry keys/values) that are `Import` values are also replaced. [IMPORTANT] ==== diff --git a/docs/modules/release-notes/pages/0.31.adoc b/docs/modules/release-notes/pages/0.31.adoc index d5990983..17e9fbcd 100644 --- a/docs/modules/release-notes/pages/0.31.adoc +++ b/docs/modules/release-notes/pages/0.31.adoc @@ -99,7 +99,7 @@ To learn more about this feature, consult https://github.com/apple/pkl-evolution [[cli-framework]] === CLI Framework -Pkl 0.31 introduces a new framework for implementing CLI tools in Pkl (pr:https://github.com/apple/pkl/pull/1367[], pr:https://github.com/apple/pkl/pull/1431[], pr:https://github.com/apple/pkl/pull/1432[], pr:https://github.com/apple/pkl/pull/1436[]). +Pkl 0.31 introduces a new framework for implementing CLI tools in Pkl (pr:https://github.com/apple/pkl/pull/1367[], pr:https://github.com/apple/pkl/pull/1431[], pr:https://github.com/apple/pkl/pull/1432[], pr:https://github.com/apple/pkl/pull/1436[], pr:https://github.com/apple/pkl/pull/1440[]). The framework provides a way to build command line tools with user experience idioms that will be immediately familiar to users. CLI tools implemented in Pkl have largely the same capabilities as normal Pkl evaluation (i.e. writing to standard output and files), but this may be extended using xref:language-reference:index.adoc#external-readers[external readers]. diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt index 34e3b81f..ebd073be 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt @@ -22,6 +22,7 @@ import com.github.ajalt.clikt.parameters.arguments.* import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.types.int import java.io.OutputStream +import java.net.URI import java.nio.file.Path import kotlin.io.path.createParentDirectories import kotlin.io.path.exists @@ -159,7 +160,7 @@ constructor( ) .convert { try { - opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri()) + opt.transformEach.apply(it, workingDirUri) } catch (e: CommandSpec.Option.BadValue) { fail(e.message!!) } catch (_: CommandSpec.Option.MissingOption) { @@ -168,7 +169,7 @@ constructor( } .transformAll(opt.defaultValue, opt.showAsRequired) { try { - opt.transformAll.apply(it) + opt.transformAll.apply(it, workingDirUri) } catch (e: CommandSpec.Option.BadValue) { fail(e.message!!) } catch (_: CommandSpec.Option.MissingOption) { @@ -201,7 +202,7 @@ constructor( ) .convert { try { - opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri()) + opt.transformEach.apply(it, workingDirUri) } catch (e: CommandSpec.Option.BadValue) { fail(e.message!!) } catch (_: CommandSpec.Option.MissingOption) { @@ -210,7 +211,7 @@ constructor( } .transformAll(if (opt.repeated) -1 else 1, !opt.repeated) { try { - opt.transformAll.apply(it) + opt.transformAll.apply(it, workingDirUri) } catch (e: CommandSpec.Option.BadValue) { fail(e.message!!) } catch (_: CommandSpec.Option.MissingOption) { @@ -223,6 +224,8 @@ constructor( spec.subcommands.forEach { subcommands(SynthesizedRunCommand(it, runner)) } } + val workingDirUri: URI by lazy { runner.options.normalizedWorkingDir.toUri() } + override val invokeWithoutSubcommand = true override val hiddenFromHelp: Boolean = spec.hidden diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt index 8356c13c..888e81d8 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt @@ -867,6 +867,65 @@ class CliCommandRunnerTest { ) } + @Test + fun `transformAll import`() { + val moduleUri = + writePklFile( + "cmd.pkl", + """ + extends "pkl:Command" + + options: Options + + output { + value = (options) { + fromImport { + baz = true // assert that imported modules are not forced + } + } + } + + class Options { + @Flag { + convert = (it) -> new Import{ uri = it } + transformAll = (values) -> values.firstOrNull ?? new Import { uri = "./default.pkl" } + } + fromImport: Module + } + """ + .trimIndent(), + ) + + val importUri = + writePklFile( + "default.pkl", + """ + foo = 1 + bar = "baz" + baz: Boolean + """ + .trimIndent(), + ) + + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri), workingDir = tempDir), + emptyList(), + ) + assertThat(output) + .isEqualTo( + """ + fromImport { + foo = 1 + bar = "baz" + baz = true + } + + """ + .trimIndent() + ) + } + @Test fun `convert glob import`() { val moduleUri = diff --git a/pkl-core/src/main/java/org/pkl/core/CommandSpec.java b/pkl-core/src/main/java/org/pkl/core/CommandSpec.java index 3fafa0be..61871539 100644 --- a/pkl-core/src/main/java/org/pkl/core/CommandSpec.java +++ b/pkl-core/src/main/java/org/pkl/core/CommandSpec.java @@ -89,7 +89,7 @@ public record CommandSpec( @Nullable String helpText, boolean showAsRequired, BiFunction transformEach, - Function, Object> transformAll, + BiFunction, URI, Object> transformAll, @Nullable CompletionCandidates completionCandidates, @Nullable String shortName, String metavar, @@ -134,7 +134,7 @@ public record CommandSpec( String name, @Nullable String helpText, BiFunction transformEach, - Function, Object> transformAll, + BiFunction, URI, Object> transformAll, @Nullable CompletionCandidates completionCandidates, boolean repeated) implements Option { diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java b/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java index 6e25d529..c584788c 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java @@ -462,7 +462,7 @@ public final class CommandSpecParser { private class OptionBehavior { private @Nullable BiFunction each; - private @Nullable Function, Object> all; + private @Nullable BiFunction, URI, Object> all; private @Nullable Boolean multiple; private @Nullable String metavar; private @Nullable CommandSpec.CompletionCandidates completionCandidates; @@ -471,7 +471,7 @@ public final class CommandSpecParser { private OptionBehavior( @Nullable BiFunction each, - @Nullable Function, Object> all, + @Nullable BiFunction, URI, Object> all, @Nullable Boolean multiple, @Nullable String metavar, @Nullable CommandSpec.CompletionCandidates completionCandidates) { @@ -493,7 +493,9 @@ public final class CommandSpecParser { annotation == null ? null : VmUtils.readMember(annotation, Identifier.TRANSFORM_ALL) instanceof VmFunction func - ? (it) -> handleBadValue(() -> func.apply(VmList.create(it))) + ? (values, workingDirUri) -> + handleBadValue( + () -> handleImports(func.apply(VmList.create(values)), workingDirUri)) : null, annotation == null ? null @@ -533,7 +535,7 @@ public final class CommandSpecParser { all = !multiple ? this::allChooseLast - : (values) -> { + : (values, workingDirUri) -> { if (values.isEmpty()) return null; var builder = new VmObjectBuilder(); values.forEach(builder::addElement); @@ -547,7 +549,7 @@ public final class CommandSpecParser { all = !multiple ? this::allChooseLast - : (values) -> { + : (values, workingDirUri) -> { if (values.isEmpty()) return null; var builder = new VmObjectBuilder(); values.forEach( @@ -563,7 +565,7 @@ public final class CommandSpecParser { all = !multiple ? this::allChooseLast - : (values) -> values.isEmpty() ? null : VmList.create(values); + : (values, workingDirUri) -> values.isEmpty() ? null : VmList.create(values); } else if (typeNode instanceof TypeNode.SetTypeNode setTypeNode) { handleElement(setTypeNode.getElementTypeNode(), prop); if (multiple == null) multiple = true; @@ -571,7 +573,7 @@ public final class CommandSpecParser { all = !multiple ? this::allChooseLast - : (values) -> values.isEmpty() ? null : VmSet.create(values); + : (values, workingDirUri) -> values.isEmpty() ? null : VmSet.create(values); } else if (typeNode instanceof TypeNode.MapTypeNode mapTypeNode) { handleEntry(mapTypeNode.getKeyTypeNode(), mapTypeNode.getValueTypeNode(), prop); if (multiple == null) multiple = true; @@ -579,7 +581,7 @@ public final class CommandSpecParser { all = !multiple ? this::allChooseLast - : (values) -> { + : (values, workingDirUri) -> { if (values.isEmpty()) return null; var builder = VmMap.builder(); values.forEach( @@ -860,7 +862,7 @@ public final class CommandSpecParser { if (metavar == null) metavar = transformKey.getMetavar() + "=" + transformValue.getMetavar(); } - private @Nullable Object allChooseLast(List values) { + private @Nullable Object allChooseLast(List values, URI workingDirUri) { if (!values.isEmpty()) return values.get(values.size() - 1); if (isOptional()) return null; throw new MissingOption(); @@ -898,7 +900,7 @@ public final class CommandSpecParser { return each; } - public Function, Object> getAll() { + public BiFunction, URI, Object> getAll() { assert all != null; return all; } diff --git a/stdlib/Command.pkl b/stdlib/Command.pkl index 2fc0852d..96b82041 100644 --- a/stdlib/Command.pkl +++ b/stdlib/Command.pkl @@ -176,6 +176,10 @@ class Flag extends BaseFlag { /// Customize the behavior of turning all parsed flag values into the final option value. /// + /// When the return value is an [Import] value or a [Pair] member, [List] or [Set] element + /// containing an [Import], the URI or glob URI specified by the value is imported and the value + /// is replaced with the value of the imported module(s). + /// /// If no value is provided, all flag values are transformed according to the option's type: /// /// | Type | Behavior | @@ -235,6 +239,10 @@ class Argument extends Annotation { /// Customize the behavior of turning all parsed flag values into the final option value. /// + /// When the return value is an [Import] value or a [Pair] member, [List] or [Set] element + /// containing an [Import], the URI or glob URI specified by the value is imported and the value + /// is replaced with the value of the imported module(s). + /// /// If no value is provided, all option values are transformed using the same rules as /// [Flag.transformAll]. transformAll: ((List) -> Any)?