Turn CLI commands into objects, self register subcommands (#935)

Usages of `RootCommand` no longer need to initialize a new instance, nor register subcommands.
This commit is contained in:
Daniel Chao
2025-02-05 09:18:23 -08:00
committed by GitHub
parent aadcccd0fc
commit 9784cd7265
15 changed files with 229 additions and 253 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -17,26 +17,10 @@
package org.pkl.cli
import com.github.ajalt.clikt.core.subcommands
import org.pkl.cli.commands.*
import org.pkl.commons.cli.cliMain
import org.pkl.core.Release
/** Main method of the Pkl CLI (command-line evaluator and REPL). */
internal fun main(args: Array<String>) {
cliMain {
val version = Release.current().versionInfo()
val helpLink = "${Release.current().documentation().homepage()}pkl-cli/index.html#usage"
RootCommand("pkl", version, helpLink)
.subcommands(
EvalCommand(helpLink),
ReplCommand(helpLink),
ServerCommand(helpLink),
TestCommand(helpLink),
ProjectCommand(helpLink),
DownloadPackageCommand(helpLink),
AnalyzeCommand(helpLink),
)
.main(args)
}
cliMain { RootCommand.main(args) }
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -25,42 +25,40 @@ import org.pkl.cli.CliImportAnalyzerOptions
import org.pkl.commons.cli.commands.ModulesCommand
import org.pkl.commons.cli.commands.single
class AnalyzeCommand(helpLink: String) :
object AnalyzeCommand :
NoOpCliktCommand(
name = "analyze",
help = "Commands related to static analysis",
epilog = "For more information, visit $helpLink"
epilog = "For more information, visit $helpLink",
) {
init {
subcommands(AnalyzeImportsCommand(helpLink))
}
companion object {
class AnalyzeImportsCommand(helpLink: String) :
ModulesCommand(
name = "imports",
helpLink = helpLink,
help = "Prints the graph of modules imported by the input module(s)."
) {
private val outputPath: Path? by
option(
names = arrayOf("-o", "--output-path"),
metavar = "<path>",
help = "File path where the output file is placed."
)
.path()
.single()
override fun run() {
val options =
CliImportAnalyzerOptions(
base = baseOptions.baseOptions(modules, projectOptions),
outputFormat = baseOptions.format,
outputPath = outputPath
)
CliImportAnalyzer(options).run()
}
}
subcommands(AnalyzeImportsCommand)
}
}
object AnalyzeImportsCommand :
ModulesCommand(
name = "imports",
helpLink = helpLink,
help = "Prints the graph of modules imported by the input module(s).",
) {
private val outputPath: Path? by
option(
names = arrayOf("-o", "--output-path"),
metavar = "<path>",
help = "File path where the output file is placed.",
)
.path()
.single()
override fun run() {
val options =
CliImportAnalyzerOptions(
base = baseOptions.baseOptions(modules, projectOptions),
outputFormat = baseOptions.format,
outputPath = outputPath,
)
CliImportAnalyzer(options).run()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -27,7 +27,7 @@ import org.pkl.commons.cli.commands.ProjectOptions
import org.pkl.commons.cli.commands.single
import org.pkl.core.packages.PackageUri
class DownloadPackageCommand(helpLink: String) :
object DownloadPackageCommand :
BaseCommand(
name = "download-package",
helpLink = helpLink,
@@ -44,7 +44,7 @@ class DownloadPackageCommand(helpLink: String) :
$ pkl download-package package://example.com/package1@1.0.0 package://example.com/package2@1.0.0
```
"""
.trimIndent()
.trimIndent(),
) {
private val projectOptions by ProjectOptions()
@@ -56,7 +56,7 @@ class DownloadPackageCommand(helpLink: String) :
private val noTransitive: Boolean by
option(
names = arrayOf("--no-transitive"),
help = "Skip downloading transitive dependencies of a package"
help = "Skip downloading transitive dependencies of a package",
)
.single()
.flag()
@@ -65,7 +65,7 @@ class DownloadPackageCommand(helpLink: String) :
CliPackageDownloader(
baseOptions.baseOptions(emptyList(), projectOptions),
packageUris,
noTransitive
noTransitive,
)
.run()
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -24,17 +24,13 @@ import org.pkl.cli.CliEvaluatorOptions
import org.pkl.commons.cli.commands.ModulesCommand
import org.pkl.commons.cli.commands.single
class EvalCommand(helpLink: String) :
ModulesCommand(
name = "eval",
help = "Render pkl module(s)",
helpLink = helpLink,
) {
object EvalCommand :
ModulesCommand(name = "eval", help = "Render pkl module(s)", helpLink = helpLink) {
private val outputPath: String? by
option(
names = arrayOf("-o", "--output-path"),
metavar = "<path>",
help = "File path where the output file is placed."
help = "File path where the output file is placed.",
)
.single()
@@ -43,7 +39,7 @@ class EvalCommand(helpLink: String) :
names = arrayOf("--module-output-separator"),
metavar = "<string>",
help =
"Separator to use when multiple module outputs are written to the same file. (default: ---)"
"Separator to use when multiple module outputs are written to the same file. (default: ---)",
)
.single()
.default("---")
@@ -52,7 +48,7 @@ class EvalCommand(helpLink: String) :
option(
names = arrayOf("-x", "--expression"),
metavar = "<expression>",
help = "Expression to be evaluated within the module."
help = "Expression to be evaluated within the module.",
)
.single()
@@ -60,7 +56,7 @@ class EvalCommand(helpLink: String) :
option(
names = arrayOf("-m", "--multiple-file-output-path"),
metavar = "<path>",
help = "Directory where a module's multiple file output is placed."
help = "Directory where a module's multiple file output is placed.",
)
.single()
.validate {
@@ -81,7 +77,7 @@ class EvalCommand(helpLink: String) :
outputFormat = baseOptions.format,
moduleOutputSeparator = moduleOutputSeparator,
multipleFileOutputPath = multipleFileOutputPath,
expression = expression ?: CliEvaluatorOptions.defaults.expression
expression = expression ?: CliEvaluatorOptions.defaults.expression,
)
CliEvaluator(options).run()
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -31,119 +31,117 @@ import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.commons.cli.commands.TestOptions
import org.pkl.commons.cli.commands.single
class ProjectCommand(helpLink: String) :
private const val NEWLINE = '\u0085'
object ProjectCommand :
NoOpCliktCommand(
name = "project",
help = "Run commands related to projects",
epilog = "For more information, visit $helpLink"
epilog = "For more information, visit $helpLink",
) {
init {
subcommands(ResolveCommand(helpLink), PackageCommand(helpLink))
}
companion object {
class ResolveCommand(helpLink: String) :
BaseCommand(
name = "resolve",
helpLink = helpLink,
help =
"""
Resolve dependencies for project(s)
This command takes the `dependencies` of `PklProject`s, and writes the
resolved versions to `PklProject.deps.json` files.
Examples:
```
# Search the current working directory for a project, and resolve its dependencies.
$ pkl project resolve
# Resolve dependencies for all projects within the `packages/` directory.
$ pkl project resolve packages/*/
```
""",
) {
private val projectDirs: List<Path> by
argument("<dir>", "The project directories to resolve dependencies for").path().multiple()
override fun run() {
CliProjectResolver(baseOptions.baseOptions(emptyList()), projectDirs).run()
}
}
private const val NEWLINE = '\u0085'
class PackageCommand(helpLink: String) :
BaseCommand(
name = "package",
helpLink = helpLink,
help =
"""
Verify package(s), and prepare package artifacts to be published.
This command runs a project's api tests, as defined by `apiTests` in `PklProject`.
Additionally, it verifies that all imports resolve to paths that are local to the project.
Finally, this command writes the following artifacts into the output directory specified by the output path.
- `name@version` - dependency metadata$NEWLINE
- `name@version.sha256` - dependency metadata's SHA-256 checksum$NEWLINE
- `name@version.zip` - package archive$NEWLINE
- `name@version.zip.sha256` - package archive's SHA-256 checksum
The output path option accepts the following placeholders:
- %{name}: The display name of the package$NEWLINE
- %{version}: The version of the package
If a project has local project dependencies, the depended upon project directories must also
be included as arguments to this command.
Examples:
```
# Search the current working directory for a project, and package it.
$ pkl project package
# Package all projects within the `packages/` directory.
$ pkl project package packages/*/
```
"""
.trimIndent(),
) {
private val testOptions by TestOptions()
private val projectDirs: List<Path> by
argument("<dir>", "The project directories to package").path().multiple()
private val outputPath: String by
option(
names = arrayOf("--output-path"),
help = "The directory to write artifacts to",
metavar = "<path>"
)
.single()
.default(".out/%{name}@%{version}")
private val skipPublishCheck: Boolean by
option(
names = arrayOf("--skip-publish-check"),
help = "Skip checking if a package has already been published with different contents",
)
.single()
.flag()
override fun run() {
CliProjectPackager(
baseOptions.baseOptions(emptyList()),
projectDirs,
testOptions.cliTestOptions,
outputPath,
skipPublishCheck
)
.run()
}
}
subcommands(ResolveCommand, PackageCommand)
}
}
object ResolveCommand :
BaseCommand(
name = "resolve",
helpLink = helpLink,
help =
"""
Resolve dependencies for project(s)
This command takes the `dependencies` of `PklProject`s, and writes the
resolved versions to `PklProject.deps.json` files.
Examples:
```
# Search the current working directory for a project, and resolve its dependencies.
$ pkl project resolve
# Resolve dependencies for all projects within the `packages/` directory.
$ pkl project resolve packages/*/
```
""",
) {
private val projectDirs: List<Path> by
argument("<dir>", "The project directories to resolve dependencies for").path().multiple()
override fun run() {
CliProjectResolver(baseOptions.baseOptions(emptyList()), projectDirs).run()
}
}
object PackageCommand :
BaseCommand(
name = "package",
helpLink = helpLink,
help =
"""
Verify package(s), and prepare package artifacts to be published.
This command runs a project's api tests, as defined by `apiTests` in `PklProject`.
Additionally, it verifies that all imports resolve to paths that are local to the project.
Finally, this command writes the following artifacts into the output directory specified by the output path.
- `name@version` - dependency metadata$NEWLINE
- `name@version.sha256` - dependency metadata's SHA-256 checksum$NEWLINE
- `name@version.zip` - package archive$NEWLINE
- `name@version.zip.sha256` - package archive's SHA-256 checksum
The output path option accepts the following placeholders:
- %{name}: The display name of the package$NEWLINE
- %{version}: The version of the package
If a project has local project dependencies, the depended upon project directories must also
be included as arguments to this command.
Examples:
```
# Search the current working directory for a project, and package it.
$ pkl project package
# Package all projects within the `packages/` directory.
$ pkl project package packages/*/
```
"""
.trimIndent(),
) {
private val testOptions by TestOptions()
private val projectDirs: List<Path> by
argument("<dir>", "The project directories to package").path().multiple()
private val outputPath: String by
option(
names = arrayOf("--output-path"),
help = "The directory to write artifacts to",
metavar = "<path>",
)
.single()
.default(".out/%{name}@%{version}")
private val skipPublishCheck: Boolean by
option(
names = arrayOf("--skip-publish-check"),
help = "Skip checking if a package has already been published with different contents",
)
.single()
.flag()
override fun run() {
CliProjectPackager(
baseOptions.baseOptions(emptyList()),
projectDirs,
testOptions.cliTestOptions,
outputPath,
skipPublishCheck,
)
.run()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -21,12 +21,8 @@ import org.pkl.cli.CliRepl
import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.commons.cli.commands.ProjectOptions
class ReplCommand(helpLink: String) :
BaseCommand(
name = "repl",
help = "Start a REPL session",
helpLink = helpLink,
) {
object ReplCommand :
BaseCommand(name = "repl", help = "Start a REPL session", helpLink = helpLink) {
private val projectOptions by ProjectOptions()
override fun run() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -17,16 +17,20 @@ package org.pkl.cli.commands
import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.versionOption
import org.pkl.core.Release
class RootCommand(name: String, version: String, helpLink: String) :
internal val helpLink = "${Release.current().documentation.homepage}pkl-cli/index.html#usage"
object RootCommand :
NoOpCliktCommand(
name = name,
name = "pkl",
printHelpOnEmptyArgs = true,
epilog = "For more information, visit $helpLink",
) {
init {
versionOption(version, names = setOf("-v", "--version"), message = { it })
versionOption(Release.current().versionInfo, names = setOf("-v", "--version"), message = { it })
context {
correctionSuggestor = { given, possible ->
@@ -35,5 +39,15 @@ class RootCommand(name: String, version: String, helpLink: String) :
} else possible
}
}
subcommands(
EvalCommand,
ReplCommand,
ServerCommand,
TestCommand,
ProjectCommand,
DownloadPackageCommand,
AnalyzeCommand,
)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -19,11 +19,11 @@ import com.github.ajalt.clikt.core.CliktCommand
import org.pkl.cli.CliServer
import org.pkl.commons.cli.CliBaseOptions
class ServerCommand(helpLink: String) :
object ServerCommand :
CliktCommand(
name = "server",
help = "Run as a server that communicates over standard input/output",
epilog = "For more information, visit $helpLink"
epilog = "For more information, visit $helpLink",
) {
override fun run() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -26,7 +26,7 @@ import org.pkl.commons.cli.commands.BaseOptions
import org.pkl.commons.cli.commands.ProjectOptions
import org.pkl.commons.cli.commands.TestOptions
class TestCommand(helpLink: String) :
object TestCommand :
BaseCommand(name = "test", help = "Run tests within the given module(s)", helpLink = helpLink) {
val modules: List<URI> by
argument(name = "<modules>", help = "Module paths or URIs to evaluate.")
@@ -40,7 +40,7 @@ class TestCommand(helpLink: String) :
override fun run() {
CliTestRunner(
options = baseOptions.baseOptions(modules, projectOptions),
testOptions = testOptions.cliTestOptions
testOptions = testOptions.cliTestOptions,
)
.run()
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -16,7 +16,6 @@
package org.pkl.cli
import com.github.ajalt.clikt.core.BadParameterValue
import com.github.ajalt.clikt.core.subcommands
import java.nio.file.Path
import kotlin.io.path.createDirectory
import kotlin.io.path.createSymbolicLinkPointingTo
@@ -27,35 +26,34 @@ import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import org.junit.jupiter.api.io.TempDir
import org.pkl.cli.commands.AnalyzeCommand
import org.pkl.cli.commands.EvalCommand
import org.pkl.cli.commands.RootCommand
import org.pkl.commons.writeString
import org.pkl.core.Release
class CliMainTest {
private val evalCmd = EvalCommand("")
private val analyzeCommand = AnalyzeCommand("")
private val cmd = RootCommand("pkl", "pkl version 1", "").subcommands(evalCmd, analyzeCommand)
@Test
fun `duplicate CLI option produces meaningful error message`(@TempDir tempDir: Path) {
val inputFile = tempDir.resolve("test.pkl").writeString("").toString()
assertThatCode {
cmd.parse(arrayOf("eval", "--output-path", "path1", "--output-path", "path2", inputFile))
RootCommand.parse(
arrayOf("eval", "--output-path", "path1", "--output-path", "path2", inputFile)
)
}
.hasMessage("Invalid value for \"--output-path\": Option cannot be repeated")
assertThatCode {
cmd.parse(arrayOf("eval", "-o", "path1", "--output-path", "path2", inputFile))
RootCommand.parse(arrayOf("eval", "-o", "path1", "--output-path", "path2", inputFile))
}
.hasMessage("Invalid value for \"--output-path\": Option cannot be repeated")
}
@Test
fun `eval requires at least one file`() {
assertThatCode { cmd.parse(arrayOf("eval")) }.hasMessage("""Missing argument "<modules>"""")
assertThatCode { RootCommand.parse(arrayOf("eval")) }
.hasMessage("""Missing argument "<modules>"""")
}
// Can't reliably create symlinks on Windows.
@@ -76,7 +74,7 @@ class CliMainTest {
val inputFile = tempDir.resolve("test.pkl").writeString(code).toString()
val outputFile = makeSymdir(tempDir, "out", "linkOut").resolve("test.pkl").toString()
assertThatCode { cmd.parse(arrayOf("eval", inputFile, "-o", outputFile)) }
assertThatCode { RootCommand.parse(arrayOf("eval", inputFile, "-o", outputFile)) }
.doesNotThrowAnyException()
}
@@ -87,22 +85,24 @@ class CliMainTest {
val error =
"""Invalid value for "--multiple-file-output-path": Option is mutually exclusive with -o, --output-path and -x, --expression."""
assertThatCode { cmd.parse(arrayOf("eval", "-m", testOut, "-x", "x", testIn)) }
assertThatCode { RootCommand.parse(arrayOf("eval", "-m", testOut, "-x", "x", testIn)) }
.hasMessage(error)
assertThatCode { cmd.parse(arrayOf("eval", "-m", testOut, "-o", "/tmp/test", testIn)) }
assertThatCode { RootCommand.parse(arrayOf("eval", "-m", testOut, "-o", "/tmp/test", testIn)) }
.hasMessage(error)
}
@Test
fun `showing version works`() {
assertThatCode { cmd.parse(arrayOf("--version")) }.hasMessage("pkl version 1")
assertThatCode { RootCommand.parse(arrayOf("--version")) }
.hasMessage(Release.current().versionInfo)
}
@Test
fun `file paths get parsed into URIs`(@TempDir tempDir: Path) {
cmd.parse(arrayOf("eval", makeInput(tempDir, "my file.txt")))
RootCommand.parse(arrayOf("eval", makeInput(tempDir, "my file.txt")))
val evalCmd = RootCommand.registeredSubcommands().filterIsInstance<EvalCommand>().first()
val modules = evalCmd.baseOptions.baseOptions(evalCmd.modules).normalizedSourceModules
assertThat(modules).hasSize(1)
assertThat(modules[0].path).endsWith("my file.txt")
@@ -110,7 +110,8 @@ class CliMainTest {
@Test
fun `invalid URIs are not accepted`() {
val ex = assertThrows<BadParameterValue> { cmd.parse(arrayOf("eval", "file:my file.txt")) }
val ex =
assertThrows<BadParameterValue> { RootCommand.parse(arrayOf("eval", "file:my file.txt")) }
assertThat(ex.message).contains("URI `file:my file.txt` has invalid syntax")
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2025 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.
@@ -16,18 +16,16 @@
package org.pkl.cli
import com.github.ajalt.clikt.core.MissingArgument
import com.github.ajalt.clikt.core.subcommands
import java.io.StringWriter
import java.io.Writer
import java.net.URI
import java.nio.file.Path
import java.util.Locale
import java.util.*
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import org.pkl.cli.commands.EvalCommand
import org.pkl.cli.commands.RootCommand
import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.CliException
@@ -35,7 +33,6 @@ import org.pkl.commons.cli.CliTestOptions
import org.pkl.commons.readString
import org.pkl.commons.toUri
import org.pkl.commons.writeString
import org.pkl.core.Release
class CliTestRunnerTest {
@Test
@@ -381,7 +378,7 @@ class CliTestRunnerTest {
val opts =
CliBaseOptions(
sourceModules = listOf(input.toUri(), input2.toUri()),
settings = URI("pkl:settings")
settings = URI("pkl:settings"),
)
val testOpts = CliTestOptions(junitDir = tempDir)
val runner = CliTestRunner(opts, testOpts, noopWriter, noopWriter)
@@ -391,12 +388,7 @@ class CliTestRunnerTest {
@Test
fun `no source modules specified has same message as pkl eval`() {
val e1 = assertThrows<CliException> { CliTestRunner(CliBaseOptions(), CliTestOptions()).run() }
val e2 =
assertThrows<MissingArgument> {
val rootCommand =
RootCommand("pkl", Release.current().versionInfo(), "").subcommands(EvalCommand(""))
rootCommand.parse(listOf("eval"))
}
val e2 = assertThrows<MissingArgument> { RootCommand.parse(listOf("eval")) }
assertThat(e1).hasMessageContaining("Missing argument \"<modules>\"")
assertThat(e1.message!!.replace("test", "eval")).isEqualTo(e2.helpMessage())
}