Allow command transformAll functions to perform imports (#1440)

This commit is contained in:
Jen Basch
2026-02-25 08:03:53 -08:00
committed by GitHub
parent 2e4d73b957
commit be21c34938
7 changed files with 91 additions and 19 deletions

View File

@@ -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 `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 `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. 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. 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] [IMPORTANT]
==== ====

View File

@@ -99,7 +99,7 @@ To learn more about this feature, consult https://github.com/apple/pkl-evolution
[[cli-framework]] [[cli-framework]]
=== 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. 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]. 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].

View File

@@ -22,6 +22,7 @@ import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.int
import java.io.OutputStream import java.io.OutputStream
import java.net.URI
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.createParentDirectories import kotlin.io.path.createParentDirectories
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -159,7 +160,7 @@ constructor(
) )
.convert { .convert {
try { try {
opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri()) opt.transformEach.apply(it, workingDirUri)
} catch (e: CommandSpec.Option.BadValue) { } catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!) fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) { } catch (_: CommandSpec.Option.MissingOption) {
@@ -168,7 +169,7 @@ constructor(
} }
.transformAll(opt.defaultValue, opt.showAsRequired) { .transformAll(opt.defaultValue, opt.showAsRequired) {
try { try {
opt.transformAll.apply(it) opt.transformAll.apply(it, workingDirUri)
} catch (e: CommandSpec.Option.BadValue) { } catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!) fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) { } catch (_: CommandSpec.Option.MissingOption) {
@@ -201,7 +202,7 @@ constructor(
) )
.convert { .convert {
try { try {
opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri()) opt.transformEach.apply(it, workingDirUri)
} catch (e: CommandSpec.Option.BadValue) { } catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!) fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) { } catch (_: CommandSpec.Option.MissingOption) {
@@ -210,7 +211,7 @@ constructor(
} }
.transformAll(if (opt.repeated) -1 else 1, !opt.repeated) { .transformAll(if (opt.repeated) -1 else 1, !opt.repeated) {
try { try {
opt.transformAll.apply(it) opt.transformAll.apply(it, workingDirUri)
} catch (e: CommandSpec.Option.BadValue) { } catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!) fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) { } catch (_: CommandSpec.Option.MissingOption) {
@@ -223,6 +224,8 @@ constructor(
spec.subcommands.forEach { subcommands(SynthesizedRunCommand(it, runner)) } spec.subcommands.forEach { subcommands(SynthesizedRunCommand(it, runner)) }
} }
val workingDirUri: URI by lazy { runner.options.normalizedWorkingDir.toUri() }
override val invokeWithoutSubcommand = true override val invokeWithoutSubcommand = true
override val hiddenFromHelp: Boolean = spec.hidden override val hiddenFromHelp: Boolean = spec.hidden

View File

@@ -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 @Test
fun `convert glob import`() { fun `convert glob import`() {
val moduleUri = val moduleUri =

View File

@@ -89,7 +89,7 @@ public record CommandSpec(
@Nullable String helpText, @Nullable String helpText,
boolean showAsRequired, boolean showAsRequired,
BiFunction<String, URI, Object> transformEach, BiFunction<String, URI, Object> transformEach,
Function<List<Object>, Object> transformAll, BiFunction<List<Object>, URI, Object> transformAll,
@Nullable CompletionCandidates completionCandidates, @Nullable CompletionCandidates completionCandidates,
@Nullable String shortName, @Nullable String shortName,
String metavar, String metavar,
@@ -134,7 +134,7 @@ public record CommandSpec(
String name, String name,
@Nullable String helpText, @Nullable String helpText,
BiFunction<String, URI, Object> transformEach, BiFunction<String, URI, Object> transformEach,
Function<List<Object>, Object> transformAll, BiFunction<List<Object>, URI, Object> transformAll,
@Nullable CompletionCandidates completionCandidates, @Nullable CompletionCandidates completionCandidates,
boolean repeated) boolean repeated)
implements Option { implements Option {

View File

@@ -462,7 +462,7 @@ public final class CommandSpecParser {
private class OptionBehavior { private class OptionBehavior {
private @Nullable BiFunction<String, URI, Object> each; private @Nullable BiFunction<String, URI, Object> each;
private @Nullable Function<List<Object>, Object> all; private @Nullable BiFunction<List<Object>, URI, Object> all;
private @Nullable Boolean multiple; private @Nullable Boolean multiple;
private @Nullable String metavar; private @Nullable String metavar;
private @Nullable CommandSpec.CompletionCandidates completionCandidates; private @Nullable CommandSpec.CompletionCandidates completionCandidates;
@@ -471,7 +471,7 @@ public final class CommandSpecParser {
private OptionBehavior( private OptionBehavior(
@Nullable BiFunction<String, URI, Object> each, @Nullable BiFunction<String, URI, Object> each,
@Nullable Function<List<Object>, Object> all, @Nullable BiFunction<List<Object>, URI, Object> all,
@Nullable Boolean multiple, @Nullable Boolean multiple,
@Nullable String metavar, @Nullable String metavar,
@Nullable CommandSpec.CompletionCandidates completionCandidates) { @Nullable CommandSpec.CompletionCandidates completionCandidates) {
@@ -493,7 +493,9 @@ public final class CommandSpecParser {
annotation == null annotation == null
? null ? null
: VmUtils.readMember(annotation, Identifier.TRANSFORM_ALL) instanceof VmFunction func : 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, : null,
annotation == null annotation == null
? null ? null
@@ -533,7 +535,7 @@ public final class CommandSpecParser {
all = all =
!multiple !multiple
? this::allChooseLast ? this::allChooseLast
: (values) -> { : (values, workingDirUri) -> {
if (values.isEmpty()) return null; if (values.isEmpty()) return null;
var builder = new VmObjectBuilder(); var builder = new VmObjectBuilder();
values.forEach(builder::addElement); values.forEach(builder::addElement);
@@ -547,7 +549,7 @@ public final class CommandSpecParser {
all = all =
!multiple !multiple
? this::allChooseLast ? this::allChooseLast
: (values) -> { : (values, workingDirUri) -> {
if (values.isEmpty()) return null; if (values.isEmpty()) return null;
var builder = new VmObjectBuilder(); var builder = new VmObjectBuilder();
values.forEach( values.forEach(
@@ -563,7 +565,7 @@ public final class CommandSpecParser {
all = all =
!multiple !multiple
? this::allChooseLast ? this::allChooseLast
: (values) -> values.isEmpty() ? null : VmList.create(values); : (values, workingDirUri) -> values.isEmpty() ? null : VmList.create(values);
} else if (typeNode instanceof TypeNode.SetTypeNode setTypeNode) { } else if (typeNode instanceof TypeNode.SetTypeNode setTypeNode) {
handleElement(setTypeNode.getElementTypeNode(), prop); handleElement(setTypeNode.getElementTypeNode(), prop);
if (multiple == null) multiple = true; if (multiple == null) multiple = true;
@@ -571,7 +573,7 @@ public final class CommandSpecParser {
all = all =
!multiple !multiple
? this::allChooseLast ? this::allChooseLast
: (values) -> values.isEmpty() ? null : VmSet.create(values); : (values, workingDirUri) -> values.isEmpty() ? null : VmSet.create(values);
} else if (typeNode instanceof TypeNode.MapTypeNode mapTypeNode) { } else if (typeNode instanceof TypeNode.MapTypeNode mapTypeNode) {
handleEntry(mapTypeNode.getKeyTypeNode(), mapTypeNode.getValueTypeNode(), prop); handleEntry(mapTypeNode.getKeyTypeNode(), mapTypeNode.getValueTypeNode(), prop);
if (multiple == null) multiple = true; if (multiple == null) multiple = true;
@@ -579,7 +581,7 @@ public final class CommandSpecParser {
all = all =
!multiple !multiple
? this::allChooseLast ? this::allChooseLast
: (values) -> { : (values, workingDirUri) -> {
if (values.isEmpty()) return null; if (values.isEmpty()) return null;
var builder = VmMap.builder(); var builder = VmMap.builder();
values.forEach( values.forEach(
@@ -860,7 +862,7 @@ public final class CommandSpecParser {
if (metavar == null) metavar = transformKey.getMetavar() + "=" + transformValue.getMetavar(); if (metavar == null) metavar = transformKey.getMetavar() + "=" + transformValue.getMetavar();
} }
private @Nullable Object allChooseLast(List<Object> values) { private @Nullable Object allChooseLast(List<Object> values, URI workingDirUri) {
if (!values.isEmpty()) return values.get(values.size() - 1); if (!values.isEmpty()) return values.get(values.size() - 1);
if (isOptional()) return null; if (isOptional()) return null;
throw new MissingOption(); throw new MissingOption();
@@ -898,7 +900,7 @@ public final class CommandSpecParser {
return each; return each;
} }
public Function<List<Object>, Object> getAll() { public BiFunction<List<Object>, URI, Object> getAll() {
assert all != null; assert all != null;
return all; return all;
} }

View File

@@ -176,6 +176,10 @@ class Flag extends BaseFlag {
/// Customize the behavior of turning all parsed flag values into the final option value. /// 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: /// If no value is provided, all flag values are transformed according to the option's type:
/// ///
/// | Type | Behavior | /// | Type | Behavior |
@@ -235,6 +239,10 @@ class Argument extends Annotation {
/// Customize the behavior of turning all parsed flag values into the final option value. /// 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 /// If no value is provided, all option values are transformed using the same rules as
/// [Flag.transformAll]. /// [Flag.transformAll].
transformAll: ((List<Any>) -> Any)? transformAll: ((List<Any>) -> Any)?