mirror of
https://github.com/apple/pkl.git
synced 2026-03-20 08:14:15 +01:00
SPICE-0025: pkl run CLI framework (#1367)
This commit is contained in:
263
pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt
Normal file
263
pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt
Normal 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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
35
pkl-cli/src/main/kotlin/org/pkl/cli/OutputUtils.kt
Normal file
35
pkl-cli/src/main/kotlin/org/pkl/cli/OutputUtils.kt
Normal 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")
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
50
pkl-cli/src/main/kotlin/org/pkl/cli/commands/RunCommand.kt
Normal file
50
pkl-cli/src/main/kotlin/org/pkl/cli/commands/RunCommand.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
1155
pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt
Normal file
1155
pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user