swift-argument-parser icon indicating copy to clipboard operation
swift-argument-parser copied to clipboard

Passing state from root to subcommands

Open JetForMe opened this issue 10 months ago • 3 comments

Working on a tool with a handful of subcommands, I find myself wanting to handle common work in the root command, and then pass some state to the subcommand. I’m not sure what might be possible, but something like this is what I’d like to be able to do:

% raise3d --addr 192.168.1.23 monitor --notify
@main
struct RootCommand : AsyncParsableCommand<Raise3DAPI> {
    static
    var
    configuration = CommandConfiguration(commandName: "raise3d",
                        abstract: "A utility to interact witih Raise3D printers.",
                        subcommands: [Monitor.self])
    
    @Option(help: "The printer’s local address.")
    var addr: String

    @Option(help: "The printer’s password.")
    var password: String?
    
    mutating
    func run() async throws -> Raise3DAPI {
        let password = self.password ?? { /* prompt user for password */ }()
        let api = Raise3DAPI(host: self.options.addr, password: self.options.password)
        try await api.login()
        return api
    }
}

struct Monitor : AsyncParsableCommand<Raise3DAPI> {
    static
    var configuration = CommandConfiguration(commandName: "monitor",
                                                abstract: "Monitor the printer and optionally notify of errors.")
    
    @Option(help: "Notify if error.")
    var notify: Bool = false
    
    mutating
    func run(state inAPI: Raise3DAPI) async throws
    {
        while (true) {
            let jobInfo = try await inAPI.getJobInfo()
            
            if self.notify && jobInfo.status == .error {
                //  Send notification
            }
            
            Task.sleep(for: .seconds(1))
        }
    }
}

class Raise3DAPI
{
    // … 
}

This lets subcommands be a little cleaner, because they don't have to redundantly declare the global options, and all the common work is done in the parent command. The desired state is generic.

JetForMe avatar Apr 17 '24 22:04 JetForMe

If you nest Monitor inside RootCommand, then you can add a property annotated with @OptionGroup in the former to access the options of the latter:

@main
struct RootCommand : AsyncParsableCommand<Raise3DAPI> {
...
  struct Monitor : AsyncParsableCommand<Raise3DAPI> {
   ...
    @OptionGroup
    var root: RootCommand

    mutating func run() async throws
      // Access `root.addr` and `root.password`...
  }
}

But you can't just make run in RootCommand return a value that you inject into the subcommand: Only one run command is invoked and that's the one of the subcommand.

Maybe you can make Raise3DAPI conform to ParsableArguments such that you can inject that value directly as an @OptionGroup (docs).

bisgardo avatar Apr 23 '24 08:04 bisgardo

A similar problem I'm having, although not related to passing state, is that if my Root command is a container for subcommands, I don't have a chance to execute any code at startup. For example this doesn't let me setup a logging system since the only code that runs is from the subcommand. I can revert to using main.swift and manually running the main method on the command (although you need to dance a bit with async context in order to call the proper overload), but it would be nice if there was a way to run setup code and then let the specific subcommand run. There maybe some way but I haven't found it 🤔

alexito4 avatar May 06 '24 12:05 alexito4

I've been able to do this via the validate method on the Root command, as SAP runs that method that for every command in the tree, even if you don't have a run method implemented

Mordil avatar Jul 12 '24 22:07 Mordil