Skip to content

Commit 08a6cf0

Browse files
reneSchmHenrZu
andauthored
1379 Add default options and a builder to CLI (#1377)
- Reworked CLI internals to reduce compile time. - Add "default options" feature to the CLI, implementing a common CLI usage pattern. - Add a ParameterSetBuilder to simplify creating parameter sets specifically for use with the CLI. Co-authored-by: Henrik Zunker <69154294+HenrZu@users.noreply.github.com>
1 parent 52ed3c1 commit 08a6cf0

File tree

9 files changed

+1352
-655
lines changed

9 files changed

+1352
-655
lines changed

cpp/examples/cli.cpp

Lines changed: 43 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -19,85 +19,59 @@
1919
*/
2020
#include "memilio/io/cli.h"
2121

22-
#include <vector>
23-
24-
struct Name {
25-
using Type = std::vector<std::string>;
26-
27-
static Type get_default()
28-
{
29-
return Type{"FirstName", "LastName"};
30-
}
31-
const static std::string name()
32-
{
33-
return "Name";
34-
}
35-
const static std::string alias()
36-
{
37-
return "n";
38-
}
39-
const static std::string description()
40-
{
41-
return "Enter your name as list of strings.";
42-
}
43-
};
44-
45-
struct Age {
46-
using Type = int;
47-
const static std::string name()
48-
{
49-
return "Age";
50-
}
51-
const static std::string alias()
52-
{
53-
return "a";
54-
}
55-
const static std::string description()
56-
{
57-
return "Enter your age.";
58-
}
59-
};
60-
61-
struct Greeting {
62-
using Type = std::string;
63-
64-
static Type get_default()
65-
{
66-
return Type{"Hello World!"};
67-
}
68-
const static std::string name()
69-
{
70-
return "Greeting";
71-
}
72-
const static std::string description()
73-
{
74-
return "Enter a custom greeting.";
75-
}
76-
};
77-
7822
int main(int argc, char** argv)
7923
{
80-
if (argc == 1) { // Print this if no arguments were given
24+
// Print a message if no arguments were given.
25+
if (argc == 1) {
8126
std::cout << "This is a small example on how to use the command line interface. "
8227
"Use \"-h\" to show the help dialogue.\n";
8328
}
84-
// create parameter set
85-
auto parameters = mio::ParameterSet<Name, Age, Greeting>{};
86-
// get command line options
87-
auto result = mio::command_line_interface("cli_example", argc, argv, parameters);
88-
// catch errors
29+
// Create a parameter set for the CLI using the builder. This defines the (parameter) options that the user can set
30+
// through the command line.
31+
//
32+
// To add() a parameter, you need to specify the name and type as template arguments, then pass an initial value.
33+
// The type can sometimes be deduced from the initial value, so it can potentially be omitted.
34+
// After the initial value you can set some optional fields of the parameter:
35+
// - alias, which allows you to add a shorthand for setting values
36+
// - description, which contains details on, e.g., what the parameter does and what values are accepted.
37+
// - is_required, which makes the CLI check whether the parameter was set. If not, it exits with an error.
38+
//
39+
// As a general rule, use simple types! The more complicated the type, the more complex is the Json representation
40+
// that the user has to input.
41+
//
42+
// Instead of using the builder, you can also define and pass a mio::ParameterSet as parameters.
43+
// The main difference (for the CLI) is that the mio::ParameterSet uses struct names to "get" parameters, while
44+
// the mio::cli::ParameterSet uses StringLiteral%s.
45+
auto parameters = mio::cli::ParameterSetBuilder()
46+
.add<"Name", std::vector<std::string>>({"FirstName", "LastName"},
47+
{"n", "Enter your name as list of strings.", false})
48+
.add<"Age">(0, {"a", "Enter your age."})
49+
.add<"Greeting">(std::string("Hello World!"),
50+
{.description = "Enter a custom greeting.", .is_required = false})
51+
.build();
52+
// Define some default options. This is an optional feature, that allows users to set some options in the given
53+
// order as the first arguments, without specifying their name or alias.
54+
auto default_options = std::vector<std::string>{"Name", "Age"};
55+
// Parse command line arguments and/or set parameters. This next line as well as the following check on its result
56+
// are required to use the CLI.
57+
auto result = mio::command_line_interface(argv[0], argc, argv, parameters, default_options);
58+
// Catch and print help output, printed options, and errors.
8959
if (!result) {
90-
std::cout << result.error().formatted_message();
91-
return result.error().code().value();
60+
std::cout << result.error().message(); // Do not use formatted_message().
61+
return result.error().code().value(); // Use exit here when not used in main().
9262
}
93-
// do something with the parameters
94-
std::cout << parameters.get<Greeting>() << "\n"
63+
// Now, do something with the parameters!
64+
// Note that the CLI only verifies that the user input is parsable, not plausible. If a parameter has certain value
65+
// requirements, like "Age > 0", you must check this yourself.
66+
std::cout << parameters.get<"Greeting">() << "\n"
9567
<< "Name: ";
96-
for (auto& name : parameters.get<Name>()) {
68+
for (auto& name : parameters.get<"Name">()) {
9769
std::cout << name << " ";
9870
}
9971
std::cout << "\n";
100-
if (parameters.get<Age>() > 0) {
101-
std::cout << "Age: " << parameters.get<Age>() << "\n";
72+
if (parameters.get<"Age">() > 0) {
73+
std::cout << "Age: " << parameters.get<"Age">() << "\n";
10274
}
75+
76+
return 0;
10377
}

cpp/memilio/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ add_library(memilio
5050
io/result_io.cpp
5151
io/epi_data.h
5252
io/epi_data.cpp
53+
io/cli.cpp
5354
io/cli.h
5455
math/euler.cpp
5556
math/euler.h

cpp/memilio/io/cli.cpp

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Copyright (C) 2020-2025 MEmilio
3+
*
4+
* Authors: René Schmieding
5+
*
6+
* Contact: Martin J. Kuehn <Martin.Kuehn@DLR.de>
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* http://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
#include "memilio/io/cli.h"
21+
22+
#ifdef MEMILIO_HAS_JSONCPP
23+
24+
#include <ostream>
25+
#include <sstream>
26+
#include <string>
27+
#include <vector>
28+
29+
/// @brief Part of write_help implementation. Writes the header with usage information and default options.
30+
void write_help_preamble(const std::string& executable_name, const std::vector<std::string>& default_options,
31+
std::ostream& os)
32+
{
33+
os << "Usage: " << executable_name;
34+
if (default_options.size() > 0) {
35+
os << " [";
36+
size_t i = 0;
37+
for (; i < default_options.size() - 1; i++) {
38+
os << default_options[i] << " ";
39+
}
40+
os << default_options[i] << "]";
41+
}
42+
os << " <option> <value> ...\n";
43+
if (default_options.size() > 0) {
44+
os << "Values of parameter options listed in [brackets] can be optionally entered in that order.\n";
45+
}
46+
os << "All values must be entered as json values, i.e. the expression to the right of \"Name : \".\n"
47+
<< "Note that, when entering values, quotation marks may have to be escaped (\\\").\n";
48+
}
49+
50+
/// @brief Part of write_help implementation. Writes out a singular parameter option.
51+
void write_help_parameter(const mio::cli::details::DatalessParameter& parameter, std::ostream& os)
52+
{
53+
// Max. space and offsets used to print everything nicely.
54+
constexpr size_t name_space = 16; // reserved space for name
55+
constexpr size_t alias_space = 4; // reserved space for alias
56+
constexpr size_t name_indent = 4; // size of " --"
57+
constexpr size_t alias_indent = 3; // size of " -"
58+
constexpr size_t description_indent = 2; // size of " "
59+
// Write name with "--" prefix and " " indent
60+
os << " --" << parameter.name;
61+
if (parameter.description.size() > 0 || parameter.alias.size() > 0) {
62+
if (parameter.name.size() <= name_space) {
63+
os << std::string(name_space - parameter.name.size(), ' ');
64+
}
65+
else {
66+
os << "\n" << std::string(name_space + name_indent, ' ');
67+
}
68+
}
69+
// Write alias (if available) with "-" prefix and " " indent
70+
size_t space = parameter.alias.size() < alias_space ? alias_space - parameter.alias.size() : 0;
71+
if (parameter.alias.size() > 0) {
72+
os << " -" << parameter.alias;
73+
}
74+
else {
75+
space += alias_indent;
76+
}
77+
// Write description (if available) and end line indentation
78+
if (parameter.description.size() > 0) {
79+
os << std::string(space + description_indent, ' ') << parameter.description;
80+
}
81+
os << "\n";
82+
}
83+
84+
void mio::cli::details::write_help(const std::string& executable_name, const AbstractSet& set,
85+
const std::vector<std::string>& default_options, std::ostream& os)
86+
{
87+
write_help_preamble(executable_name, default_options, os);
88+
os << "Options:\n";
89+
for (const auto& parameter : PresetOptions::all_presets) {
90+
write_help_parameter(parameter, os);
91+
}
92+
os << "Parameter options:\n";
93+
for (const auto& parameter : set.parameters()) {
94+
write_help_parameter(parameter, os);
95+
}
96+
}
97+
98+
mio::IOResult<void> mio::cli::details::write_abstract_set_to_file(mio::cli::details::AbstractSet& set,
99+
const std::string& filepath)
100+
{
101+
Json::Value output;
102+
for (auto& parameter : set.parameters()) {
103+
BOOST_OUTCOME_TRY(output[parameter.name()], parameter.get());
104+
}
105+
return mio::write_json(filepath, output);
106+
}
107+
108+
mio::IOResult<void> mio::cli::details::read_abstract_set_from_file(mio::cli::details::AbstractSet& set,
109+
const std::string& filepath)
110+
{
111+
// read file into json value
112+
auto json_result = mio::read_json(filepath);
113+
if (!json_result) {
114+
return mio::failure(json_result.error());
115+
}
116+
// set each parameter manually
117+
for (auto itr = json_result.value().begin(); itr != json_result.value().end(); itr++) {
118+
BOOST_OUTCOME_TRY(set.set_param(mio::cli::details::Identifier::make_raw(itr.name()), *itr));
119+
}
120+
return mio::success();
121+
}
122+
123+
mio::IOResult<void> mio::cli::details::command_line_interface(const std::string& executable_name, const int argc,
124+
char** argv, cli::details::AbstractSet& set,
125+
const std::vector<std::string>& default_options)
126+
{
127+
assert(set.parameters().size() > 0 && "At least one parameter is required!");
128+
// this function glues all functionalities of the cli together. it may repeatedly iterate through all values of
129+
// argv (starting at 1).
130+
using namespace mio::cli;
131+
using namespace mio::cli::details;
132+
// verify that all default_options are parameter names
133+
for (const auto& option : default_options) {
134+
if (!set.contains(Identifier::make_raw(option))) {
135+
return failure(mio::StatusCode::KeyNotFound, "Default option \"" + option + "\" is not a parameter name.");
136+
}
137+
}
138+
// pre-scan all argumemts before doing anything with them to deal with help and print_option
139+
// this avoids returning e.g. parsing errors instead of the help dialogue
140+
for (int i = 1; i < argc; i++) {
141+
auto id_result = Identifier::parse(argv[i]);
142+
// skip non-option arguments
143+
if (!id_result) {
144+
continue;
145+
}
146+
const auto& id = id_result.value();
147+
// handle help option
148+
if (id.matches_parameter(PresetOptions::help)) {
149+
// print the help dialogue and exit
150+
std::stringstream ss;
151+
write_help(executable_name, set, default_options, ss);
152+
return mio::failure(StatusCode::OK, std::move(ss.str()));
153+
}
154+
// handle print_option option
155+
else if (id.matches_parameter(PresetOptions::print_option)) {
156+
i++; // skip the PrintOption argument
157+
std::stringstream ss;
158+
for (; i < argc && !Identifier::is_option(argv[i]); i++) {
159+
// try to get the parameter's json value
160+
BOOST_OUTCOME_TRY(auto&& value, set.get_param(Identifier::make_raw(argv[i])));
161+
// print the name (or alias) and value
162+
ss << "Option " << argv[i] << ":\n" << value << "\n";
163+
}
164+
// return after all values are printed
165+
return mio::failure(StatusCode::OK, ss.str());
166+
}
167+
}
168+
// main pass over all args to set options
169+
int i = 1;
170+
// handle parameter options that require values iteratively. assign given values or return an error
171+
while (i < argc) {
172+
const auto id_result = Identifier::parse(argv[i]);
173+
// try to parse the first default_options.size() as arguments; afterwards, require an identifier
174+
if (!id_result) {
175+
// checking #defaults suffices, as non-option arguments are greedily collected into "arguments" below
176+
if (i - 1 < static_cast<int>(default_options.size())) {
177+
const auto& param_name = Identifier::make_raw(default_options[i - 1]);
178+
BOOST_OUTCOME_TRY(set.set_param(param_name, std::string(argv[i])));
179+
180+
i++;
181+
continue;
182+
}
183+
else {
184+
return id_result.error();
185+
}
186+
}
187+
const Identifier current_option(id_result.value());
188+
i++; // go to first argument
189+
// assert that the first argument is not an identifier (i.e. name or alias)
190+
if (i == argc || Identifier::is_option(argv[i])) {
191+
return mio::failure(mio::StatusCode::OutOfRange,
192+
"Missing value for option \"" + current_option.string + "\".");
193+
}
194+
// collect all argv's that are not identifiers and set i to the position of the next identifier
195+
std::string arguments(argv[i]);
196+
i++;
197+
for (; (i < argc) && !Identifier::is_option(argv[i]); i++) {
198+
// here space separated args are joined together. maybe a better way is to make users use 'ticks' to group
199+
// their input.
200+
arguments.append(" ").append(argv[i]);
201+
}
202+
// handle built-in options
203+
if (current_option.matches_parameter(PresetOptions::read_from_json)) {
204+
BOOST_OUTCOME_TRY(read_abstract_set_from_file(set, arguments));
205+
}
206+
else if (current_option.matches_parameter(PresetOptions::write_to_json)) {
207+
BOOST_OUTCOME_TRY(write_abstract_set_to_file(set, arguments));
208+
}
209+
// (try to) set the parameter, to the value given by arguments
210+
else {
211+
BOOST_OUTCOME_TRY(set.set_param(current_option, arguments));
212+
}
213+
}
214+
// check if required parameters were set, return an error if not
215+
if (std::ranges::any_of(set.parameters(), [](auto&& p) {
216+
return p.is_required();
217+
})) {
218+
std::stringstream ss;
219+
ss << "Missing values for required parameter(s):\n";
220+
for (const auto& p : set.parameters()) {
221+
if (p.is_required() == true) {
222+
ss << " " << p.name();
223+
}
224+
}
225+
ss << "\n"
226+
<< "Use \"" << executable_name << " --help\" for more info.\n";
227+
return mio::failure(mio::StatusCode::InvalidValue, ss.str());
228+
}
229+
return mio::success();
230+
}
231+
232+
#endif // MEMILIO_HAS_JSONCPP

0 commit comments

Comments
 (0)