CORDA-1833: Create a picocli base class (#3826)

* Add shell extensions to CLI utils class and move into its own module

* Fix issue with completion script generation and slight refactor

* Fix autocompletion for logging level

* Delete uneeded comment

* More tidying up

* Make run function final

* Fixed an issue with the program being run twice.

* Address review comments
This commit is contained in:
Anthony Keenan 2018-08-22 21:51:25 +01:00 committed by GitHub
parent 98eef2f960
commit bcfadfeebf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 254 additions and 214 deletions

2
.idea/compiler.xml generated
View File

@ -26,6 +26,8 @@
<module name="canonicalizer_test" target="1.8" />
<module name="client_main" target="1.8" />
<module name="client_test" target="1.8" />
<module name="cliutils_main" target="1.8" />
<module name="cliutils_test" target="1.8" />
<module name="common_main" target="1.8" />
<module name="common_test" target="1.8" />
<module name="confidential-identities_main" target="1.8" />

View File

@ -70,7 +70,7 @@ buildscript {
ext.snappy_version = '0.4'
ext.fast_classpath_scanner_version = '2.12.3'
ext.jcabi_manifests_version = '1.1'
ext.picocli_version = '3.3.0'
ext.picocli_version = '3.5.2'
// Name of the IntelliJ SDK created for the deterministic Java rt.jar.
// ext.deterministic_idea_sdk = '1.8 (Deterministic)'

View File

@ -43,6 +43,7 @@ include 'tools:blobinspector'
include 'tools:shell'
include 'tools:shell-cli'
include 'tools:network-bootstrapper'
include 'tools:cliutils'
include 'example-code'
project(':example-code').projectDir = file("$settingsDir/docs/source/example-code")
include 'samples:attachment-demo'

View File

@ -7,6 +7,7 @@ description 'Network bootstrapper'
dependencies {
compile project(':node-api')
compile project(':tools:cliutils')
compile "info.picocli:picocli:$picocli_version"
compile "org.slf4j:jul-to-slf4j:$slf4j_version"
compile "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version"

View File

@ -1,38 +1,17 @@
package net.corda.bootstrapper
import com.jcabi.manifests.Manifests
import net.corda.core.internal.*
import net.corda.cliutils.CordaCliWrapper
import net.corda.cliutils.start
import net.corda.nodeapi.internal.network.NetworkBootstrapper
import picocli.CommandLine
import picocli.CommandLine.*
import picocli.CommandLine.Option
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
import kotlin.system.exitProcess
fun main(args: Array<String>) {
val main = Main()
try {
CommandLine.run(main, *args)
} catch (e: ExecutionException) {
val throwable = e.cause ?: e
if (main.verbose) {
throwable.printStackTrace()
} else {
System.err.println("*ERROR*: ${throwable.rootMessage ?: "Use --verbose for more details"}")
}
exitProcess(1)
}
NetworkBootstrapperRunner().start(*args)
}
@Command(
name = "Network Bootstrapper",
versionProvider = CordaVersionProvider::class,
mixinStandardHelpOptions = true,
showDefaultValues = true,
description = ["Bootstrap a local test Corda network using a set of node configuration files and CorDapp JARs"]
)
class Main : Runnable {
class NetworkBootstrapperRunner : CordaCliWrapper("bootstrapper", "Bootstrap a local test Corda network using a set of node configuration files and CorDapp JARs") {
@Option(
names = ["--dir"],
description = [
@ -45,143 +24,7 @@ class Main : Runnable {
@Option(names = ["--no-copy"], description = ["""Don't copy the CorDapp JARs into the nodes' "cordapps" directories."""])
private var noCopy: Boolean = false
@Option(names = ["--verbose"], description = ["Enable verbose output."])
var verbose: Boolean = false
@Option(names = ["--install-shell-extensions"], description = ["Install bootstrapper alias and autocompletion for bash and zsh"])
var installShellExtensions: Boolean = false
private class SettingsFile(val filePath: Path) {
private val lines: MutableList<String> by lazy { getFileLines() }
var fileModified: Boolean = false
// Return the lines in the file if it exists, else return an empty mutable list
private fun getFileLines(): MutableList<String> {
return if (filePath.exists()) {
filePath.toFile().readLines().toMutableList()
} else {
emptyList<String>().toMutableList()
}
}
fun addOrReplaceIfStartsWith(startsWith: String, replaceWith: String) {
val index = lines.indexOfFirst { it.startsWith(startsWith) }
if (index >= 0) {
if (lines[index] != replaceWith) {
lines[index] = replaceWith
fileModified = true
}
} else {
lines.add(replaceWith)
fileModified = true
}
}
fun addIfNotExists(line: String) {
if (!lines.contains(line)) {
lines.add(line)
fileModified = true
}
}
fun updateAndBackupIfNecessary() {
if (fileModified) {
val backupFilePath = filePath.parent / "${filePath.fileName}.backup"
println("Updating settings in ${filePath.fileName} - existing settings file has been backed up to $backupFilePath")
if (filePath.exists()) filePath.copyTo(backupFilePath, REPLACE_EXISTING)
filePath.writeLines(lines)
}
}
}
private val userHome: Path by lazy { Paths.get(System.getProperty("user.home")) }
private val jarLocation: Path by lazy { this.javaClass.location.toPath() }
// If on Windows, Path.toString() returns a path with \ instead of /, but for bash Windows users we want to convert those back to /'s
private fun Path.toStringWithDeWindowsfication(): String = this.toAbsolutePath().toString().replace("\\", "/")
private fun jarVersion(alias: String) = "# $alias - Version: ${CordaVersionProvider.releaseVersion}, Revision: ${CordaVersionProvider.revision}"
private fun getAutoCompleteFileLocation(alias: String) = userHome / ".completion" / alias
private fun generateAutoCompleteFile(alias: String) {
println("Generating $alias auto completion file")
val autoCompleteFile = getAutoCompleteFileLocation(alias)
autoCompleteFile.parent.createDirectories()
picocli.AutoComplete.main("-f", "-n", alias, this.javaClass.name, "-o", autoCompleteFile.toStringWithDeWindowsfication())
// Append hash of file to autocomplete file
autoCompleteFile.toFile().appendText(jarVersion(alias))
}
private fun installShellExtensions(alias: String) {
// Get jar location and generate alias command
val command = "alias $alias='java -jar \"${jarLocation.toStringWithDeWindowsfication()}\"'"
generateAutoCompleteFile(alias)
// Get bash settings file
val bashSettingsFile = SettingsFile(userHome / ".bashrc")
// Replace any existing bootstrapper alias. There can be only one.
bashSettingsFile.addOrReplaceIfStartsWith("alias $alias", command)
val completionFileCommand = "for bcfile in ~/.completion/* ; do . \$bcfile; done"
bashSettingsFile.addIfNotExists(completionFileCommand)
bashSettingsFile.updateAndBackupIfNecessary()
// Get zsh settings file
val zshSettingsFile = SettingsFile(userHome / ".zshrc")
zshSettingsFile.addIfNotExists("autoload -U +X compinit && compinit")
zshSettingsFile.addIfNotExists("autoload -U +X bashcompinit && bashcompinit")
zshSettingsFile.addOrReplaceIfStartsWith("alias $alias", command)
zshSettingsFile.addIfNotExists(completionFileCommand)
zshSettingsFile.updateAndBackupIfNecessary()
println("Installation complete, $alias is available in bash with autocompletion. ")
println("Type `$alias <options>` from the commandline.")
println("Restart bash for this to take effect, or run `. ~/.bashrc` in bash or `. ~/.zshrc` in zsh to re-initialise your shell now")
}
private fun checkForAutoCompleteUpdate(alias: String) {
val autoCompleteFile = getAutoCompleteFileLocation(alias)
// If no autocomplete file, it hasn't been installed, so don't do anything
if (!autoCompleteFile.exists()) return
var lastLine = ""
autoCompleteFile.toFile().forEachLine { lastLine = it }
if (lastLine != jarVersion(alias)) {
println("Old auto completion file detected... regenerating")
generateAutoCompleteFile(alias)
println("Restart bash for this to take effect, or run `. ~/.bashrc` to re-initialise bash now")
}
}
private fun installOrUpdateShellExtensions(alias: String) {
if (installShellExtensions) {
installShellExtensions(alias)
exitProcess(0)
} else {
checkForAutoCompleteUpdate(alias)
}
}
override fun run() {
installOrUpdateShellExtensions("bootstrapper")
if (verbose) {
System.setProperty("logLevel", "trace")
}
override fun runProgram() {
NetworkBootstrapper().bootstrap(dir.toAbsolutePath().normalize(), copyCordapps = !noCopy)
}
}
private class CordaVersionProvider : IVersionProvider {
companion object {
val releaseVersion: String by lazy { Manifests.read("Corda-Release-Version") }
val revision: String by lazy { Manifests.read("Corda-Revision") }
}
override fun getVersion(): Array<String> {
return arrayOf(
"Version: $releaseVersion",
"Revision: $revision"
)
}
}
}

View File

@ -0,0 +1,14 @@
apply plugin: 'java'
apply plugin: 'kotlin'
description 'CLI Utilities'
dependencies {
compile project(":core")
compile "info.picocli:picocli:$picocli_version"
compile "com.jcabi:jcabi-manifests:$jcabi_manifests_version"
compile "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version"
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}

View File

@ -0,0 +1,25 @@
package net.corda.cliutils
import net.corda.core.internal.exists
import net.corda.core.internal.isReadable
import picocli.CommandLine
import java.nio.file.Path
/**
* When a config file is required as part of setup, use this class to check that it exists and is formatted correctly. Add it as
* `@CommandLine.Mixin
* lateinit var configParser: ConfigFilePathArgsParser`
* in your command class and then call `validate()`
*/
@CommandLine.Command(description = ["Parse configuration file. Checks if given configuration file exists"])
class ConfigFilePathArgsParser : Validated {
@CommandLine.Option(names = ["--config-file", "-f"], required = true, paramLabel = "FILE", description = ["The path to the config file"])
lateinit var configFile: Path
override fun validator(): List<String> {
val res = mutableListOf<String>()
if (!configFile.exists()) res += "Config file ${configFile.toAbsolutePath().normalize()} does not exist!"
if (!configFile.isReadable) res += "Config file ${configFile.toAbsolutePath().normalize()} is not readable"
return res
}
}

View File

@ -1,54 +1,17 @@
package net.corda.node.utilities
package net.corda.cliutils
import com.jcabi.manifests.Manifests
import net.corda.core.internal.exists
import net.corda.core.internal.isReadable
import net.corda.core.internal.rootMessage
import net.corda.core.utilities.contextLogger
import org.apache.logging.log4j.Level
import picocli.CommandLine
import picocli.CommandLine.*
import java.nio.file.Path
import kotlin.system.exitProcess
import java.util.*
/**
* Something heavily used in network services, I am not sure it's of much use in corda, but who knows. Definitely it was the key to making DevOps happy.
* Add it as
* `@CommandLine.Mixin
* lateinit var configParser: ConfigFilePathArgsParser`
*
* in your command class and then validate()
*/
@Command(description = ["Parse configuration file. Checks if given configuration file exists"])
class ConfigFilePathArgsParser : Validated {
@Option(names = ["--config-file", "-f"], required = true, paramLabel = "FILE", description = ["The path to the config file"])
lateinit var configFile: Path
override fun validator(): List<String> {
val res = mutableListOf<String>()
if(!configFile.exists()) res += "Config file ${configFile.toAbsolutePath().normalize()} does not exist!"
if(!configFile.isReadable) res += "Config file ${configFile.toAbsolutePath().normalize()} is not readable"
return res
}
}
/**
* Simple version printing when command is called with --version or -V flag. Assuming that we reuse Corda-Release-Version and Corda-Revision
* in the manifest file.
*/
class CordaVersionProvider : IVersionProvider {
override fun getVersion(): Array<String> {
return if (Manifests.exists("Corda-Release-Version") && Manifests.exists("Corda-Revision")) {
arrayOf("Version: ${Manifests.read("Corda-Release-Version")}", "Revision: ${Manifests.read("Corda-Revision")}")
} else {
arrayOf("No version data is available in the MANIFEST file.")
}
}
}
/**
* Usually when we have errors in some command line flags that are not handled by picocli (e.g. non existing file). Error is thrown
* and no CommandLine help afterwards. This can be called from run() method.
* When we have errors in command line flags that are not handled by picocli (e.g. non existing files), an error is thrown
* without any command line help afterwards. This can be called from run() method.
*/
interface Validated {
companion object {
@ -56,6 +19,7 @@ interface Validated {
const val RED = "\u001B[31m"
const val RESET = "\u001B[0m"
}
/**
* Check that provided command line parameters are valid, e.g. check file existence. Return list of error strings.
*/
@ -75,6 +39,25 @@ interface Validated {
}
}
fun CordaCliWrapper.start(vararg args: String) {
val cmd = CommandLine(this)
cmd.commandSpec.name(alias)
cmd.commandSpec.usageMessage().description(description)
try {
cmd.parseWithHandlers(RunLast().useOut(System.out).useAnsi(Help.Ansi.AUTO),
DefaultExceptionHandler<List<Any>>().useErr(System.err).useAnsi(Help.Ansi.AUTO),
*args)
} catch (e: ExecutionException) {
val throwable = e.cause ?: e
if (this.verbose) {
throwable.printStackTrace()
} else {
System.err.println("*ERROR*: ${throwable.rootMessage ?: "Use --verbose for more details"}")
}
exitProcess(1)
}
}
/**
* Simple base class for handling help, version, verbose and logging-level commands.
* As versionProvider information from the MANIFEST file is used. It can be overwritten by custom version providers (see: Node)
@ -83,30 +66,44 @@ interface Validated {
@Command(mixinStandardHelpOptions = true,
versionProvider = CordaVersionProvider::class,
sortOptions = false,
showDefaultValues = true,
synopsisHeading = "%n@|bold,underline Usage|@:%n%n",
descriptionHeading = "%n@|bold,underline Description|@:%n%n",
parameterListHeading = "%n@|bold,underline Parameters|@:%n%n",
optionListHeading = "%n@|bold,underline Options|@:%n%n",
commandListHeading = "%n@|bold,underline Commands|@:%n%n")
abstract class ArgsParser {
@Option(names = [ "-v", "--verbose" ], description = ["If set, prints logging to the console as well as to a file."])
abstract class CordaCliWrapper(val alias: String, val description: String) : Runnable {
@Option(names = ["-v", "--verbose"], description = ["If set, prints logging to the console as well as to a file."])
var verbose: Boolean = false
@Option(names = ["--logging-level"],
// TODO For some reason I couldn't make picocli COMPLETION-CANDIDATES work
description = ["Enable logging at this level and higher. Defaults to INFO. Possible values: OFF, INFO, WARN, TRACE, DEBUG, ERROR, FATAL, ALL"],
converter = [LoggingLevelConverter::class])
completionCandidates = LoggingLevelConverter.LoggingLevels::class,
description = ["Enable logging at this level and higher. Possible values: \${COMPLETION-CANDIDATES}"],
converter = [LoggingLevelConverter::class]
)
var loggingLevel: Level = Level.INFO
@Mixin
private lateinit var installShellExtensionsParser: InstallShellExtensionsParser
// This needs to be called before loggers (See: NodeStartup.kt:51 logger called by lazy, initLogging happens before).
// Node's logging is more rich. In corda configurations two properties, defaultLoggingLevel and consoleLogLevel, are usually used.
protected open fun initLogging() {
private fun initLogging() {
val loggingLevel = loggingLevel.name().toLowerCase(Locale.ENGLISH)
System.setProperty("defaultLogLevel", loggingLevel) // These properties are referenced from the XML config file.
if (verbose) {
System.setProperty("consoleLogLevel", loggingLevel)
}
}
// Override this function with the actual method to be run once all the arguments have been parsed
abstract fun runProgram()
final override fun run() {
installShellExtensionsParser.installOrUpdateShellExtensions(alias, this.javaClass.name)
initLogging()
runProgram()
}
}
/**
@ -114,6 +111,9 @@ abstract class ArgsParser {
*/
class LoggingLevelConverter : ITypeConverter<Level> {
override fun convert(value: String?): Level {
return value?.let { Level.getLevel(it) } ?: throw TypeConversionException("Unknown option for --logging-level: $value")
return value?.let { Level.getLevel(it) }
?: throw TypeConversionException("Unknown option for --logging-level: $value")
}
class LoggingLevels : ArrayList<String>(Level.values().map { it.toString() })
}

View File

@ -0,0 +1,23 @@
package net.corda.cliutils
import com.jcabi.manifests.Manifests
import picocli.CommandLine
/**
* Simple version printing when command is called with --version or -V flag. Assuming that we reuse Corda-Release-Version and Corda-Revision
* in the manifest file.
*/
class CordaVersionProvider : CommandLine.IVersionProvider {
companion object {
val releaseVersion: String by lazy { Manifests.read("Corda-Release-Version") }
val revision: String by lazy { Manifests.read("Corda-Revision") }
}
override fun getVersion(): Array<String> {
return if (Manifests.exists("Corda-Release-Version") && Manifests.exists("Corda-Revision")) {
arrayOf("Version: $releaseVersion", "Revision: $revision")
} else {
arrayOf("No version data is available in the MANIFEST file.")
}
}
}

View File

@ -0,0 +1,131 @@
package net.corda.cliutils
import net.corda.core.internal.*
import picocli.CommandLine
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.system.exitProcess
private class ShellExtensionsGenerator(val alias: String, val className: String) {
private class SettingsFile(val filePath: Path) {
private val lines: MutableList<String> by lazy { getFileLines() }
var fileModified: Boolean = false
// Return the lines in the file if it exists, else return an empty mutable list
private fun getFileLines(): MutableList<String> {
return if (filePath.exists()) {
filePath.toFile().readLines().toMutableList()
} else {
Collections.emptyList<String>().toMutableList()
}
}
fun addOrReplaceIfStartsWith(startsWith: String, replaceWith: String) {
val index = lines.indexOfFirst { it.startsWith(startsWith) }
if (index >= 0) {
if (lines[index] != replaceWith) {
lines[index] = replaceWith
fileModified = true
}
} else {
lines.add(replaceWith)
fileModified = true
}
}
fun addIfNotExists(line: String) {
if (!lines.contains(line)) {
lines.add(line)
fileModified = true
}
}
fun updateAndBackupIfNecessary() {
if (fileModified) {
val backupFilePath = filePath.parent / "${filePath.fileName}.backup"
println("Updating settings in ${filePath.fileName} - existing settings file has been backed up to $backupFilePath")
if (filePath.exists()) filePath.copyTo(backupFilePath, StandardCopyOption.REPLACE_EXISTING)
filePath.writeLines(lines)
}
}
}
private val userHome: Path by lazy { Paths.get(System.getProperty("user.home")) }
private val jarLocation: Path by lazy { this.javaClass.location.toPath() }
// If on Windows, Path.toString() returns a path with \ instead of /, but for bash Windows users we want to convert those back to /'s
private fun Path.toStringWithDeWindowsfication(): String = this.toAbsolutePath().toString().replace("\\", "/")
private fun jarVersion(alias: String) = "# $alias - Version: ${CordaVersionProvider.releaseVersion}, Revision: ${CordaVersionProvider.revision}"
private fun getAutoCompleteFileLocation(alias: String) = userHome / ".completion" / alias
private fun generateAutoCompleteFile(alias: String, className: String) {
println("Generating $alias auto completion file")
val autoCompleteFile = getAutoCompleteFileLocation(alias)
autoCompleteFile.parent.createDirectories()
picocli.AutoComplete.main("-f", "-n", alias, className, "-o", autoCompleteFile.toStringWithDeWindowsfication())
// Append hash of file to autocomplete file
autoCompleteFile.toFile().appendText(jarVersion(alias))
}
fun installShellExtensions() {
// Get jar location and generate alias command
val command = "alias $alias='java -jar \"${jarLocation.toStringWithDeWindowsfication()}\"'"
generateAutoCompleteFile(alias, className)
// Get bash settings file
val bashSettingsFile = SettingsFile(userHome / ".bashrc")
// Replace any existing alias. There can be only one.
bashSettingsFile.addOrReplaceIfStartsWith("alias $alias", command)
val completionFileCommand = "for bcfile in ~/.completion/* ; do . \$bcfile; done"
bashSettingsFile.addIfNotExists(completionFileCommand)
bashSettingsFile.updateAndBackupIfNecessary()
// Get zsh settings file
val zshSettingsFile = SettingsFile(userHome / ".zshrc")
zshSettingsFile.addIfNotExists("autoload -U +X compinit && compinit")
zshSettingsFile.addIfNotExists("autoload -U +X bashcompinit && bashcompinit")
zshSettingsFile.addOrReplaceIfStartsWith("alias $alias", command)
zshSettingsFile.addIfNotExists(completionFileCommand)
zshSettingsFile.updateAndBackupIfNecessary()
println("Installation complete, $alias is available in bash with autocompletion. ")
println("Type `$alias <options>` from the commandline.")
println("Restart bash for this to take effect, or run `. ~/.bashrc` in bash or `. ~/.zshrc` in zsh to re-initialise your shell now")
}
fun checkForAutoCompleteUpdate() {
val autoCompleteFile = getAutoCompleteFileLocation(alias)
// If no autocomplete file, it hasn't been installed, so don't do anything
if (!autoCompleteFile.exists()) return
var lastLine = ""
autoCompleteFile.toFile().forEachLine { lastLine = it }
if (lastLine != jarVersion(alias)) {
println("Old auto completion file detected... regenerating")
generateAutoCompleteFile(alias, className)
println("Restart bash for this to take effect, or run `. ~/.bashrc` to re-initialise bash now")
}
}
}
@CommandLine.Command(description = [""])
class InstallShellExtensionsParser {
@CommandLine.Option(names = ["--install-shell-extensions"], description = ["Install alias and autocompletion for bash and zsh"])
var installShellExtensions: Boolean = false
fun installOrUpdateShellExtensions(alias: String, className: String) {
val generator = ShellExtensionsGenerator(alias, className)
if (installShellExtensions) {
generator.installShellExtensions()
exitProcess(0)
} else {
generator.checkForAutoCompleteUpdate()
}
}
}