Is mixing getopts with positional parameters possible?

Viewed 42997

I want to design a shell script as a wrapper for a couple of scripts. I would like to specify parameters for myshell.sh using getopts and pass the remaining parameters in the same order to the script specified.

If myshell.sh is executed like:

myshell.sh -h hostname -s test.sh -d waittime param1 param2 param3

myshell.sh param1 param2 -h hostname param3 -d waittime -s test.sh

myshell.sh param1 -h hostname -d waittime -s test.sh param2 param3

All of the above should be able to call as

test.sh param1 param2 param3

Is it possible to utilize the options parameters in the myshell.sh and post remaining parameters to underlying script?

9 Answers

myshell.sh:

#!/bin/bash

script_args=()
while [ $OPTIND -le "$#" ]
do
    if getopts h:d:s: option
    then
        case $option
        in
            h) host_name="$OPTARG";;
            d) wait_time="$OPTARG";;
            s) script="$OPTARG";;
        esac
    else
        script_args+=("${!OPTIND}")
        ((OPTIND++))
    fi
done

"$script" "${script_args[@]}"

test.sh:

#!/bin/bash
echo "$0 $@"

Testing the OP's cases:

$ PATH+=:.  # Use the cases as written without prepending ./ to the scripts
$ myshell.sh -h hostname -s test.sh -d waittime param1 param2 param3
./test.sh param1 param2 param3
$ myshell.sh param1 param2 -h hostname param3 -d waittime -s test.sh
./test.sh param1 param2 param3
$ myshell.sh param1 -h hostname -d waittime -s test.sh param2 param3
./test.sh param1 param2 param3

What's going on:

getopts will fail if it encounters a positional parameter. If it's used as a loop condition, the loop would break prematurely whenever positional parameters appear before options, as they do in two of the test cases.

So instead, this loop breaks only once all parameters have been processed. If getopts doesn't recognize something, we just assume it's a positional parameter, and stuff it into an array while manually incrementing getopts's counter.

Possible improvements:

As written, the child script can't accept options (only positional parameters), since getopts in the wrapper script will eat those and print an error message, while treating any argument like a positional parameter:

$ myshell.sh param1 param2 -h hostname -d waittime -s test.sh -a opt1 param3
./myshell.sh: illegal option -- a
./test.sh param1 param2 opt1 param3

If we know the child script can only accept positional parameters, then myshell.sh should probably halt on an unrecognized option. That could be as simple as adding a default last case at the end of the case block:

            \?) exit 1;;
$ myshell.sh param1 param2 -h hostname -d waittime -s test.sh -a opt1 param3
./myshell.sh: illegal option -- a

If the child script needs to accept options (as long as they don't collide with the options in myshell.sh), we could switch getopts to silent error reporting by prepending a colon to the option string:

    if getopts :h:d:s: option

Then we'd use the default last case to stuff any unrecognized option into script_args:

            \?) script_args+=("-$OPTARG");;
$ myshell.sh param1 param2 -h hostname -d waittime -s test.sh -a opt1 param3
./test.sh param1 param2 -a opt1 param3

Mix opts and args :

ARGS=""
echo "options :"
while [ $# -gt 0 ]
do
    unset OPTIND
    unset OPTARG
    while getopts as:c:  options
    do
    case $options in
            a)  echo "option a  no optarg"
                    ;;
            s)  serveur="$OPTARG"
                    echo "option s = $serveur"
                    ;;
            c)  cible="$OPTARG"
                    echo "option c = $cible"
                    ;;
        esac
   done
   shift $((OPTIND-1))
   ARGS="${ARGS} $1 "
   shift
done

echo "ARGS : $ARGS"
exit 1

Result:

bash test.sh  -a  arg1 arg2 -s serveur -c cible  arg3
options :
option a  no optarg
option s = serveur
option c = cible
ARGS :  arg1  arg2  arg3

Instead of using getopts, you can directly implement your own bash argument parser. Take this as a working example. It can handle simultaneously name and position arguments.

#!/bin/bash

function parse_command_line() {
    local named_options;
    local parsed_positional_arguments;

    yes_to_all_questions="";
    parsed_positional_arguments=0;

    named_options=(
            "-y" "--yes"
            "-n" "--no"
            "-h" "--help"
            "-s" "--skip"
            "-v" "--version"
        );

    function validateduplicateoptions() {
        local item;
        local variabletoset;
        local namedargument;
        local argumentvalue;

        variabletoset="${1}";
        namedargument="${2}";
        argumentvalue="${3}";

        if [[ -z "${namedargument}" ]]; then
            printf "Error: Missing command line option for named argument '%s', got '%s'...\\n" "${variabletoset}" "${argumentvalue}";
            exit 1;
        fi;

        for item in "${named_options[@]}";
        do
            if [[ "${item}" == "${argumentvalue}" ]]; then
                printf "Warning: Named argument '%s' got possible invalid option '%s'...\\n" "${namedargument}" "${argumentvalue}";
                exit 1;
            fi;
        done;

        if [[ -n "${!variabletoset}" ]]; then
            printf "Warning: Overriding the named argument '%s=%s' with '%s'...\\n" "${namedargument}" "${!variabletoset}" "${argumentvalue}";
        else
            printf "Setting '%s' named argument '%s=%s'...\\n" "${thing_name}" "${namedargument}" "${argumentvalue}";
        fi;
        eval "${variabletoset}='${argumentvalue}'";
    }

    # https://stackoverflow.com/questions/2210349/test-whether-string-is-a-valid-integer
    function validateintegeroption() {
        local namedargument;
        local argumentvalue;

        namedargument="${1}";
        argumentvalue="${2}";

        if [[ -z "${2}" ]];
        then
            argumentvalue="${1}";
        fi;

        if [[ -n "$(printf "%s" "${argumentvalue}" | sed s/[0-9]//g)" ]];
        then
            if [[ -z "${2}" ]];
            then
                printf "Error: The %s positional argument requires a integer, but it got '%s'...\\n" "${parsed_positional_arguments}" "${argumentvalue}";
            else
                printf "Error: The named argument '%s' requires a integer, but it got '%s'...\\n" "${namedargument}" "${argumentvalue}";
            fi;
            exit 1;
        fi;
    }

    function validateposisionaloption() {
        local variabletoset;
        local argumentvalue;

        variabletoset="${1}";
        argumentvalue="${2}";

        if [[ -n "${!variabletoset}" ]]; then
            printf "Warning: Overriding the %s positional argument '%s=%s' with '%s'...\\n" "${parsed_positional_arguments}" "${variabletoset}" "${!variabletoset}" "${argumentvalue}";
        else
            printf "Setting the %s positional argument '%s=%s'...\\n" "${parsed_positional_arguments}" "${variabletoset}" "${argumentvalue}";
        fi;
        eval "${variabletoset}='${argumentvalue}'";
    }

    while [[ "${#}" -gt 0 ]];
    do
        case ${1} in
            -y|--yes)
                yes_to_all_questions="${1}";
                printf "Named argument '%s' for yes to all questions was triggered.\\n" "${1}";
                ;;

            -n|--no)
                yes_to_all_questions="${1}";
                printf "Named argument '%s' for no to all questions was triggered.\\n" "${1}";
                ;;

            -h|--help)
                printf "Print help here\\n";
                exit 0;
                ;;

            -s|--skip)
                validateintegeroption "${1}" "${2}";
                validateduplicateoptions g_installation_model_skip_commands "${1}" "${2}";
                shift;
                ;;

            -v|--version)
                validateduplicateoptions branch_or_tag "${1}" "${2}";
                shift;
                ;;

            *)
                parsed_positional_arguments=$((parsed_positional_arguments+1));

                case ${parsed_positional_arguments} in
                    1)
                        validateposisionaloption branch_or_tag "${1}";
                        ;;

                    2)
                        validateintegeroption "${1}";
                        validateposisionaloption g_installation_model_skip_commands "${1}";
                        ;;

                    *)
                        printf "ERROR: Extra positional command line argument '%s' found.\\n" "${1}";
                        exit 1;
                        ;;
                esac;
                ;;
        esac;
        shift;
    done;

    if [[ -z "${g_installation_model_skip_commands}" ]];
    then
        g_installation_model_skip_commands="0";
    fi;
}

You would call this function as:

#!/bin/bash
source ./function_file.sh;
parse_command_line "${@}";

Usage example:

./test.sh as 22 -s 3
Setting the 1 positional argument 'branch_or_tag=as'...
Setting the 2 positional argument 'skip_commands=22'...
Warning: Overriding the named argument '-s=22' with '3'...

References:

  1. example_installation_model.sh.md
  2. Checking for the correct number of arguments
  3. https://unix.stackexchange.com/questions/129391/passing-named-arguments-to-shell-scripts
  4. An example of how to use getopts in bash
Related