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 `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]
====

View File

@@ -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].

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

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

View File

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

View File

@@ -462,7 +462,7 @@ public final class CommandSpecParser {
private class OptionBehavior {
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 String metavar;
private @Nullable CommandSpec.CompletionCandidates completionCandidates;
@@ -471,7 +471,7 @@ public final class CommandSpecParser {
private OptionBehavior(
@Nullable BiFunction<String, URI, Object> each,
@Nullable Function<List<Object>, Object> all,
@Nullable BiFunction<List<Object>, 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<Object> values) {
private @Nullable Object allChooseLast(List<Object> 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<List<Object>, Object> getAll() {
public BiFunction<List<Object>, URI, Object> getAll() {
assert all != null;
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.
///
/// 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>) -> Any)?