Skip to content

Commit ebd80fb

Browse files
authored
Add Context.registerClosable (#497)
1 parent 76dfa93 commit ebd80fb

File tree

11 files changed

+377
-88
lines changed

11 files changed

+377
-88
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44
### Added
55
- 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))
6+
- 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))
67

78
## 4.2.2
89
### Changed

clikt/api/clikt.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ public final class com/github/ajalt/clikt/core/Context {
161161
public static final field Companion Lcom/github/ajalt/clikt/core/Context$Companion;
162162
public synthetic fun <init> (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
163163
public final fun ancestors ()Lkotlin/sequences/Sequence;
164+
public final fun callOnClose (Lkotlin/jvm/functions/Function0;)V
165+
public final fun close ()V
164166
public final fun commandNameWithParents ()Ljava/util/List;
165167
public final fun fail (Ljava/lang/String;)Ljava/lang/Void;
166168
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 {
239241
public abstract fun setContext (Lcom/github/ajalt/clikt/core/Context;)V
240242
}
241243

244+
public final class com/github/ajalt/clikt/core/ContextJvmKt {
245+
public static final fun registerJvmCloseable (Lcom/github/ajalt/clikt/core/Context;Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable;
246+
}
247+
242248
public final class com/github/ajalt/clikt/core/ContextKt {
243249
public static final fun getTheme (Lcom/github/ajalt/clikt/core/Context;)Lcom/github/ajalt/mordant/rendering/Theme;
250+
public static final fun registerCloseable (Lcom/github/ajalt/clikt/core/Context;Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable;
244251
}
245252

246253
public final class com/github/ajalt/clikt/core/FileNotFound : com/github/ajalt/clikt/core/UsageError {

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/Context.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ class Context private constructor(
129129
var errorEncountered: Boolean = false
130130
internal set
131131

132+
private val closeables = mutableListOf<() -> Unit>()
133+
132134
/** Find the closest object of type [T] */
133135
inline fun <reified T : Any> findObject(): T? {
134136
return selfAndAncestors().mapNotNull { it.obj as? T }.firstOrNull()
@@ -163,6 +165,43 @@ class Context private constructor(
163165
/** Throw a [UsageError] with the given message */
164166
fun fail(message: String = ""): Nothing = throw UsageError(message)
165167

168+
/**
169+
* Register a callback to be called when this command and all its subcommands have finished.
170+
*
171+
* This is useful for resources that need to be shared across multiple commands.
172+
*
173+
* If your resource implements [AutoCloseable], you should use [registerCloseable] instead.
174+
*
175+
* ### Example
176+
*
177+
* ```
178+
* currentContext.callOnClose { myResource.close() }
179+
* ```
180+
*/
181+
fun callOnClose(closeable: () -> Unit) {
182+
closeables.add(closeable)
183+
}
184+
185+
/**
186+
* Close all registered closeables in the reverse order they were registered.
187+
*
188+
* This is called automatically after a command and its subcommands have finished running.
189+
*/
190+
fun close() {
191+
var err: Throwable? = null
192+
for (c in closeables.asReversed()) {
193+
try {
194+
c()
195+
} catch (e: Throwable) {
196+
if (err == null) err = e
197+
else err.addSuppressed(e)
198+
}
199+
}
200+
closeables.clear()
201+
if (err != null) throw err
202+
}
203+
204+
// TODO(5.0): these don't need to be member functions
166205
@PublishedApi
167206
internal fun ancestors() = generateSequence(parent) { it.parent }
168207

@@ -342,6 +381,30 @@ class Context private constructor(
342381
}
343382
}
344383

384+
/**
385+
* Register an [AutoCloseable] to be closed when this command and all its subcommands have
386+
* finished running.
387+
*
388+
* This is useful for resources that need to be shared across multiple commands. For resources
389+
* that aren't shared, it's often simpler to use [use] directly.
390+
*
391+
* Registered closeables will be closed in the reverse order that they were registered.
392+
*
393+
* ### Example
394+
*
395+
* ```
396+
* currentContext.obj = currentContext.registerCloseable(MyResource())
397+
* ```
398+
*
399+
* @return the closeable that was registered
400+
* @see Context.callOnClose
401+
*/
402+
@ExperimentalStdlibApi
403+
fun <T: AutoCloseable> Context.registerCloseable(closeable: T): T {
404+
callOnClose { closeable.close() }
405+
return closeable
406+
}
407+
345408
/** Find the closest object of type [T], or throw a [NullPointerException] */
346409
@Suppress("UnusedReceiverParameter") // these extensions don't use their receiver, but we want to limit where they can be called
347410
inline fun <reified T : Any> CliktCommand.requireObject(): ReadOnlyProperty<CliktCommand, T> {

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt

Lines changed: 126 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.github.ajalt.clikt.parsers
33
import com.github.ajalt.clikt.core.*
44
import com.github.ajalt.clikt.internal.finalizeParameters
55
import com.github.ajalt.clikt.parameters.arguments.Argument
6+
import com.github.ajalt.clikt.parameters.groups.ParameterGroup
67
import com.github.ajalt.clikt.parameters.options.Option
78
import com.github.ajalt.clikt.parameters.options.splitOptionPrefix
89

@@ -99,15 +100,13 @@ internal object Parser {
99100
return context.tokenTransformer(context, token.take(2)) !in optionsByName
100101
}
101102

102-
fun addError(e: Err) {
103-
errors += e
104-
context.errorEncountered = true
105-
}
106-
107103
fun consumeParse(tokenIndex: Int, result: OptParseResult) {
108104
positionalArgs += result.unknown.map { tokenIndex to it }
109105
invocations += result.known
110-
result.err?.let(::addError)
106+
result.err?.let {
107+
errors += it
108+
context.errorEncountered = true
109+
}
111110
i += result.consumed
112111
}
113112

@@ -211,92 +210,143 @@ internal object Parser {
211210
}
212211

213212

214-
// Finalize and validate everything as long as we aren't resuming a parse for multiple subcommands
213+
// Finalize and validate everything as long as we aren't resuming a parse for multiple
214+
// subcommands
215215
try {
216-
if (canRun) {
217-
// Finalize and validate eager options
218-
invocationsByOption.forEach { (o, inv) ->
219-
if (o.eager) {
220-
o.finalize(context, inv)
221-
o.postValidate(context)
222-
}
216+
try {
217+
if (canRun) {
218+
i = finalizeAndRun(
219+
context,
220+
i,
221+
command,
222+
subcommand,
223+
invocationsByOption,
224+
positionalArgs,
225+
arguments,
226+
hasMultipleSubAncestor,
227+
tokens,
228+
subcommands,
229+
errors,
230+
ungroupedOptions,
231+
invocationsByOptionByGroup
232+
)
233+
} else if (subcommand == null && positionalArgs.isNotEmpty()) {
234+
// If we're resuming a parse with multiple subcommands, there can't be any args
235+
// after the last subcommand is parsed
236+
throw excessArgsError(positionalArgs, positionalArgs.size, context)
223237
}
238+
} catch (e: UsageError) {
239+
// Augment usage errors with the current context if they don't have one
240+
e.context = context
241+
throw e
242+
}
224243

225-
// Parse arguments
226-
val argsParseResult = parseArguments(i, positionalArgs, arguments)
227-
argsParseResult.err?.let(::addError)
244+
if (subcommand != null) {
245+
val nextTokens = parse(tokens.drop(i), subcommand.currentContext, true)
246+
if (command.allowMultipleSubcommands && nextTokens.isNotEmpty()) {
247+
parse(nextTokens, context, false)
248+
}
249+
return nextTokens
250+
}
251+
} finally {
252+
context.close()
253+
}
228254

229-
val excessResult = handleExcessArguments(
230-
argsParseResult.excessCount,
231-
hasMultipleSubAncestor,
232-
i,
233-
tokens,
234-
subcommands,
235-
positionalArgs,
236-
context
237-
)
238-
excessResult.second?.let(::addError)
255+
return tokens.drop(i)
256+
}
239257

240-
val usageErrors = errors
241-
.filter { it.includeInMulti }.ifEmpty { errors }
242-
.sortedBy { it.i }.mapTo(mutableListOf()) { it.e }
258+
private fun finalizeAndRun(
259+
context: Context,
260+
i: Int,
261+
command: CliktCommand,
262+
subcommand: CliktCommand?,
263+
invocationsByOption: Map<Option, List<Invocation>>,
264+
positionalArgs: MutableList<Pair<Int, String>>,
265+
arguments: MutableList<Argument>,
266+
hasMultipleSubAncestor: Boolean,
267+
tokens: List<String>,
268+
subcommands: Map<String, CliktCommand>,
269+
errors: MutableList<Err>,
270+
ungroupedOptions: List<Option>,
271+
invocationsByOptionByGroup: Map<ParameterGroup?, Map<Option, List<Invocation>>>,
272+
): Int {
273+
// Finalize and validate eager options
274+
var nextArgvI = i
275+
276+
invocationsByOption.forEach { (o, inv) ->
277+
if (o.eager) {
278+
o.finalize(context, inv)
279+
o.postValidate(context)
280+
}
281+
}
243282

244-
i = excessResult.first
283+
// Parse arguments
284+
val argsParseResult = parseArguments(nextArgvI, positionalArgs, arguments)
285+
argsParseResult.err?.let {
286+
errors += it
287+
context.errorEncountered = true
288+
}
245289

246-
// Finalize arguments, groups, and options
247-
gatherErrors(usageErrors, context) {
248-
finalizeParameters(
249-
context,
250-
ungroupedOptions,
251-
command._groups,
252-
invocationsByOptionByGroup,
253-
argsParseResult.args,
254-
)
255-
}
290+
val excessResult = handleExcessArguments(
291+
argsParseResult.excessCount,
292+
hasMultipleSubAncestor,
293+
nextArgvI,
294+
tokens,
295+
subcommands,
296+
positionalArgs,
297+
context
298+
)
299+
excessResult.second?.let {
300+
errors += it
301+
context.errorEncountered = true
302+
}
256303

257-
// We can't validate a param that didn't finalize successfully, and we don't keep
258-
// track of which ones are finalized, so throw any errors now
259-
MultiUsageError.buildOrNull(usageErrors)?.let { throw it }
304+
val usageErrors = errors
305+
.filter { it.includeInMulti }.ifEmpty { errors }
306+
.sortedBy { it.i }.mapTo(mutableListOf()) { it.e }
260307

261-
// Now that all parameters have been finalized, we can validate everything
262-
ungroupedOptions.forEach { gatherErrors(usageErrors, context) { it.postValidate(context) } }
263-
command._groups.forEach { gatherErrors(usageErrors, context) { it.postValidate(context) } }
264-
command._arguments.forEach { gatherErrors(usageErrors, context) { it.postValidate(context) } }
308+
nextArgvI = excessResult.first
265309

266-
MultiUsageError.buildOrNull(usageErrors)?.let { throw it }
310+
// Finalize arguments, groups, and options
311+
gatherErrors(usageErrors, context) {
312+
finalizeParameters(
313+
context,
314+
ungroupedOptions,
315+
command._groups,
316+
invocationsByOptionByGroup,
317+
argsParseResult.args,
318+
)
319+
}
267320

268-
if (subcommand == null && subcommands.isNotEmpty() && !command.invokeWithoutSubcommand) {
269-
throw PrintHelpMessage(context, error = true)
270-
}
321+
// We can't validate a param that didn't finalize successfully, and we don't keep
322+
// track of which ones are finalized, so throw any errors now
323+
MultiUsageError.buildOrNull(usageErrors)?.let { throw it }
324+
325+
// Now that all parameters have been finalized, we can validate everything
326+
ungroupedOptions.forEach { gatherErrors(usageErrors, context) { it.postValidate(context) } }
327+
command._groups.forEach { gatherErrors(usageErrors, context) { it.postValidate(context) } }
328+
command._arguments.forEach {
329+
gatherErrors(
330+
usageErrors,
331+
context
332+
) { it.postValidate(context) }
333+
}
271334

272-
command.currentContext.invokedSubcommand = subcommand
273-
if (command.currentContext.printExtraMessages) {
274-
for (warning in command.messages) {
275-
command.terminal.warning(warning, stderr = true)
276-
}
277-
}
335+
MultiUsageError.buildOrNull(usageErrors)?.let { throw it }
278336

279-
command.run()
280-
} else if (subcommand == null && positionalArgs.isNotEmpty()) {
281-
// If we're resuming a parse with multiple subcommands, there can't be any args after the last
282-
// subcommand is parsed
283-
throw excessArgsError(positionalArgs, positionalArgs.size, context)
284-
}
285-
} catch (e: UsageError) {
286-
// Augment usage errors with the current context if they don't have one
287-
e.context = context
288-
throw e
337+
if (subcommand == null && subcommands.isNotEmpty() && !command.invokeWithoutSubcommand) {
338+
throw PrintHelpMessage(context, error = true)
289339
}
290340

291-
if (subcommand != null) {
292-
val nextTokens = parse(tokens.drop(i), subcommand.currentContext, true)
293-
if (command.allowMultipleSubcommands && nextTokens.isNotEmpty()) {
294-
parse(nextTokens, context, false)
341+
command.currentContext.invokedSubcommand = subcommand
342+
if (command.currentContext.printExtraMessages) {
343+
for (warning in command.messages) {
344+
command.terminal.warning(warning, stderr = true)
295345
}
296-
return nextTokens
297346
}
298347

299-
return tokens.drop(i)
348+
command.run()
349+
return nextArgvI
300350
}
301351

302352
/** Returns either the new argv index, or an error */
@@ -514,14 +564,14 @@ internal object Parser {
514564
1 -> context.localization.extraArgumentOne(actual)
515565
else -> context.localization.extraArgumentMany(actual, excess)
516566
}
517-
return UsageError(message)
567+
return UsageError(message).also { it.context = context }
518568
}
519569
}
520570

521571
private inline fun gatherErrors(
522572
errors: MutableList<UsageError>,
523573
context: Context,
524-
block: () -> Unit
574+
block: () -> Unit,
525575
) {
526576
try {
527577
block()

0 commit comments

Comments
 (0)