Picocli: How to trigger system exit with a given return code and Feature request

Created on 28 Jun 2018  路  14Comments  路  Source: remkop/picocli

Dear,

Firstly, thanks for your awesome library.

I would like to follow as much as possible standard return type on *NIX . Thus I defined some constants (see below ) and use picocli through a Runnable class .

In order to capture exception throw by picocli I invoke CommandLine.populateCommand method followed by App.run . And inside the run method I can do some checks on both options and parameters and return an error code if it is needed.

But I fail to catch MissingParameterException and seem to be consumed by CommandLine::handleParseException and CommandLine::internalHandleParseException

So what is the recommended way to capture all errors events in order to exit with the proper system exit code ?

Any improvement of the current library in order to ease custom/standard status code usage is welcome.

@CommandLine.Command(name = "Foo")
public final class App implements Runnable {

    @CommandLine.Option(names = {"-v", "--verbose"}, description = "Turn-on informative messages. " +
                                                                   "Multiple -v options increase the verbosity.")
    private boolean[] verbose = new boolean[0];

    @CommandLine.Parameters(index = "0", paramLabel = "Input", description = "Input to load.")
    private File input;

    @CommandLine.Parameters(index = "1", paramLabel = "LOG DIR", description = "Directory to store log files.")
    private File logDir;

    public static void exit(final int status) {
        new Thread("App-exit") {
            @Override
            public void run() {
                System.exit(status);
            }
        }.start();
    }
    public static void main(final String[] args) {
        // Configure the number of columns used for the help message
        int width = Integer.parseInt(System.getProperty("picocli.usage.width", String.valueOf(DEFAULT_USAGE_WIDTH)));
        System.setProperty("picocli.usage.width", String.valueOf(width));

        try {
            final App app = CommandLine.populateCommand(new App(), args);
            app.run();
        }
        catch (CommandLine.MissingParameterException e){
            System.err.printf("Error missing parameter: %s%n%n", e.getMessage());
            CommandLine.usage(new App(), System.err);
            exit(SysExit.USAGE);
        }
        catch (CommandLine.ParameterException e){
            System.err.printf("Error: %s%n%n", e.getMessage());
            CommandLine.usage(new App(), System.err);
            exit(SysExit.USAGE);
        }
        catch (Exception e){
            System.err.printf("Unknown Error: %s%n%n", e.getMessage());
            CommandLine.usage(new App(), System.err);
            exit(SysExit.USAGE);
        }

    }


    @Override
    public void run() {

        if (!model.exists()) {
            System.err.printf("The file " + input+ " do not exists!%n%n");
            new CommandLine(this).usage(System.err);
            exit(SysExit.IO_ERROR);
        } else if (!model.isFile()) {
            System.err.printf("The item " + input+ " is not a file!%n%n");
            new CommandLine(this).usage(System.err);
            exit(SysExit.USAGE);
        }
        else if( model == null ) {
            System.err.printf("Missing required parameter: INPUT%n%n");
            new CommandLine(this).usage(System.err);
            exit(SysExit.USAGE);
        }

        if (!logDir.exists()) {
            System.err.printf("The directory " + logDir + " do not exists!%n%n");
            new CommandLine(this).usage(System.err);
            exit(SysExit.IO_ERROR);
        } else if (!logDir.isDirectory()) {
            System.err.printf("The item " + logDir + " is not a directory!%n%n");
            new CommandLine(this).usage(System.err);
            exit(SysExit.USAGE);
        }
        else if( logDir == null ) {
            System.err.printf("Missing required parameter: LOG DIR!%n%n");
            new CommandLine(this).usage(System.err);
            exit(SysExit.USAGE);
        }

        final Logger root = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
        switch (verbose.length) {
            case 0:
                root.setLevel(Level.OFF);
            case 1:
                root.setLevel(Level.INFO);
            case 2:
                root.setLevel(Level.WARN);
            case 3:
                root.setLevel(Level.DEBUG);
            default:
                root.setLevel(Level.DEBUG);
        }
    }
}

// FROM /usr/include/sysexits.h
public final class SysExit {
    public static final int OK = 0;
    public static final int GENERIC_ERROR = 1;
    public static final int BASE = 64;
    public static final int USAGE = 64;
    public static final int NO_DATA = 65;
    public static final int NO_INPUT = 66;
    public static final int NO_USER = 67;
    public static final int NO_HOST = 68;
    public static final int UNAVAILLABLE = 69;
    public static final int SOFTWARE = 70;
    public static final int OS_ERROR = 71;
    public static final int OS_FILE = 72;
    public static final int CAN_NOT_CREATE = 73;
    public static final int IO_ERROR = 74;
    public static final int TEMP_FAIL = 75;
    public static final int PROTOCOL = 76;
    public static final int NO_PERM = 77;
    public static final int CONFIG = 78;
}

Thanks for your insight

All 14 comments

I would recommend using CommandLine.run(Runnable, String[]) or CommandLine.call(Callable, String[] instead of the populate method. This takes care of exception handling and requests for usage help.

Perhaps something like this:

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

import java.io.File;
import java.util.concurrent.Callable;

@Command(name = "Foo", mixinStandardHelpOptions = true) // add --help and --version options
public final class App implements Callable<Integer> {

    @Option(names = {"-v", "--verbose"}, description = "Turn-on informative messages. " +
            "Multiple -v options increase the verbosity.")
    private boolean[] verbose = new boolean[0];

    @Parameters(index = "0", paramLabel = "Input", description = "Input to load.")
    private File input;

    @Parameters(index = "1", paramLabel = "LOG DIR", description = "Directory to store log files.")
    private File logDir;

    public App() {
    }

    public static void main(final String[] args) {
        // CommandLine.call takes care of exception handling and requests for usage or version help
        Integer result = CommandLine.call(new App(), args);

        // return value is null if user requested help with --help or --version
        if (result == null) {
            return;
        }
        exit(result);
    }

    @Override
    public Integer call() {

        validateInput();

        final Logger root = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
        switch (verbose.length) {
            case 0:
                root.setLevel(Level.OFF);
            case 1:
                root.setLevel(Level.INFO);
            case 2:
                root.setLevel(Level.WARN);
            case 3:
                root.setLevel(Level.DEBUG);
            default:
                root.setLevel(Level.DEBUG);
        }

        return 0; // success
    }

    private void validateInput() {
        if (!input.exists()) {
            exitWithError(SysExit.IO_ERROR, "The file %s does not exist!%n%n", input);
        } else if (!input.isFile()) {
            exitWithError(SysExit.USAGE, "The file %s is not a file!%n%n", input);
        }
        // we will never get here: the parameter is mandatory because
        // from the type (File) picocli derives that arity=1.
        // If missing, picocli throws a ParameterException (handled in CommandLine.call)
//        else if( input == null ) {
//            exitWithError(SysExit.IO_ERROR, "Missing required parameter: INPUT%n%n");
//        }

        if (!logDir.exists()) {
            exitWithError(SysExit.IO_ERROR, "The directory %s does not exist!%n%n", logDir);
        } else if (!logDir.isDirectory()) {
            exitWithError(SysExit.IO_ERROR, "The directory %s is not a directory!%n%n", logDir);
        }
        // we will never get here: the parameter is mandatory because
        // from the type (File) picocli derives that arity=1.
        // If missing, picocli throws a ParameterException (handled in CommandLine.call)
//        else if( logDir == null ) {
//            exitWithError(SysExit.USAGE, "Missing required parameter: LOG DIR%n%n");
//        }
    }

    private void exitWithError(int status, String message, Object... params) {
        System.err.printf(message, params);
        new CommandLine(this).usage(System.err);
        exit(status); 
    }

    private static void exit(int status) {
        System.exit(status); // no need to start a new thread
    }
}

Actually, the above will give an exit code of zero for invalid user input. This is not what you want.

Instead of CommandLine.call(Callable, String[]), you can use the longer (equivalent) form
CommandLine.parseWithHandlers(IParseResultHandler2, IExceptionHandler2, String[]).

Both IParseResultHandler2 and IExceptionHandler2 allow setting an exit code:

public static void main(final String[] args) {
    List<Object> results = new CommandLine(new App()).parseWithHandlers(new CommandLine.RunLast(),
            // set exit code to use when any exception occurs.
            // Unfortunately it is currently not possible to have different exit codes per exception...
            CommandLine.defaultExceptionHandler().andExit(SysExit.USAGE), args);

    // return value is null if user requested help with --help or --version
    if (results == null) {
        return;
    }
    exit((Integer) results.get(0));
}

Did that answer your question?

Thanks a lot @remkop , you have provided a really good snippet code. You can close it .
Thanks for your work and your help

Have a nice day

Glad I was able to help.
Enjoy using picocli, please spread the word if you like it! :-)

As few notes.
I use the library com.github.stefanbirkner:system-rules in order to test that the exit status is expected.

I remark, when the program exit from an option error the CheckExitCalled exception bubble and is never caught from a generic catch statement.

```java
import org.junit.contrib.java.lang.system.internal.CheckExitCalled;

...
final CheckExitCalled checkExitCalled = assertThrows(CheckExitCalled.class, () -> App.main(args));
assertEquals(SysExit.USAGE, checkExitCalled.getStatus().intValue());
````

But when the program exit from a parameter error the CheckExitCalled exception is caught while bubbling and a CommandLine.ExecutionException is throw.

```java
import org.junit.contrib.java.lang.system.internal.CheckExitCalled;

...
final CommandLine.ExecutionException exception = assertThrows(CommandLine.ExecutionException.class, () -> App.main(args));
assertTrue( exception.getCause() instanceof CheckExitCalled);
final CheckExitCalled cause = (CheckExitCalled) exception.getCause();
assertEquals(SysExit.USAGE, cause.getStatus().intValue());
````

So it is only to highlight the way to check the expected exit code. It is slightly different depends if error is raise from an option or a parameter.

best regards

To give you some background:

If picocli detects invalid input during parsing, a ParameterException is thrown. This is caught by the DefaultExceptionHandler, an error is printed to System.err and the program exits with the exit code specified on the exception handler.

If parsing succeeds, picocli invokes the run or call method of the application. If this method throws an exception, picocli will catch it and throw an ExecutionException that wraps the exception. That is why the CheckExitCalled thrown by stefanbirkner:system-rules (excellent library, I use it to test picocli also) is rethrown wrapped in an ExecutionException.

Thanks for letting me know!

Hi there! I am trying with the library and looks really cool. But I am wondering if the procedure to finish (exit) the application couldn't be simplier in cases of --help, -V or invalid arguments. Couldn't be possible a solution involving annotation for exit such as:

@Command(exitOnHelp = true,    // should exit with code 0
        exitOnVersion = true,             // should exit with code 0
        exitOnInvalidArgs = true        // should exit with code != 0
        // ... some other exit conditions
)

Maybe I am missing something, but the procedure described in this thread looks a little cumbersome for such common thing. Please, correct me if I am wrong or I missed anything :)

Thanks anyway.

Yes I鈥檓 also not 100% happy about exit code handling as it stands. I don鈥檛 have a good answer on how to fix it yet, need to think about this more.

So for now, is the standard procedure to to handle this situation (exit application on -hV/invalid args) exactly the one described in this thread? Could we open an issue to improve it?

Actually, wouldn't be the default behavior to exit when -hV (disregarding any other args) or invalid args? (unless someone explicitly specified the opposite).

Thanks.

If you use the CommandLine.run or CommandLine.call convenience methods to parse the command line, the parser will not check for missing required arguments if -h or -V was specified.

For other requirements feel free to create new tickets.

Yes, I know what you say but it seems that when -h or -V is present, neither CommandLine.run() nor CommandLine.call() makes application to exit -after print usage. This is the behavior I was expecting out of the box: print usage and immediately exit.

A bit of relates to #414 .

@gerardbosch Would you mind creating a new ticket?

Was this page helpful?
0 / 5 - 0 ratings