SPICE-0025: pkl run CLI framework (#1367)

This commit is contained in:
Jen Basch
2026-02-12 07:53:02 -08:00
committed by GitHub
parent 63a20dd453
commit 72a57af164
35 changed files with 4706 additions and 147 deletions

View File

@@ -0,0 +1,263 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.cli
import com.github.ajalt.clikt.completion.CompletionCandidates
import com.github.ajalt.clikt.completion.CompletionCommand
import com.github.ajalt.clikt.core.*
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.nio.file.Path
import kotlin.io.path.createParentDirectories
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.writeBytes
import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.CliCommand
import org.pkl.commons.cli.CliException
import org.pkl.commons.cli.commands.installCommonOptions
import org.pkl.commons.currentWorkingDir
import org.pkl.core.Closeables
import org.pkl.core.CommandSpec
import org.pkl.core.EvaluatorBuilder
import org.pkl.core.FileOutput
import org.pkl.core.ModuleSource.uri
import org.pkl.core.PklBugException
import org.pkl.core.PklException
import org.pkl.core.util.IoUtils
class CliCommandRunner
@JvmOverloads
constructor(
private val options: CliBaseOptions,
private val args: List<String>,
private val outputStream: OutputStream = System.out,
private val errStream: OutputStream = System.err,
) : CliCommand(options) {
private val normalizedSourceModule = options.normalizedSourceModules.first()
override fun doRun() {
val builder = evaluatorBuilder()
try {
evalCmd(builder)
} finally {
Closeables.closeQuietly(builder.moduleKeyFactories)
Closeables.closeQuietly(builder.resourceReaders)
}
}
private fun evalCmd(builder: EvaluatorBuilder) {
val evaluator = builder.build()
evaluator.use {
evaluator.evaluateCommand(uri(normalizedSourceModule)) { spec ->
try {
val root = SynthesizedRunCommand(spec, this, options.sourceModules.first().toString())
root.installCommonOptions(includeVersion = false)
root.subcommands(
CompletionCommand(
name = "shell-completion",
help = "Generate a completion script for the given shell",
)
)
root.parse(args)
} catch (e: PklException) {
throw e
} catch (e: Exception) {
throw e.message?.let { PklException(it, e) } ?: PklException(e)
}
}
}
}
/** Renders the comand's `output.bytes`, writing it to the standard output stream. */
fun writeOutput(outputBytes: ByteArray) {
if (outputBytes.isEmpty()) return
outputStream.write(outputBytes)
outputStream.flush()
}
/**
* Renders the command's `output.files`, writing each entry as a file.
*
* File paths are written to the standard error stream.
*
* Unlike CliEvaluator, command outputs write relative to --working-dir and may write files
* anywhere in the filesystem. This is intentionally less sandboxed than `pkl eval` and directly
* targets the capabilities of CLI tools written in general purpose languages. Pkl commands should
* therefore be treated as untrusted code the way that any other CLI tool would be.
*/
fun writeMultipleFileOutput(outputFiles: Map<String, FileOutput>) {
if (outputFiles.isEmpty()) return
val writtenFiles = mutableMapOf<Path, String>()
val outputDir = options.normalizedWorkingDir
if (outputDir.exists() && !outputDir.isDirectory()) {
throw CliException("Output path `$outputDir` exists and is not a directory.")
}
for ((pathSpec, fileOutput) in outputFiles) {
checkPathSpec(pathSpec)
val resolvedPath = outputDir.resolve(pathSpec).normalize()
val realPath = if (resolvedPath.exists()) resolvedPath.toRealPath() else resolvedPath
val previousOutput = writtenFiles[realPath]
if (previousOutput != null) {
throw CliException(
"Output file conflict: `output.files` entries `\"${previousOutput}\"` and `\"$pathSpec\"` resolve to the same file path `$realPath`."
)
}
if (realPath.isDirectory()) {
throw CliException(
"Output file conflict: `output.files` entry `\"$pathSpec\"` resolves to file path `$realPath`, which is a directory."
)
}
writtenFiles[realPath] = pathSpec
realPath.createParentDirectories()
realPath.writeBytes(fileOutput.bytes)
val displayPath =
if (Path.of(pathSpec).isAbsolute) pathSpec
else IoUtils.relativize(resolvedPath, currentWorkingDir).toString()
errStream.writeText(displayPath + IoUtils.getLineSeparator())
errStream.flush()
}
}
class SynthesizedRunCommand(
private val spec: CommandSpec,
private val runner: CliCommandRunner,
name: String? = null,
) : CliktCommand(name = name ?: spec.name) {
init {
spec.options.forEach { opt ->
when (opt) {
is CommandSpec.Flag ->
registerOption(
option(
names = opt.names,
help = opt.helpText ?: "",
metavar = opt.metavar,
hidden = opt.hidden,
completionCandidates = opt.completionCandidates?.toClikt(),
)
.convert {
try {
opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri())
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingOption(option)
}
}
.transformAll(opt.defaultValue, opt.showAsRequired) {
try {
opt.transformAll.apply(it)
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingOption(option)
}
}
)
is CommandSpec.BooleanFlag ->
registerOption(
if (opt.defaultValue != null)
option(names = opt.names, help = opt.helpText ?: "", hidden = opt.hidden)
.flag("--no-${opt.name}", default = opt.defaultValue!!)
else
option(names = opt.names, help = opt.helpText ?: "", hidden = opt.hidden)
.nullableFlag("--no-${opt.name}")
)
is CommandSpec.CountedFlag ->
registerOption(
option(names = opt.names, help = opt.helpText ?: "", hidden = opt.hidden)
.int()
.transformValues(0..0) { it.lastOrNull() ?: 1 }
.transformAll { it.sum().toLong() }
)
is CommandSpec.Argument ->
registerArgument(
argument(
opt.name,
opt.helpText ?: "",
completionCandidates = opt.completionCandidates?.toClikt(),
)
.convert {
try {
opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri())
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingArgument(argument)
}
}
.transformAll(if (opt.repeated) -1 else 1, !opt.repeated) {
try {
opt.transformAll.apply(it)
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingArgument(argument)
}
}
)
}
}
spec.subcommands.forEach { subcommands(SynthesizedRunCommand(it, runner)) }
}
override val invokeWithoutSubcommand = true
override val hiddenFromHelp: Boolean = spec.hidden
override fun help(context: Context): String = spec.helpText ?: ""
override fun run() {
if (currentContext.invokedSubcommand is CompletionCommand) return
val opts =
registeredOptions()
.mapNotNull {
val opt = it as? OptionWithValues<*, *, *> ?: return@mapNotNull null
return@mapNotNull if (it.names.contains("--help")) null
else it.names.first().trimStart('-') to opt.value
}
.toMap() +
registeredArguments()
.mapNotNull { it as? ArgumentDelegate<*> }
.associateBy({ it.name }, { it.value })
val state = spec.apply.apply(opts, currentContext.obj as CommandSpec.State?)
currentContext.obj = state
if (currentContext.invokedSubcommand != null) return
if (spec.subcommands.isNotEmpty() && spec.noOp) {
throw PrintHelpMessage(currentContext, true, 1)
}
val result = state.evaluate()
runner.writeOutput(result.outputBytes)
runner.writeMultipleFileOutput(result.outputFiles)
}
}
}
fun CommandSpec.CompletionCandidates.toClikt(): CompletionCandidates =
when (this) {
CommandSpec.CompletionCandidates.PATH -> CompletionCandidates.Path
is CommandSpec.CompletionCandidates.Fixed -> CompletionCandidates.Fixed(values)
else -> throw PklBugException.unreachableCode()
}

View File

@@ -143,17 +143,6 @@ constructor(
}
}
private fun Evaluator.writeOutput(moduleSource: ModuleSource, writeTo: Path): Boolean {
if (options.expression == null) {
val bytes = evaluateOutputBytes(moduleSource)
writeTo.writeBytes(bytes)
return bytes.isNotEmpty()
}
val text = evaluateExpressionString(moduleSource, options.expression)
writeTo.writeString(text)
return text.isNotEmpty()
}
private fun Evaluator.evalOutput(moduleSource: ModuleSource): ByteArray {
if (options.expression == null) {
return evaluateOutputBytes(moduleSource)
@@ -227,13 +216,6 @@ constructor(
}
}
private fun OutputStream.writeText(text: String) = write(text.toByteArray())
private fun OutputStream.writeLine(text: String) {
writeText(text)
writeText("\n")
}
private fun toModuleSource(uri: URI, reader: InputStream) =
if (uri == VmUtils.REPL_TEXT_URI) {
ModuleSource.create(uri, reader.readAllBytes().toString(StandardCharsets.UTF_8))
@@ -241,14 +223,6 @@ constructor(
ModuleSource.uri(uri)
}
private fun checkPathSpec(pathSpec: String) {
val illegal = pathSpec.indexOfFirst { IoUtils.isReservedFilenameChar(it) && it != '/' }
if (illegal == -1) {
return
}
throw CliException("Path spec `$pathSpec` contains illegal character `${pathSpec[illegal]}`.")
}
/**
* Renders each module's `output.files`, writing each entry as a file into the specified output
* directory.

View File

@@ -0,0 +1,35 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.cli
import java.io.OutputStream
import org.pkl.commons.cli.CliException
import org.pkl.core.util.IoUtils
fun checkPathSpec(pathSpec: String) {
val illegal = pathSpec.indexOfFirst { IoUtils.isReservedFilenameChar(it) && it != '/' }
if (illegal == -1) {
return
}
throw CliException("Path spec `$pathSpec` contains illegal character `${pathSpec[illegal]}`.")
}
fun OutputStream.writeText(text: String) = write(text.toByteArray())
fun OutputStream.writeLine(text: String) {
writeText(text)
writeText("\n")
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -50,6 +50,7 @@ class RootCommand : NoOpCliktCommand(name = "pkl") {
DownloadPackageCommand(),
AnalyzeCommand(),
FormatterCommand(),
RunCommand(),
CompletionCommand(
name = "shell-completion",
help = "Generate a completion script for the given shell",

View File

@@ -0,0 +1,50 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.cli.commands
import com.github.ajalt.clikt.completion.CompletionCandidates
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.convert
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import java.net.URI
import org.pkl.cli.CliCommandRunner
import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.commons.cli.commands.BaseOptions
import org.pkl.commons.cli.commands.ProjectOptions
class RunCommand : BaseCommand(name = "run", helpLink = helpLink) {
override val helpString = "Run a Pkl pkl:Command CLI tool"
override val treatUnknownOptionsAsArgs = true
init {
context { allowInterspersedArgs = false }
}
val module: URI by
argument(name = "module", completionCandidates = CompletionCandidates.Path).convert {
BaseOptions.parseModuleName(it)
}
val args: List<String> by argument(name = "args").multiple()
private val projectOptions by ProjectOptions()
override fun run() {
CliCommandRunner(baseOptions.baseOptions(listOf(module), projectOptions), args).run()
}
}

File diff suppressed because it is too large Load Diff