1
0
mirror of https://github.com/pgbackrest/pgbackrest.git synced 2026-10-23 21:28:56 +03:00
Files
pgbackrest/build/lib/config/parse.py
David Steele 59bd2cd96c Migrate the build and documentation code from C to Python.
The build tool in src/build becomes build/build.py with its library in build/lib, and the C part of the documentation tool in doc/src becomes doc/doc.py with its library in doc/lib. They migrate together because doc/src was built on src/build, linking its config and help parsing along with its extensions to the core types. doc.pl runs doc.py rather than building and running a C binary, and meson runs build.py for the code it generates as it builds.

The three libraries form the hierarchy the C include path did: build/lib is the base, doc/lib sits on it, and test/lib sits on both. They are implicit namespace packages so a package spans them, and a module lives in exactly one library and holds the union of what the tools need from it, since Python resolves a module name to a single file and cannot extend one the way the C included a core module and added to it. define.yaml declares which libraries each tool may import and the test runner puts only those on the path, so a test of the build library cannot reach the doc or test library any more than the build tool can.

The declarations move with the code. config.yaml, error.yaml, and postgres.yaml are now at the root of build, and help.xml becomes doc/xml/reference.xml since it is documentation that the help happens to be generated from, so the generator is told where it is rather than knowing. dist.sh moves to build as well. The documentation tool's own option declarations are gone because they existed only to generate the option parser for the C binary and doc.py parses its command line directly.

Three modules in src/build/common were never build tool code. exec, regExp, and string extend the core types for the test harness and the unit tests, which were their only users, so they move to test/src/harness. The json and xml extensions had no users outside the two tools and are removed with them, and the tests for both are trimmed to the core functions that remain.

The help unit test built its fixture by calling the generator's C functions directly, which was the last thing outside the tools that depended on them. It now runs build.py help-data to write the packed help and loads that, so it still runs against the current declarations without needing the generated C, which may not have been written when the test runs.

The manual page wrapping is a port of the wrapper the binary uses for its own help, so the manual page and pgbackrest help break lines the same way, including where a word longer than a line is left unbroken.

libyaml is no longer needed to build pgBackRest since the only thing that read yaml was the C generator. It is dropped from the development container and from every CI platform, and meson no longer looks for it. Code generation needs nothing compiled first either, so a test run no longer builds a generator before it can select the tests.

The common source list is merged into the pgbackrest source list since it existed only so the generator could be built from the same files, and the entries that were out of alphabetical order are put in order while the list is being rewritten anyway.

Every file the generator writes is byte identical to what the C wrote, for the project declarations and for the documentation's own. The documentation is checked the same way: the HTML rendered on this branch is byte identical to the HTML rendered on integration, every file of it, which exercises the whole pipeline on the real content rather than only the four files the tool writes.

Every module in both libraries is declared in define.yaml and has full line and branch coverage. The coverage list is keyed by file rather than by module name, since the same name in two languages is two files and one of them would have silently lost its requirement while both existed.

TestError becomes ToolError, since it is the error type for three tools now rather than for the harness alone.
2026-07-30 19:37:43 +07:00

709 lines
29 KiB
Python

"""Parse Configuration Yaml.
Reads the command and option declarations, which are the definition of record for the pgBackRest command line and configuration file.
Most of the work here is inheritance. An option can take another option's command list, allow list, or dependency, and a command entry
in an option can default to what the option itself declares, so what a declaration means depends on what was declared before it. That
is resolved here rather than at render time, so the render sees complete options.
The declaration order is also what the reader sees, so an option can only inherit from an option above it."""
####################################################################################################################################
import copy
import os
from common.error import ToolError, check
from common.storage import file_read
from common.yaml import YamlMap, yaml_bool, yaml_load, yaml_map_empty
# Command roles. A role is a process that carries out part of a command, and main is the process the user started. The order here is
# the order the roles are rendered in.
CMD_ROLE_MAIN = "main"
CMD_ROLE_ASYNC = "async"
CMD_ROLE_LOCAL = "local"
CMD_ROLE_REMOTE = "remote"
CMD_ROLE_LIST = (CMD_ROLE_MAIN, CMD_ROLE_ASYNC, CMD_ROLE_LOCAL, CMD_ROLE_REMOTE)
# Commands that take no options of their own, so they are left out of an option's command list
CMD_HELP = "help"
CMD_VERSION = "version"
# Option types
OPT_TYPE_BOOLEAN = "boolean"
OPT_TYPE_HASH = "hash"
OPT_TYPE_INTEGER = "integer"
OPT_TYPE_LIST = "list"
OPT_TYPE_PATH = "path"
OPT_TYPE_SIZE = "size"
OPT_TYPE_STRING = "string"
OPT_TYPE_STRING_ID = "string-id"
OPT_TYPE_TIME = "time"
# Options that the rest of the build refers to by name
OPT_STANZA = "stanza"
# Sections an option can be configured in. An option in the command-line section cannot be set in the configuration file, which is
# why it can be neither negated nor reset.
SECTION_COMMAND_LINE = "command-line"
# How a default value is rendered
DEFAULT_TYPE_QUOTE = "quote"
DEFAULT_TYPE_LITERAL = "literal"
DEFAULT_TYPE_DYNAMIC = "dynamic"
_DEFAULT_TYPE_LIST = (DEFAULT_TYPE_QUOTE, DEFAULT_TYPE_LITERAL, DEFAULT_TYPE_DYNAMIC)
####################################################################################################################################
class BldCfgCommand:
"""A command."""
def __init__(self, name):
self.name = name
self.internal = False # Is the command internal?
self.log_file = True # Does the command write automatically to a log file?
self.log_level_default = "info" # Default log level
self.lock_required = False # Is a lock required?
self.lock_remote_required = False # Is a remote lock required?
self.lock_type = "none" # Lock type
self.parameter_allowed = False # Are command line parameters allowed?
self.role_list = None # Roles valid for the command
####################################################################################################################################
class BldCfgOptionGroup:
"""An option group, i.e. a set of options that can be indexed, e.g. repo1-path and repo2-path."""
def __init__(self, name):
self.name = name
####################################################################################################################################
class BldCfgOptionValue:
"""A value in an allow list, which may be compiled in only when a feature is."""
def __init__(self, value, condition):
self.value = value
self.condition = condition # Is the value conditionally compiled?
####################################################################################################################################
class BldCfgOptionAllowRangeMap:
"""An allowed range for one value of the option the range maps on."""
def __init__(self, map, min, max):
self.map = map
self.min = min
self.max = max
####################################################################################################################################
class BldCfgOptionAllowRange:
"""An allowed range, either one range or a range per value of another option."""
def __init__(self, min=None, max=None, map_list=None):
self.min = min
self.max = max
self.map_list = map_list
####################################################################################################################################
class BldCfgOptionDefaultMap:
"""A default for one value of the option the default maps on."""
def __init__(self, map, value):
self.map = map
self.value = value
####################################################################################################################################
class BldCfgOptionDefault:
"""A default value, either one value or a value per value of another option."""
def __init__(self, value=None, map_list=None):
self.value = value
self.map_list = map_list
####################################################################################################################################
class BldCfgOptionDepend:
"""A dependency on another option."""
def __init__(self, option, default_value, value_list):
self.option = option # Option the dependency is on
self.default_value = default_value # Value to use when the dependency is not resolved
self.value_list = value_list # Values of the option that resolve the dependency
####################################################################################################################################
class BldCfgOptionDeprecate:
"""A deprecated name for an option."""
def __init__(self, name):
self.name = name
self.indexed = False # Can the deprecation be indexed?
self.unindexed = False # Can the deprecation be unindexed?
####################################################################################################################################
class BldCfgOptionCommand:
"""What an option means for one command."""
def __init__(self, name):
self.name = name
self.internal = None # Is the option internal, or None to use what the option declares?
self.required = None # Is the option required, or None to use what the option declares?
self.sequence = None # Is a sequence added to the StringId, or None to use what the option declares?
self.default_value = None
self.depend = None
self.allow_list = None
self.role_list = None # Roles of the command the option is valid for
####################################################################################################################################
class BldCfgOption:
"""An option."""
def __init__(self, name):
self.name = name
self.type = None # Option type, e.g. integer
self.section = None # Section the option can be configured in
self.bool_like = False # Does the option accept y/n and can be treated as a boolean?
self.internal = False # Is the option internal?
self.beta = False # Is the option beta?
self.required = True # Is the option required?
self.negate = None # Can the option be negated?
self.reset = False # Can the option be reset?
self.sequence = False # Is a sequence added to the StringId?
self.default_type = DEFAULT_TYPE_QUOTE # How the default is rendered
self.default_value = None
self.group = None # Option group, if any
self.secure = False # Does the option contain a secret?
self.depend = None
self.allow_list = None
self.allow_range = None
self.cmd_list = None # What the option means for each command it is valid for
self.cmd_role_list = None # Roles the option is valid for, before they are filtered per command
self.deprecate_list = None
####################################################################################################################################
class BldCfg:
"""The configuration declaration."""
def __init__(self, cmd_list, opt_grp_list, opt_list, opt_resolve_list):
self.cmd_list = cmd_list # Commands, sorted
self.opt_grp_list = opt_grp_list # Option groups, sorted
self.opt_list = opt_list # Options, sorted
self.opt_resolve_list = opt_resolve_list # Options in the order their dependencies can be resolved in
####################################################################################################################################
def _find(item_list, name):
"""Find an entry in a list by name, or None when it is not there."""
for item in item_list:
if item.name == name:
return item
return None
####################################################################################################################################
def _command_role(role_raw, name):
"""Parse a command role list, which is a map of roles with nothing under them."""
result = []
for role, detail in role_raw:
yaml_map_empty(detail, "%s role '%s'" % (name, role))
result.append(role)
return result
####################################################################################################################################
def _command_list(cmd_raw):
"""Parse the command list."""
result = []
for name, detail in cmd_raw:
cmd = BldCfgCommand(name)
for key, value in detail:
if key == "command-role":
cmd.role_list = _command_role(value, "command '%s'" % name)
elif key == "internal":
cmd.internal = yaml_bool(value, "command '%s' internal" % name)
elif key == "lock-type":
cmd.lock_type = value
elif key == "lock-remote-required":
cmd.lock_remote_required = yaml_bool(value, "command '%s' lock-remote-required" % name)
elif key == "lock-required":
cmd.lock_required = yaml_bool(value, "command '%s' lock-required" % name)
elif key == "log-file":
cmd.log_file = yaml_bool(value, "command '%s' log-file" % name)
elif key == "log-level-default":
cmd.log_level_default = value.lower()
elif key == "parameter-allowed":
cmd.parameter_allowed = yaml_bool(value, "command '%s' parameter-allowed" % name)
else:
raise ToolError("unknown command definition '%s'" % key)
# Every command has a main role whether it declares one or not, since main is the process the user started
cmd.role_list = sorted(set(cmd.role_list or []) | {CMD_ROLE_MAIN})
result.append(cmd)
return sorted(result, key=lambda cmd: cmd.name)
####################################################################################################################################
def _option_group_list(opt_grp_raw):
"""Parse the option group list, which is a map of groups with nothing under them."""
result = []
for name, detail in opt_grp_raw:
yaml_map_empty(detail, "option group '%s'" % name)
result.append(BldCfgOptionGroup(name))
return sorted(result, key=lambda opt_grp: opt_grp.name)
####################################################################################################################################
def _allow_list(allow_raw, opt_list, name):
"""Parse an allow list, which is either the list of allowed values or the name of an option to take it from."""
# A scalar names the option the allow list is inherited from
if isinstance(allow_raw, str):
check(opt_list is not None, "allow list for %s cannot be inherited" % name)
opt_inherit = _find(opt_list, allow_raw)
check(opt_inherit is not None, "allow list inherited from option '%s' before it is defined" % allow_raw)
return copy.copy(opt_inherit.allow_list)
result = []
for value in allow_raw:
# A scalar is the value on its own, else a map of the value to the feature it is compiled in with
if isinstance(value, str):
result.append(BldCfgOptionValue(value, None))
else:
check(len(value) == 1, "allow list value for %s must have a single condition" % name)
for item, condition in value:
result.append(BldCfgOptionValue(item, condition))
return result
####################################################################################################################################
def _allow_range(range_raw, name):
"""Parse an allow range, which is either a min/max pair or a min/max pair per value of the option it maps on."""
# A scalar first means the range itself rather than a range per map
if isinstance(range_raw[0], str):
check(len(range_raw) == 2, "allow range for %s must be a min and a max" % name)
return BldCfgOptionAllowRange(min=range_raw[0], max=range_raw[1])
map_list = []
for entry in range_raw:
for map, range in entry:
check(len(range) == 2, "allow range for %s map '%s' must be a min and a max" % (name, map))
map_list.append(BldCfgOptionAllowRangeMap(map, range[0], range[1]))
return BldCfgOptionAllowRange(map_list=map_list)
####################################################################################################################################
def _default(default_raw):
"""Parse a default, which is either the value or a value per value of the option it maps on.
A tilde overrides an inherited default back to no default at all."""
# A scalar is the default itself
if isinstance(default_raw, str):
return None if default_raw == "~" else BldCfgOptionDefault(value=default_raw)
map_list = []
for entry in default_raw:
for map, value in entry:
map_list.append(BldCfgOptionDefaultMap(map, value))
return BldCfgOptionDefault(map_list=map_list)
####################################################################################################################################
def _depend(depend_raw, opt_list):
"""Parse a dependency, which is either the dependency or the name of an option to take it from."""
# A scalar names the option the dependency is inherited from
if isinstance(depend_raw, str):
opt_inherit = _find(opt_list, depend_raw)
if opt_inherit is None:
raise ToolError("dependency inherited from option '%s' before it is defined" % depend_raw)
return opt_inherit.depend
option = None
default_value = None
value_list = None
for key, value in depend_raw:
if key == "list":
value_list = list(value)
elif key == "default":
default_value = value
elif key == "option":
option = value
else:
raise ToolError("unknown depend definition '%s'" % key)
return BldCfgOptionDepend(option, default_value, value_list)
####################################################################################################################################
def _depend_reconcile(opt, depend, opt_list):
"""Resolve a dependency to the option it is on, now that every option exists."""
if depend is None:
return None
if depend.default_value is not None and opt.type not in (OPT_TYPE_BOOLEAN, OPT_TYPE_INTEGER):
raise ToolError("dependency default invalid for non integer/boolean option '%s'" % opt.name)
opt_depend = _find(opt_list, depend.option)
if opt_depend is None:
raise ToolError("dependency on undefined option '%s'" % depend.option)
return BldCfgOptionDepend(opt_depend, depend.default_value, depend.value_list)
####################################################################################################################################
def _deprecate_list(deprecate_raw):
"""Parse the deprecated names of an option.
A question mark in the name means the deprecation may be indexed, e.g. repo?-path covers repo-path and repo1-path, so the same
name can appear twice and each spelling is recorded separately."""
result = []
for name, detail in deprecate_raw:
yaml_map_empty(detail, "deprecate '%s'" % name)
indexed = "?" in name
name = name.replace("?", "", 1)
deprecate = _find(result, name)
if deprecate is None:
deprecate = BldCfgOptionDeprecate(name)
result.append(deprecate)
if indexed:
deprecate.indexed = True
else:
deprecate.unindexed = True
return sorted(result, key=lambda deprecate: deprecate.name)
####################################################################################################################################
def _option_command_list(opt_cmd_raw, cmd_list, opt_list, name):
"""Parse the commands an option is valid for, which is either the list or the name of an option to take it from."""
# A scalar names the option the command list is inherited from
if isinstance(opt_cmd_raw, str):
opt_inherit = _find(opt_list, opt_cmd_raw)
check(opt_inherit is not None, "command list inherited from option '%s' before it is defined" % opt_cmd_raw)
return opt_inherit.cmd_list
result = []
for opt_cmd, detail in opt_cmd_raw:
# Add every command of another option, stripped of what that option declared about them
if opt_cmd == "+inherit":
opt_inherit = _find(opt_list, detail)
check(opt_inherit is not None, "commands inherited from option '%s' before it is defined" % detail)
for cmd in opt_inherit.cmd_list:
result.append(BldCfgOptionCommand(cmd.name))
# Add every command that has a role, or any role
elif opt_cmd == "+role":
for cmd in cmd_list:
# Help and version take no options of their own
if cmd.name in (CMD_HELP, CMD_VERSION):
continue
for role in cmd.role_list:
if (detail == "any" or role == detail) and _find(result, cmd.name) is None:
result.append(BldCfgOptionCommand(cmd.name))
# Remove a command, which is how a command is trimmed from what +inherit or +role added
elif opt_cmd == "-command":
cmd = _find(result, detail)
if cmd is not None:
result.remove(cmd)
# Else the command is declared with what it overrides
else:
cmd = BldCfgOptionCommand(opt_cmd)
for key, value in detail:
if key == "allow-list":
cmd.allow_list = _allow_list(value, None, "%s command '%s'" % (name, opt_cmd))
elif key == "command-role":
cmd.role_list = _command_role(value, "%s command '%s'" % (name, opt_cmd))
elif key == "depend":
cmd.depend = _depend(value, opt_list)
elif key == "default":
cmd.default_value = _default(value)
elif key == "internal":
cmd.internal = yaml_bool(value, "%s command '%s' internal" % (name, opt_cmd))
elif key == "required":
cmd.required = yaml_bool(value, "%s command '%s' required" % (name, opt_cmd))
elif key == "sequence":
cmd.sequence = yaml_bool(value, "%s command '%s' sequence" % (name, opt_cmd))
else:
raise ToolError("unknown option command definition '%s'" % key)
# A declaration replaces whatever +inherit or +role added for the command
cmd_prior = _find(result, opt_cmd)
if cmd_prior is not None:
result.remove(cmd_prior)
result.append(cmd)
return sorted(result, key=lambda cmd: cmd.name)
####################################################################################################################################
def _option_list_raw(opt_raw, cmd_list, opt_grp_list):
"""Parse the options as declared, before any of them are resolved against each other."""
result = []
for name, detail in opt_raw:
opt = BldCfgOption(name)
inherit_found = False
for key, value in detail:
if key == "allow-list":
opt.allow_list = _allow_list(value, result, "option '%s'" % name)
elif key == "allow-range":
opt.allow_range = _allow_range(value, "option '%s'" % name)
elif key == "command":
opt.cmd_list = _option_command_list(value, cmd_list, result, "option '%s'" % name)
elif key == "command-role":
opt.cmd_role_list = _command_role(value, "option '%s'" % name)
elif key == "default":
opt.default_value = _default(value)
elif key == "depend":
opt.depend = _depend(value, result)
elif key == "deprecate":
opt.deprecate_list = _deprecate_list(value)
elif key == "default-type":
check(value in _DEFAULT_TYPE_LIST, "option '%s' has invalid default type '%s'" % (name, value))
opt.default_type = value
elif key == "group":
check(_find(opt_grp_list, value) is not None, "option '%s' has invalid group '%s'" % (name, value))
opt.group = value
elif key == "inherit":
opt_inherit = _find(result, value)
check(opt_inherit is not None, "option '%s' inherited before it is defined" % value)
# Everything declared before inherit is replaced and everything after it overrides, which is why inherit is written
# first. Deprecations are not inherited since they name the option they are a deprecation of.
opt = copy.copy(opt_inherit)
opt.name = name
opt.deprecate_list = None
inherit_found = True
elif key == "internal":
opt.internal = yaml_bool(value, "option '%s' internal" % name)
elif key == "bool-like":
opt.bool_like = yaml_bool(value, "option '%s' bool-like" % name)
elif key == "beta":
opt.beta = yaml_bool(value, "option '%s' beta" % name)
elif key == "negate":
opt.negate = yaml_bool(value, "option '%s' negate" % name)
elif key == "sequence":
opt.sequence = yaml_bool(value, "option '%s' sequence" % name)
elif key == "required":
opt.required = yaml_bool(value, "option '%s' required" % name)
elif key == "section":
opt.section = value
elif key == "secure":
opt.secure = yaml_bool(value, "option '%s' secure" % name)
elif key == "type":
opt.type = value
else:
raise ToolError("unknown option definition '%s'" % key)
if opt.type is None:
raise ToolError("option '%s' requires 'type'" % name)
# An inherited option already has these set from the option it inherited from
if not inherit_found:
# An option that does not say where it can be configured can only be given on the command line
if opt.section is None:
opt.section = SECTION_COMMAND_LINE
# Only an option that can be written in the configuration file can be negated there
if opt.negate is None:
opt.negate = (opt.type == OPT_TYPE_BOOLEAN or opt.bool_like) and opt.section != SECTION_COMMAND_LINE
# An option that does not name its commands is valid for all of them
if opt.cmd_list is None:
opt.cmd_list = sorted(
(BldCfgOptionCommand(cmd.name) for cmd in cmd_list if cmd.name not in (CMD_HELP, CMD_VERSION)),
key=lambda cmd: cmd.name,
)
# Only an option that can be written in the configuration file can be reset, which is what removes it again
opt.reset = opt.section != SECTION_COMMAND_LINE
result.append(opt)
return sorted(result, key=lambda opt: opt.name)
####################################################################################################################################
def _option_list(opt_raw, cmd_list, opt_grp_list):
"""Parse the option list and resolve every option against the others."""
opt_list_raw = _option_list_raw(opt_raw, cmd_list, opt_grp_list)
# Copy the options so a dependency can point at the option it is on rather than at the name of one
result = []
for opt_raw in opt_list_raw:
opt = copy.copy(opt_raw)
opt.cmd_list = None
opt.depend = None
result.append(opt)
# Resolve what each option means for each of its commands, which is what it declares for the command falling back to what it
# declares for itself
for idx, opt_raw in enumerate(opt_list_raw):
cmd_opt_list = []
for opt_cmd_raw in opt_raw.cmd_list:
cmd = _find(cmd_list, opt_cmd_raw.name)
if cmd is None:
raise ToolError("invalid command '%s' in option '%s' command list" % (opt_cmd_raw.name, opt_raw.name))
opt_cmd = copy.copy(opt_cmd_raw)
if opt_cmd.required is None:
opt_cmd.required = opt_raw.required
if opt_cmd.internal is None:
opt_cmd.internal = opt_raw.internal
if opt_cmd.sequence is None:
opt_cmd.sequence = opt_raw.sequence
if opt_cmd.role_list is None:
# What the option declares for every command, less the roles this command does not have
if opt_raw.cmd_role_list is not None:
opt_cmd.role_list = [role for role in opt_raw.cmd_role_list if role in cmd.role_list]
else:
opt_cmd.role_list = cmd.role_list
opt_cmd.depend = _depend_reconcile(opt_raw, opt_cmd_raw.depend, result)
cmd_opt_list.append(opt_cmd)
result[idx].cmd_list = cmd_opt_list
result[idx].depend = _depend_reconcile(opt_raw, opt_raw.depend, result)
return result
####################################################################################################################################
def _option_resolve_list(opt_list):
"""Order the options so that every option comes after the options it depends on.
The stanza option is resolved first since an error about a missing option is confusing when the stanza it would apply to is what is
actually missing, so stanza must exist and may not depend on anything."""
opt_stanza = _find(opt_list, OPT_STANZA)
if opt_stanza is None:
raise ToolError("option '%s' must exist" % OPT_STANZA)
if opt_stanza.depend is not None:
raise ToolError("option '%s' may not depend on other option" % OPT_STANZA)
for opt_cmd in opt_stanza.cmd_list:
if opt_cmd.depend is not None:
raise ToolError("option '%s' command '%s' may not depend on other option" % (OPT_STANZA, opt_cmd.name))
resolve_list = [OPT_STANZA]
while len(resolve_list) != len(opt_list):
resolved = False
for opt in opt_list:
if opt.name in resolve_list:
continue
if opt.depend is not None and opt.depend.option.name not in resolve_list:
continue
if any(cmd.depend is not None and cmd.depend.option.name not in resolve_list for cmd in opt.cmd_list):
continue
resolve_list.append(opt.name)
resolved = True
# Nothing resolved means the options left depend on each other, since every other reason to skip is already excluded
if not resolved:
raise ToolError(
"unable to resolve dependencies for option(s) '%s'\nHINT: are there circular dependencies?"
% ", ".join(opt.name for opt in opt_list if opt.name not in resolve_list)
)
return [_find(opt_list, name) for name in resolve_list]
####################################################################################################################################
def bld_cfg_parse(path_repo):
"""Parse config.yaml into the configuration declaration."""
path = os.path.join(path_repo, "build/config.yaml")
cfg_raw = yaml_load(file_read(path), path)
cmd_raw = None
opt_grp_raw = None
opt_raw = None
for key, value in cfg_raw:
if key == "command":
cmd_raw = value
elif key == "optionGroup":
opt_grp_raw = value
elif key == "option":
opt_raw = value
else:
raise ToolError("unknown config definition '%s'" % key)
check(cmd_raw is not None and opt_grp_raw is not None and opt_raw is not None, "command, optionGroup, and option are required")
cmd_list = _command_list(cmd_raw)
opt_grp_list = _option_group_list(opt_grp_raw)
opt_list = _option_list(opt_raw, cmd_list, opt_grp_list)
return BldCfg(cmd_list, opt_grp_list, opt_list, _option_resolve_list(opt_list))