From 596ccb1d9d4be7180083fa0bf425947dbc498d3c Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 9 Mar 2024 12:49:23 -0800 Subject: [PATCH] Add Context.registerClosable --- CHANGELOG.md | 1 + clikt/api/clikt.api | 7 + .../com/github/ajalt/clikt/core/Context.kt | 63 ++++++ .../com/github/ajalt/clikt/parsers/Parser.kt | 202 +++++++++++------- .../github/ajalt/clikt/core/ContextTest.kt | 76 +++++++ .../com/github/ajalt/clikt/core/ContextJvm.kt | 25 +++ .../clikt/parameters/types/inputStream.kt | 10 +- .../clikt/parameters/types/outputStream.kt | 10 +- .../github/ajalt/clikt/core/ContextJvmTest.kt | 22 ++ docs/advanced.md | 44 +++- docs/commands.md | 5 + 11 files changed, 377 insertions(+), 88 deletions(-) create mode 100644 clikt/src/jvmMain/kotlin/com/github/ajalt/clikt/core/ContextJvm.kt create mode 100644 clikt/src/jvmTest/kotlin/com/github/ajalt/clikt/core/ContextJvmTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 247097087..6914d43f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added - Added `limit` parameter to `option().counted()` to limit the number of times the option can be used. You can either clamp the value to the limit, or throw an error if the limit is exceeded. ([#483](https://github.com/ajalt/clikt/issues/483)) +- Added `Context.registerClosable` and `Context.callOnClose` to allow you to register cleanup actions that will be called when the command exits. ([#395](https://github.com/ajalt/clikt/issues/395)) ## 4.2.2 ### Changed diff --git a/clikt/api/clikt.api b/clikt/api/clikt.api index bd2a7cece..1337c9728 100644 --- a/clikt/api/clikt.api +++ b/clikt/api/clikt.api @@ -161,6 +161,8 @@ public final class com/github/ajalt/clikt/core/Context { public static final field Companion Lcom/github/ajalt/clikt/core/Context$Companion; public synthetic fun (Lcom/github/ajalt/clikt/core/Context;Lcom/github/ajalt/clikt/core/CliktCommand;ZZLjava/lang/String;ZLjava/util/Set;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lcom/github/ajalt/mordant/terminal/Terminal;Lkotlin/jvm/functions/Function1;ZLcom/github/ajalt/clikt/sources/ValueSource;Lkotlin/jvm/functions/Function2;Lcom/github/ajalt/clikt/output/Localization;Lkotlin/jvm/functions/Function1;Ljava/lang/Object;Ljava/util/List;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun ancestors ()Lkotlin/sequences/Sequence; + public final fun callOnClose (Lkotlin/jvm/functions/Function0;)V + public final fun close ()V public final fun commandNameWithParents ()Ljava/util/List; public final fun fail (Ljava/lang/String;)Ljava/lang/Void; public static synthetic fun fail$default (Lcom/github/ajalt/clikt/core/Context;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/Void; @@ -239,8 +241,13 @@ public abstract interface class com/github/ajalt/clikt/core/ContextCliktError { public abstract fun setContext (Lcom/github/ajalt/clikt/core/Context;)V } +public final class com/github/ajalt/clikt/core/ContextJvmKt { + public static final fun registerJvmCloseable (Lcom/github/ajalt/clikt/core/Context;Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable; +} + public final class com/github/ajalt/clikt/core/ContextKt { public static final fun getTheme (Lcom/github/ajalt/clikt/core/Context;)Lcom/github/ajalt/mordant/rendering/Theme; + public static final fun registerCloseable (Lcom/github/ajalt/clikt/core/Context;Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable; } public final class com/github/ajalt/clikt/core/FileNotFound : com/github/ajalt/clikt/core/UsageError { diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/Context.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/Context.kt index d6d4c0af2..f3f2d2971 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/Context.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/Context.kt @@ -129,6 +129,8 @@ class Context private constructor( var errorEncountered: Boolean = false internal set + private val closeables = mutableListOf<() -> Unit>() + /** Find the closest object of type [T] */ inline fun findObject(): T? { return selfAndAncestors().mapNotNull { it.obj as? T }.firstOrNull() @@ -163,6 +165,43 @@ class Context private constructor( /** Throw a [UsageError] with the given message */ fun fail(message: String = ""): Nothing = throw UsageError(message) + /** + * Register a callback to be called when this command and all its subcommands have finished. + * + * This is useful for resources that need to be shared across multiple commands. + * + * If your resource implements [AutoCloseable], you should use [registerCloseable] instead. + * + * ### Example + * + * ``` + * currentContext.callOnClose { myResource.close() } + * ``` + */ + fun callOnClose(closeable: () -> Unit) { + closeables.add(closeable) + } + + /** + * Close all registered closeables in the reverse order they were registered. + * + * This is called automatically after a command and its subcommands have finished running. + */ + fun close() { + var err: Throwable? = null + for (c in closeables.asReversed()) { + try { + c() + } catch (e: Throwable) { + if (err == null) err = e + else err.addSuppressed(e) + } + } + closeables.clear() + if (err != null) throw err + } + + // TODO(5.0): these don't need to be member functions @PublishedApi internal fun ancestors() = generateSequence(parent) { it.parent } @@ -342,6 +381,30 @@ class Context private constructor( } } +/** + * Register an [AutoCloseable] to be closed when this command and all its subcommands have + * finished running. + * + * This is useful for resources that need to be shared across multiple commands. For resources + * that aren't shared, it's often simpler to use [use] directly. + * + * Registered closeables will be closed in the reverse order that they were registered. + * + * ### Example + * + * ``` + * currentContext.obj = currentContext.registerCloseable(MyResource()) + * ``` + * + * @return the closeable that was registered + * @see Context.callOnClose + */ +@ExperimentalStdlibApi +fun Context.registerCloseable(closeable: T): T { + callOnClose { closeable.close() } + return closeable +} + /** Find the closest object of type [T], or throw a [NullPointerException] */ @Suppress("UnusedReceiverParameter") // these extensions don't use their receiver, but we want to limit where they can be called inline fun CliktCommand.requireObject(): ReadOnlyProperty { diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt index a73aa8891..8bd60c214 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt @@ -3,6 +3,7 @@ package com.github.ajalt.clikt.parsers import com.github.ajalt.clikt.core.* import com.github.ajalt.clikt.internal.finalizeParameters import com.github.ajalt.clikt.parameters.arguments.Argument +import com.github.ajalt.clikt.parameters.groups.ParameterGroup import com.github.ajalt.clikt.parameters.options.Option import com.github.ajalt.clikt.parameters.options.splitOptionPrefix @@ -99,15 +100,13 @@ internal object Parser { return context.tokenTransformer(context, token.take(2)) !in optionsByName } - fun addError(e: Err) { - errors += e - context.errorEncountered = true - } - fun consumeParse(tokenIndex: Int, result: OptParseResult) { positionalArgs += result.unknown.map { tokenIndex to it } invocations += result.known - result.err?.let(::addError) + result.err?.let { + errors += it + context.errorEncountered = true + } i += result.consumed } @@ -211,92 +210,143 @@ internal object Parser { } - // Finalize and validate everything as long as we aren't resuming a parse for multiple subcommands + // Finalize and validate everything as long as we aren't resuming a parse for multiple + // subcommands try { - if (canRun) { - // Finalize and validate eager options - invocationsByOption.forEach { (o, inv) -> - if (o.eager) { - o.finalize(context, inv) - o.postValidate(context) - } + try { + if (canRun) { + i = finalizeAndRun( + context, + i, + command, + subcommand, + invocationsByOption, + positionalArgs, + arguments, + hasMultipleSubAncestor, + tokens, + subcommands, + errors, + ungroupedOptions, + invocationsByOptionByGroup + ) + } else if (subcommand == null && positionalArgs.isNotEmpty()) { + // If we're resuming a parse with multiple subcommands, there can't be any args + // after the last subcommand is parsed + throw excessArgsError(positionalArgs, positionalArgs.size, context) } + } catch (e: UsageError) { + // Augment usage errors with the current context if they don't have one + e.context = context + throw e + } - // Parse arguments - val argsParseResult = parseArguments(i, positionalArgs, arguments) - argsParseResult.err?.let(::addError) + if (subcommand != null) { + val nextTokens = parse(tokens.drop(i), subcommand.currentContext, true) + if (command.allowMultipleSubcommands && nextTokens.isNotEmpty()) { + parse(nextTokens, context, false) + } + return nextTokens + } + } finally { + context.close() + } - val excessResult = handleExcessArguments( - argsParseResult.excessCount, - hasMultipleSubAncestor, - i, - tokens, - subcommands, - positionalArgs, - context - ) - excessResult.second?.let(::addError) + return tokens.drop(i) + } - val usageErrors = errors - .filter { it.includeInMulti }.ifEmpty { errors } - .sortedBy { it.i }.mapTo(mutableListOf()) { it.e } + private fun finalizeAndRun( + context: Context, + i: Int, + command: CliktCommand, + subcommand: CliktCommand?, + invocationsByOption: Map>, + positionalArgs: MutableList>, + arguments: MutableList, + hasMultipleSubAncestor: Boolean, + tokens: List, + subcommands: Map, + errors: MutableList, + ungroupedOptions: List