ajalt / clikt

Multiplatform command line interface parsing for Kotlin
https://ajalt.github.io/clikt/
Apache License 2.0
2.56k stars 123 forks source link

multiple cooccurring option groups e.g. val userOptions by UserOptions().cooccurring().multiple() #468

Closed HoffiMuc closed 1 year ago

HoffiMuc commented 1 year ago

docs for co-occurring-option-groups

Hi, would it be possible to have multiple cooccurring option groups?

val userOptions: List<UserOptions> by UserOptions().cooccurring().multiple()

class UserOptions : CliktCommand() {
    class UserOptions : OptionGroup() {
        val name by option().required()
        val age by option().int()
        val opt by option().int()
    }
    val userOptions: List<UserOptions> by UserOptions().cooccurring().multiple()

    override fun run() {
        if (userOptions == null || userOptions.isEmpty()) echo("No user options")
        userOptions.forEach {
            userOptions.let {
                echo("name: '${it.name}'")
                echo(" age: '${it.age}'")
                echo(" opt: '${it.opt}'")
            }
        }
    }
}

on calling:

and then be able to:

$ ./tool --name hoffi --age=30   --unrelatedOption --name both --age=77 --opt=78   --name jesus --opt=42 --whatewaoption

with result:

name: 'hoffi'
 age:  '30'
 opt:  'null'
name: 'both'
 age: '77'
 opt: '78'
name 'jesus'
 age: 'null'
 opt: '42'

Would be cool (I hope it would be possible without any problems, but I'm not sure)

ajalt commented 1 year ago

Groups can't be repeated, but subcommands can. Use a subcommand instead of a group and you can have something like this:

$ ./tool --unrelatedOption person --name hoffi --age=30  person --name both --age=77 --opt=78  person --name jesus --opt=42
HoffiMuc commented 1 year ago

My goal is NOT to run() a command consecutively, as I am transforming very big files, I want to have some opts that are "globally" for "how" (e.g. case insensitive) to transform all the files, PLUS multiple groups of options which tell "what" to do with different(!) parts/regions in each file. So: for a region identified by A do X, for a region identified by B do Y, ...

As the files are very big, I don't want to run the subcommand x times for achieving all the x transformations, but give a set of transformations (plus the global options once) so I can do a "transform once"-run on each file, doing ALL the transformations to it which in turn are defined by the combined information in each group of options.

any way on how to achieve something like that?

addendum: how could I pass trailing arguments (the files to operate on) to ALL subcommand's execution?? Because if my subcommand has arguments val args: Set<String> by argument().multiple().unique() It will "eat up" anything after the last option of the first subcommand as arguments (and not calling the subcommand a second time, but passing the 2nd 'person' as first arg to the subcommand)

example of what I have in mind:

$ kscript replaceInFiles \
    --verbose --ignore-nonexisting --backup \
    \
    --region-start '^# START REPLACE Region 1' \
    --region-end '^# END REPLACE Region 1' \
    --replace '\d' "X"   --replace 'ri' 'ir'  \
    \
    --region-start '^# START REPLACE Region 2' \
    --region-end '^# END REPLACE Region 2' \
    --replace '^(\w+) ([A-Z]+)(.*)$' 'CHANGED $2'   --replace 'regex' 'replaced by $2' \
    \
    ~/tmp/original.txt ~/tmp/nonex.txt

I know, a bit more "invasive" ... but also would be way cool! :)

ajalt commented 1 year ago

You don't have to do any processing in your run; you can collect the info into your context object and process everything at the end.

But if you're fixed on not using subcommands, you'll have to collect the repeated options as arguments and group them yourself. Global options can be declared as usual.

You can't have arguments after a subcommand with multiple arguments. There would be no way to know whether a token should belong to the parent or subcommand.

HoffiMuc commented 1 year ago

just to confirm if I correctly understood: (and if so, for others who come acoss this as reference)

for re-cooccuring OptionGroups you'd need

so e.g.:

the gathering one OptionGroup CliktCommand:

class UserOptGroup() : CliktCommand(name = "user") {
    class UserOptions : OptionGroup() {
        override fun toString(): String = "user($login, ${name.singleQuote()}, $age, $opt)"
        val login by option().required()
        val name by option()
        val age by option().int()
        val opt by option().int()
    }
    data class User(val login: String, val name: String?, val age: Int?, val opt: Int?) {
        override fun toString(): String = "User('$login', ${name.singleQuote()}, $age, $opt)"
    }
    val userOptions by UserOptions().cooccurring()
    val unrelatedOption by option("--unrelatedOption").flag()

    override fun run() {
        val parentCtxUsers: MutableMap<String, User> = currentContext.parent!!.findOrSetObject { mutableMapOf() }
        userOptions?.let { parentCtxUsers[it.login] = User(it.login, it.name, it.age, it.opt) }
        // debug output
        echo("UserOptGroup run():")
        echo("  unrelatedOption: '${unrelatedOption}'")
        userOptions?.let { echo("  $it") } ?: echo(" no userOptions")
    }
}

the actual CliktCommand using all of above's OptionGroups

class DoWithUsers() : CliktCommand(name = "doWithUsers") {
    val users: MutableMap<String, UserOptGroup.User> = mutableMapOf() // set/filled in run() from parent CliktCommand context
    val args: Set<String> by argument().multiple().unique().help("files to do replacement(s) in")

    override fun run() {
        val parentUsers = currentContext.parent!!.findObject<MutableMap<String, UserOptGroup.User>>()
        parentUsers?.let { users.putAll(it) }
        // debug output
        if (users.isEmpty()) echo("no users!")
        echo("DoWithUsers: ${users.size} users: ${users.entries.joinToStringSingleQuoted()}")
        echo("DoWithUsers: args: ${args.joinToStringSingleQuoted()}")
    }
}

btw: doing it this way the actual CliktCommand cannot be unit tested without both CliktCommands or if connected via a parent CliktComand all three of them, as fun CliktCommand.test(...) only tests a CliktCommand in isolation of all other CliktCommands (at least not without some dirty hardcoded inject the List/Map of OptionGroup's into it.) or do you have a fancy idea for testing things like above's class DoWithUsers() : CliktCommand(name = "doWithUsers") ? :)

ajalt commented 1 year ago

That's one way to do it, although you don't necessarily need the group. You can test each command individually if you use findOrSetObject instead.

data class User(val login: String, val name: String?)

class UserCommand : CliktCommand() {
    val login by option().required()
    val name by option()

    val users by findOrSetObject { mutableMapOf<String, User>() }

    override fun run() {
        users[login] = User(login, name)
    }
}

class ReplaceCommand : CliktCommand() {
    val users by findOrSetObject { mutableMapOf<String, User>() }
    val args: Set<String> by argument().multiple().unique().help("files to do replacement(s) in")

    override fun run() {
        echo("users: $users")
        echo("args: $args")
    }
}

class Tool : CliktCommand(allowMultipleSubcommands = true) {
    override fun run() {
        // set the context object for subcommands
        currentContext.obj = mutableMapOf<String, User>()
    }
}

test works with subcommands, you just have to register them like you normally would:

val command = Tool().subcommands(UserCommand(), ReplaceCommand())
val result = command.test(
    "user --login a --name b user --login c replace file1 file2"
)
assertEquals(
    result.output,
    """
    users: {a=User(login=a, name=b), c=User(login=c, name=null)}
    args: [file1, file2]
    """.trimIndent()
)