Skip to content

Commit fd5ed39

Browse files
committed
Add choice options with groups
1 parent fd931f0 commit fd5ed39

File tree

13 files changed

+255
-19
lines changed

13 files changed

+255
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
[`OptionWithValues.valueSplit`](https://ajalt.github.io/clikt/api/clikt/com.github.ajalt.clikt.parameters.options/-option-with-values/value-split.html).
1212
- Marking options as deprecated with [`option().deprecated()`](https://ajalt.github.io/clikt/api/clikt/com.github.ajalt.clikt.parameters.options/deprecated.html)
1313
- You can manually set the pattern to split envvars on by passing a pattern to the `envvarSplit` parameter of [`option()`](https://ajalt.github.io/clikt/api/clikt/com.github.ajalt.clikt.parameters.options/option.html)
14-
- [Option groups](https://ajalt.github.io/clikt/documenting/#grouping-options-in-help), [mutually exclusive groups](https://ajalt.github.io/clikt/options/#prompting-for-input), and [co-occurring groups](https://ajalt.github.io/clikt/options/#co-occurring-option-groups)
14+
- [Option groups](https://ajalt.github.io/clikt/documenting/#grouping-options-in-help), [mutually exclusive groups](https://ajalt.github.io/clikt/options/#prompting-for-input), [co-occurring groups](https://ajalt.github.io/clikt/options/#co-occurring-option-groups), and [choice options with groups](https://ajalt.github.io/clikt/options/#choice-options-with-groups)
1515

1616
### Changed
1717
- If multiple `--` tokens are present on the command line, all subsequent occurrences after the first are now

clikt/src/main/kotlin/com/github/ajalt/clikt/core/ParameterHolder.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@ interface ParameterHolder {
2323
interface GroupableOption : Option {
2424
/** The group that this option belongs to, or null. Set by the group. */
2525
var parameterGroup: ParameterGroup?
26+
27+
/** The name of the group, or null if this option should not be grouped in the help output. */
28+
var groupName: String?
2629
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.github.ajalt.clikt.parameters.groups
2+
3+
import com.github.ajalt.clikt.completion.CompletionCandidates.Fixed
4+
import com.github.ajalt.clikt.core.BadParameterValue
5+
import com.github.ajalt.clikt.core.CliktCommand
6+
import com.github.ajalt.clikt.core.Context
7+
import com.github.ajalt.clikt.core.MissingParameter
8+
import com.github.ajalt.clikt.parameters.internal.NullableLateinit
9+
import com.github.ajalt.clikt.parameters.options.Option
10+
import com.github.ajalt.clikt.parameters.options.RawOption
11+
import com.github.ajalt.clikt.parameters.options.option
12+
import com.github.ajalt.clikt.parameters.options.required
13+
import com.github.ajalt.clikt.parsers.OptionParser
14+
import kotlin.properties.ReadOnlyProperty
15+
import kotlin.reflect.KProperty
16+
17+
class ChoiceGroup<GroupT : OptionGroup, OutT>(
18+
internal val option: RawOption,
19+
internal val groups: Map<String, GroupT>,
20+
internal val transform: (GroupT?) -> OutT
21+
) : ParameterGroupDelegate<OutT> {
22+
override val groupName: String? = null
23+
override val groupHelp: String? = null
24+
private var value: OutT by NullableLateinit("Cannot read from option delegate before parsing command line")
25+
26+
override fun provideDelegate(thisRef: CliktCommand, prop: KProperty<*>): ReadOnlyProperty<CliktCommand, OutT> {
27+
option.provideDelegate(thisRef, prop) // infer the option name and register it
28+
thisRef.registerOptionGroup(this)
29+
for ((_, group) in groups) {
30+
for (option in group.options) {
31+
option.parameterGroup = this
32+
option.groupName = group.groupName
33+
thisRef.registerOption(option)
34+
}
35+
}
36+
return this
37+
}
38+
39+
override fun getValue(thisRef: CliktCommand, property: KProperty<*>): OutT = value
40+
41+
override fun finalize(context: Context, invocationsByOption: Map<Option, List<OptionParser.Invocation>>) {
42+
val key = option.value
43+
if (key == null) {
44+
value = transform(null)
45+
return
46+
}
47+
48+
val group = groups[key]
49+
?: throw BadParameterValue(
50+
"invalid choice: $key. (choose from ${groups.keys.joinToString()})",
51+
option,
52+
context
53+
)
54+
group.finalize(context, invocationsByOption.filterKeys { it in group.options })
55+
value = transform(group)
56+
}
57+
}
58+
59+
/**
60+
* Convert the option to an option group based on a fixed set of values.
61+
*
62+
* ### Example:
63+
*
64+
* ```kotlin
65+
* option().choice(mapOf("foo" to FooOptionGroup(), "bar" to BarOptionGroup()))
66+
* ```
67+
*
68+
* @see com.github.ajalt.clikt.parameters.types.choice
69+
*/
70+
fun <T : OptionGroup> RawOption.groupChoice(choices: Map<String, T>): ChoiceGroup<T, T?> {
71+
return ChoiceGroup(
72+
copy(transformValue, transformEach, transformAll, completionCandidates = Fixed(choices.keys)),
73+
choices
74+
) { it }
75+
}
76+
77+
/**
78+
* Convert the option to an option group based on a fixed set of values.
79+
*
80+
* ### Example:
81+
*
82+
* ```kotlin
83+
* option().choice("foo" to FooOptionGroup(), "bar" to BarOptionGroup())
84+
* ```
85+
*
86+
* @see com.github.ajalt.clikt.parameters.types.choice
87+
*/
88+
fun <T : OptionGroup> RawOption.groupChoice(vararg choices: Pair<String, T>): ChoiceGroup<T, T?> {
89+
return groupChoice(choices.toMap())
90+
}
91+
92+
/**
93+
* If a [groupChoice] option is not called on the command line, throw a [MissingParameter] exception.
94+
*
95+
* ### Example:
96+
*
97+
* ```kotlin
98+
* option().choice("foo" to FooOptionGroup(), "bar" to BarOptionGroup()).required()
99+
* ```
100+
*/
101+
fun <T : OptionGroup> ChoiceGroup<T, T?>.required(): ChoiceGroup<T, T> {
102+
return ChoiceGroup(option, groups) { it ?: throw MissingParameter(option) }
103+
}
104+

clikt/src/main/kotlin/com/github/ajalt/clikt/parameters/groups/CoOccurringOptionGroup.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package com.github.ajalt.clikt.parameters.groups
22

33
import com.github.ajalt.clikt.core.CliktCommand
44
import com.github.ajalt.clikt.core.Context
5-
import com.github.ajalt.clikt.core.MissingParameter
65
import com.github.ajalt.clikt.output.HelpFormatter
76
import com.github.ajalt.clikt.parameters.internal.NullableLateinit
87
import com.github.ajalt.clikt.parameters.options.Option
@@ -20,9 +19,10 @@ class CoOccurringOptionGroup<GroupT : OptionGroup, OutT>(
2019

2120
override fun provideDelegate(thisRef: CliktCommand, prop: KProperty<*>): ReadOnlyProperty<CliktCommand, OutT> {
2221
thisRef.registerOptionGroup(this)
23-
for (o in group.options) {
24-
o.parameterGroup = this
25-
thisRef.registerOption(o)
22+
for (option in group.options) {
23+
option.parameterGroup = this
24+
option.groupName = groupName
25+
thisRef.registerOption(option)
2626
}
2727
return this
2828
}

clikt/src/main/kotlin/com/github/ajalt/clikt/parameters/groups/MutuallyExclusiveOption.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class MutuallyExclusiveOptions<OptT : Any, OutT>(
3737
for (option in options) {
3838
require(option.names.isNotEmpty()) { "must specify names for all options in a group" }
3939
option.parameterGroup = this
40+
option.groupName = groupName
4041
thisRef.registerOption(option)
4142
}
4243

clikt/src/main/kotlin/com/github/ajalt/clikt/parameters/options/FlagOption.kt

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class FlagOption<T>(
2727
val transformEnvvar: OptionTransformContext.(String) -> T,
2828
val transformAll: CallsTransformer<String, T>) : OptionDelegate<T> {
2929
override var parameterGroup: ParameterGroup? = null
30+
override var groupName: String? = null
3031
override val metavar: String? = null
3132
override val nvalues: Int get() = 0
3233
override val parser = FlagOptionParser
@@ -49,6 +50,19 @@ class FlagOption<T>(
4950
thisRef.registerOption(this)
5051
return this
5152
}
53+
54+
fun <T> copy(
55+
transformEnvvar: OptionTransformContext.(String) -> T,
56+
transformAll: CallsTransformer<String, T>,
57+
names: Set<String> = this.names,
58+
secondaryNames: Set<String> = this.secondaryNames,
59+
help: String = this.help,
60+
hidden: Boolean = this.hidden,
61+
helpTags: Map<String, String> = this.helpTags,
62+
envvar: String? = this.envvar
63+
): FlagOption<T> {
64+
return FlagOption(names, secondaryNames, help, hidden, helpTags, envvar, transformEnvvar, transformAll)
65+
}
5266
}
5367

5468
/**
@@ -88,17 +102,18 @@ fun <T : Any> RawOption.switch(choices: Map<String, T>): FlagOption<T?> {
88102
transformEnvvar = {
89103
throw UsageError("environment variables not supported for switch options", this)
90104
},
91-
transformAll = { it.map { choices[it]!! }.lastOrNull() })
105+
transformAll = { it.map { choices.getValue(it) }.lastOrNull() })
92106
}
93107

94108
/** Turn an option into a set of flags that each map to a value. */
95109
fun <T : Any> RawOption.switch(vararg choices: Pair<String, T>): FlagOption<T?> = switch(mapOf(*choices))
96110

97111
/** Set a default value for a option. */
98112
fun <T : Any> FlagOption<T?>.default(value: T): FlagOption<T> {
99-
return FlagOption(names, secondaryNames, help, hidden, helpTags, envvar,
113+
return copy(
100114
transformEnvvar = { transformEnvvar(it) ?: value },
101-
transformAll = { transformAll(it) ?: value })
115+
transformAll = { transformAll(it) ?: value }
116+
)
102117
}
103118

104119
/**
@@ -108,7 +123,7 @@ fun <T : Any> FlagOption<T?>.default(value: T): FlagOption<T> {
108123
* if the value is not valid. It is not called if the delegate value is null.
109124
*/
110125
fun <T : Any> FlagOption<T>.validate(validator: OptionValidator<T>): OptionDelegate<T> {
111-
return FlagOption(names, secondaryNames, help, hidden, helpTags, envvar,
126+
return copy(
112127
transformEnvvar = { transformEnvvar(it).also { validator(this, it) } },
113128
transformAll = { transformAll(it).also { validator(this, it) } }
114129
)
@@ -133,5 +148,5 @@ fun <T> FlagOption<T>.deprecated(
133148
error: Boolean = false
134149
): OptionDelegate<T> {
135150
val helpTags = if (tagName.isNullOrBlank()) helpTags else helpTags + mapOf(tagName to tagValue)
136-
return FlagOption(names, secondaryNames, help, hidden, helpTags, envvar, transformEnvvar, deprecationTransformer(message, error, transformAll))
151+
return copy(transformEnvvar, deprecationTransformer(message, error, transformAll), helpTags = helpTags)
137152
}

clikt/src/main/kotlin/com/github/ajalt/clikt/parameters/options/OptionWithValues.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class OptionWithValues<AllT, EachT, ValueT>(
105105
val transformAll: CallsTransformer<EachT, AllT>
106106
) : OptionDelegate<AllT>, GroupableOption {
107107
override var parameterGroup: ParameterGroup? = null
108+
override var groupName: String? = null
108109
override val metavar: String? get() = metavarWithDefault.value
109110
override var value: AllT by NullableLateinit("Cannot read from option delegate before parsing command line")
110111
private set

clikt/src/main/kotlin/com/github/ajalt/clikt/parameters/types/choice.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ fun <T : Any> RawArgument.choice(choices: Map<String, T>): ProcessedArgument<T,
4343
* ```
4444
*/
4545
fun <T : Any> RawArgument.choice(vararg choices: Pair<String, T>): ProcessedArgument<T, T> {
46-
return choice(mapOf(*choices))
46+
return choice(choices.toMap())
4747
}
4848

4949
/**
@@ -69,6 +69,8 @@ fun RawArgument.choice(vararg choices: String): ProcessedArgument<String, String
6969
* ```kotlin
7070
* option().choice(mapOf("foo" to 1, "bar" to 2))
7171
* ```
72+
*
73+
* @see com.github.ajalt.clikt.parameters.groups.groupChoice
7274
*/
7375
fun <T : Any> RawOption.choice(choices: Map<String, T>,
7476
metavar: String = mvar(choices.keys)): NullableOption<T, T> {
@@ -86,10 +88,12 @@ fun <T : Any> RawOption.choice(choices: Map<String, T>,
8688
* ```kotlin
8789
* option().choice("foo" to 1, "bar" to 2)
8890
* ```
91+
*
92+
* @see com.github.ajalt.clikt.parameters.groups.groupChoice
8993
*/
9094
fun <T : Any> RawOption.choice(vararg choices: Pair<String, T>,
9195
metavar: String = mvar(choices.map { it.first })): NullableOption<T, T> {
92-
return choice(mapOf(*choices), metavar)
96+
return choice(choices.toMap(), metavar)
9397
}
9498

9599
/**

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,7 @@ internal object Parser {
9696
// Finalize eager options
9797
invocationsByOption.forEach { (o, inv) -> if (o is EagerOption) o.finalize(context, inv) }
9898

99-
// Finalize option groups
100-
invocationsByOptionByGroup.forEach { (group, invocations) ->
101-
group?.finalize(context, invocations)
102-
}
103-
104-
// Finalize remaining un-grouped options that occurred on the command line
99+
// Finalize un-grouped options that occurred on the command line
105100
invocationsByOptionByGroup[null]?.forEach { (o, inv) -> o.finalize(context, inv) }
106101

107102
// Finalize un-grouped options not provided on the command line so that they can apply default values etc.
@@ -111,6 +106,11 @@ internal object Parser {
111106
}
112107
}
113108

109+
// Finalize option groups after other options so that the groups can their values
110+
invocationsByOptionByGroup.forEach { (group, invocations) ->
111+
group?.finalize(context, invocations)
112+
}
113+
114114
// Finalize groups with no invocations
115115
command._groups.forEach { if (it !in invocationsByGroup) it.finalize(context, emptyMap()) }
116116

clikt/src/test/kotlin/com/github/ajalt/clikt/parameters/groups/OptionGroupsTest.kt

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
package com.github.ajalt.clikt.parameters.groups
44

5+
import com.github.ajalt.clikt.core.BadParameterValue
56
import com.github.ajalt.clikt.core.MissingParameter
67
import com.github.ajalt.clikt.core.MutuallyExclusiveGroupException
78
import com.github.ajalt.clikt.core.UsageError
89
import com.github.ajalt.clikt.parameters.options.default
910
import com.github.ajalt.clikt.parameters.options.option
1011
import com.github.ajalt.clikt.parameters.options.required
12+
import com.github.ajalt.clikt.parameters.types.int
1113
import com.github.ajalt.clikt.testing.TestCommand
1214
import io.kotlintest.data.forall
1315
import io.kotlintest.fail
@@ -237,4 +239,50 @@ class OptionGroupsTest {
237239
shouldThrow<IllegalArgumentException> { C() }
238240
.message shouldBe "At least one option in a co-occurring group must use `required()`"
239241
}
242+
243+
@Test
244+
fun `choice group`() {
245+
class Group1 : OptionGroup() {
246+
val g11 by option().int().required()
247+
val g12 by option().int()
248+
}
249+
250+
class Group2 : OptionGroup() {
251+
val g21 by option().int().required()
252+
val g22 by option().int()
253+
}
254+
255+
class C : TestCommand() {
256+
val g by option().groupChoice("1" to Group1(), "2" to Group2())
257+
}
258+
forall(
259+
row("", 0, null, null),
260+
row("--g11=1 --g21=1", 0, null, null),
261+
row("--g=1 --g11=2", 1, 2, null),
262+
row("--g=1 --g11=2 --g12=3", 1, 2, 3),
263+
row("--g=1 --g11=2 --g12=3", 1, 2, 3),
264+
row("--g=2 --g21=2 --g22=3", 2, 2, 3),
265+
row("--g=2 --g11=2 --g12=3 --g21=2 --g22=3", 2, 2, 3)
266+
) { argv, eg, eg1, eg2 ->
267+
with(C()) {
268+
parse(argv)
269+
when (eg) {
270+
0 -> {
271+
g shouldBe null
272+
}
273+
1 -> {
274+
(g as Group1).g11 shouldBe eg1
275+
(g as Group1).g12 shouldBe eg2
276+
}
277+
2 -> {
278+
(g as Group2).g21 shouldBe eg1
279+
(g as Group2).g22 shouldBe eg2
280+
}
281+
}
282+
}
283+
}
284+
285+
shouldThrow<BadParameterValue> { C().parse("--g=3") }
286+
.message shouldBe "Invalid value for \"--g\": invalid choice: 3. (choose from 1, 2)"
287+
}
240288
}

docs/documenting.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ Options:
208208

209209
You can group options into separate help sections by using
210210
[OptionGroup](api/clikt/com.github.ajalt.clikt.parameters.groups/-option-group/index.html).
211-
The name of the group will be shown in the output. You can also add an extra help message to be shown with the group.
211+
The name of the group will be shown in the output. You can also add an extra help message to be
212+
shown with the group. Groups can't be nested.
212213

213214
```kotlin
214215
class UserOptions : OptionGroup(name = "User Options", help = "Options controlling the user") {

0 commit comments

Comments
 (0)