Skip to content

Commit

Permalink
Merge pull request #35 from max-au/max-au/implement-global-default
Browse files Browse the repository at this point in the history
[argparse, cli] implement global default
  • Loading branch information
max-au authored Mar 23, 2022
2 parents d515fd6 + 1a725b4 commit 69acf3c
Show file tree
Hide file tree
Showing 10 changed files with 69 additions and 23 deletions.
4 changes: 1 addition & 3 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ name: Build, Test, Dialyze

on:
pull_request:
branches:
- 'master'
types: [ opened, reopened, synchronize ]
push:
branches:
- 'master'

jobs:
linux:
name: Test on OTP ${{ matrix.otp_version }} and ${{ matrix.os }}
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ To be considered after 1.2.0:

## Changelog

Verson 1.2.1:
* implemented global default
* minor bugfixes

Verson 1.2.1:
* minor bugfixes, support for choices of atoms

Expand Down
13 changes: 11 additions & 2 deletions doc/ARGPARSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ To override default optional argument prefix (**-**), use **prefixes** option:
3> argparse:parse(["+sbwt"], #{arguments => [#{name => mode, short => $s}]}, #{prefixes => "+"}).
#{mode => "bwt"}

To define a global default for arguments that are not required, use **default** option:

4> argparse:parse([], #{arguments => [#{name => mode, required => false}]}, #{default => undef}).
#{mode => undef}

When global default is not set, resulting argument map does not include keys for arguments
that are not specified in the command line and there is no locally defined default value.

## Validation, help & usage information

Function ```validate/1``` may be used to validate command with all sub-commands
Expand All @@ -40,8 +48,9 @@ argument names to their values:

This map contains all arguments matching command line passed, initialised with
corresponding values. If argument is omitted, but default value is specified for it,
it is added to the map. When no default value specified, and argument is not
present, corresponding key is not present in the map.
it is added to the map. When no local default value specified, and argument is not
present, corresponding key is not present in the map, unless there is a global default
passed with `parse/3` options.

Missing required (field **required** is set to true for optional arguments,
or missing for positional) arguments raises an error.
Expand Down
4 changes: 3 additions & 1 deletion doc/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,6 @@ to provide correct help/usage line:
## Reference

cli is able to pass **prefixes** option to argparse (this also changes *-h* and *--help*
prefix). There are also additional options to explore, see `cli:run/2` function reference.
prefix). There are also additional options to explore, see `cli:run/2` function reference.

cli also passes **default** option to argparse.
2 changes: 1 addition & 1 deletion doc/overview.edoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
** this is the overview.doc file for the application 'argparse' **

@version 1.2.1
@version 1.2.2
@author Maxim Fedorov, <[email protected]>
@title argparse: A simple framework to create complex CLI.

Expand Down
2 changes: 1 addition & 1 deletion src/argparse.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, argparse,
[{description, "argparse: arguments parser, and cli framework"},
{vsn, "1.2.1"},
{vsn, "1.2.2"},
{applications,
[kernel,
stdlib
Expand Down
36 changes: 23 additions & 13 deletions src/argparse.erl
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@
short = #{} :: #{integer() => argument()},
long = #{} :: #{string() => argument()},
%% flag, whether there are no options that can be confused with negative numbers
no_digits = true :: boolean()
no_digits = true :: boolean(),
%% global default for not required arguments
default :: error | {ok, term()}
}).

%% Error Reason thrown by parser (feed it into format_error to get human-readable error).
Expand All @@ -230,6 +232,8 @@
-type parser_options() :: #{
%% allowed prefixes (default is [$-]).
prefixes => [integer()],
%% default value for all missing not required arguments
default => term(),
%% next fields are only considered when printing usage
progname => string() | atom(), %% program name override
command => [string()] %% nested command (missing/empty for top-level command)
Expand Down Expand Up @@ -265,7 +269,8 @@ parse(Args, Command) ->
parse(Args, Command, Options) ->
{Prog, Cmd} = validate(Command, Options),
Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
parse_impl(Args, merge_arguments(Prog, Cmd, #eos{prefixes = Prefixes, current = Cmd})).
parse_impl(Args, merge_arguments(Prog, Cmd, #eos{prefixes = Prefixes, current = Cmd,
default = maps:find(default, Options)})).

%% By default, options are indented with 2 spaces for each level of
%% sub-command.
Expand Down Expand Up @@ -398,16 +403,16 @@ parse_impl([Positional | Tail], Eos) ->

%% Entire command line has been matched, go over missing arguments,
%% add defaults etc
parse_impl([], #eos{argmap = ArgMap0, commands = Commands, current = Current, pos = Pos} = Eos) ->
parse_impl([], #eos{argmap = ArgMap0, commands = Commands, current = Current, pos = Pos, default = Def} = Eos) ->
%% error if stopped at sub-command with no handler
map_size(maps:get(commands, Current, #{})) >0 andalso
(not is_map_key(handler, Current)) andalso
fail({missing_argument, Commands, "missing handler"}),
%% go over remaining positional, verify they are all not required
ArgMap1 = fold_args_map(Commands, true, ArgMap0, Pos),
ArgMap1 = fold_args_map(Commands, true, ArgMap0, Pos, Def),
%% go over optionals, and either raise an error, or set default
ArgMap2 = fold_args_map(Commands, false, ArgMap1, maps:values(Eos#eos.short)),
ArgMap3 = fold_args_map(Commands, false, ArgMap2, maps:values(Eos#eos.long)),
ArgMap2 = fold_args_map(Commands, false, ArgMap1, maps:values(Eos#eos.short), Def),
ArgMap3 = fold_args_map(Commands, false, ArgMap2, maps:values(Eos#eos.long), Def),
case Eos#eos.commands of
[_] ->
%% if there were no commands specified, only the argument map
Expand All @@ -420,7 +425,7 @@ parse_impl([], #eos{argmap = ArgMap0, commands = Commands, current = Current, po

%% Generate error for missing required argument, and supply defaults for
%% missing optional arguments that have defaults.
fold_args_map(Commands, Req, ArgMap, Args) ->
fold_args_map(Commands, Req, ArgMap, Args, GlobalDefault) ->
lists:foldl(
fun (#{name := Name}, Acc) when is_map_key(Name, Acc) ->
%% argument present
Expand All @@ -431,9 +436,9 @@ fold_args_map(Commands, Req, ArgMap, Args) ->
(#{name := Name, required := false, default := Default}, Acc) ->
%% explicitly not required argument with default
Acc#{Name => Default};
(#{required := false}, Acc) ->
%% explicitly not required argument with no default - just skip
Acc;
(#{name := Name, required := false}, Acc) ->
%% explicitly not required with no local default, try global one
try_global_default(Name, Acc, GlobalDefault);
(#{name := Name, default := Default}, Acc) when Req =:= true ->
%% positional argument with default
Acc#{Name => Default};
Expand All @@ -443,11 +448,16 @@ fold_args_map(Commands, Req, ArgMap, Args) ->
(#{name := Name, default := Default}, Acc) ->
%% missing, optional, and there is a default
Acc#{Name => Default};
(_Opt, Acc) ->
%% missing, optional, no default - don't populate
Acc
(#{name := Name}, Acc) ->
%% missing, optional, no local default, try global default
try_global_default(Name, Acc, GlobalDefault)
end, ArgMap, Args).

try_global_default(_Name, Acc, error) ->
Acc;
try_global_default(Name, Acc, {ok, Term}) ->
Acc#{Name => Term}.

%%--------------------------------------------------------------------
%% argument consumption (nargs) handling

Expand Down
2 changes: 2 additions & 0 deletions src/cli.erl
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ run(Args) ->
help => boolean(),
error => ok | error | halt | {halt, non_neg_integer()},
prefixes => [integer()],%% prefixes passed to argparse
%% default value for all missing not required arguments
default => term(),
progname => string() | atom() %% specifies executable name instead of 'erl'
}.

Expand Down
11 changes: 10 additions & 1 deletion test/argparse_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
nodigits/0, nodigits/1,
python_issue_15112/0, python_issue_15112/1,
default_for_not_required/0, default_for_not_required/1,
global_default/0, global_default/1,
type_validators/0, type_validators/1,
error_format/0, error_format/1,
subcommand/0, subcommand/1,
Expand All @@ -47,7 +48,8 @@ groups() ->
basic, long_form_eq, single_arg_built_in_types, complex_command, errors,
unicode, args, argparse, negative, proxy_arguments, default_for_not_required,
nodigits, python_issue_15112, type_validators, subcommand, error_format,
very_short, multi_short, usage, readme, error_usage, meta, usage_template
very_short, multi_short, usage, readme, error_usage, meta, usage_template,
global_default
]}].

all() ->
Expand Down Expand Up @@ -591,6 +593,13 @@ default_for_not_required(Config) when is_list(Config) ->
?assertEqual(#{def => 1}, parse("", #{arguments => [#{name => def, short => $d, required => false, default => 1}]})),
?assertEqual(#{def => 1}, parse("", #{arguments => [#{name => def, required => false, default => 1}]})).

global_default() ->
[{doc, "Tests that a global default can be enabled for all non-required arguments"}].

global_default(Config) when is_list(Config) ->
?assertEqual(#{def => "global"}, argparse:parse("", #{arguments => [#{name => def, type => int, required => false}]},
#{default => "global"})).

subcommand() ->
[{doc, "Tests subcommands parser"}].

Expand Down
14 changes: 13 additions & 1 deletion test/cli_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
multi_module/0, multi_module/1,
warnings/0, warnings/1,
simple/0, simple/1,
global_default/0, global_default/1,
malformed_behaviour/0, malformed_behaviour/1,
exit_code/0, exit_code/1
]).
Expand All @@ -48,7 +49,7 @@ suite() ->

all() ->
[test_cli, auto_help, subcmd_help, missing_handler, bare_cli, multi_module, warnings,
malformed_behaviour, exit_code].
malformed_behaviour, exit_code, global_default].

%%--------------------------------------------------------------------
%% Helpers
Expand Down Expand Up @@ -325,6 +326,17 @@ simple(Config) when is_list(Config) ->
ct:pal("~s", [IO]),
?assertEqual("Removing 4 (force: false, recursive: false)\n", IO).

global_default() ->
[{doc, "Verifies that global default for maps works"}].

global_default(Config) when is_list(Config) ->
CliRet = "#{arguments => [#{name => foo, short => $f}, #{name => bar, short => $b, default => \"1\"}]}",
FunExport = "cli/1",
FunDefs = "cli(#{foo := Foo, bar := Bar}) -> io:format(\"Foo ~s, bar ~s~n\", [Foo, Bar]).",
cli_module(simple, CliRet, FunExport, [FunDefs]),
{ok, IO} = capture_output(fun () -> cli:run([], #{modules => simple, error => ok, default => undefined}) end),
?assertEqual("Foo undefined, bar 1\n", IO).

malformed_behaviour() ->
[{doc, "Tests for cli/0 callback returning invalid command map"}].

Expand Down

0 comments on commit 69acf3c

Please sign in to comment.