New features (closes #6):

- config file selection (---config PATH)
- dump state as executable command (---dump)
- over library desciption (---help)
- finished automatic --help view

Fixes
- callbacks are called after the application's state is determined (closes #9)
- many small fixes
This commit is contained in:
Martinez 2016-05-18 00:40:42 +02:00
parent 5b983a3131
commit ed947bf8e6
5 changed files with 187 additions and 49 deletions

View file

@ -40,9 +40,7 @@ class UnknownOption(Exception):
This option was mentioned in code, the config file or on the command line but is not known.
"""
@property
def description(self):
return "--%s" %(self.args[0])
pass
class ReadingUnsetOption(Exception):
"""
@ -120,14 +118,16 @@ class Option:
else:
self.is_boolean = is_boolean
self.reset() # sets self.source and self._value_list
self.reset(True) # sets self.source and self._value_lists
if self.default != Option_sources.none:
self.set_value(self.default, Option_sources.default)
def reset(self):
def reset(self, default=False):
self.source = Option_sources.none
self._value_list = []
self._raw_value_list = []
if default:
if self.default != Option_sources.none:
self.set_value(self.default, Option_sources.default)
def set_value(self, raw_value, source):
"""
@ -157,8 +157,10 @@ class Option:
if self.overwrite:
self._value_list = [value]
self._raw_value_list = [raw_value]
else:
self._value_list.append(value)
self._raw_value_list.append(raw_value)
@property
def value(self):
@ -255,6 +257,18 @@ def serialize_default(option):
# --------------------------------------------------
def serialize_source(source):
if source == Option_sources.none:
return "<R>None<.>"
elif source == Option_sources.default:
return "default"
elif source == Option_sources.config_file:
return "<y>config file<.>"
elif source == Option_sources.command_line:
return "<Y>command line<.>"
# --------------------------------------------------
class ConfigFile:
"""
Config file object. Takes a {name: Option} dictionary and a file path, and:
@ -263,10 +277,10 @@ class ConfigFile:
- missing options are appended to the file
"""
def __init__(self, options, app_name, app_version):
def __init__(self, options, app_name, app_version, force_path=None):
self.options = options
self.dir = get_xdg_paths(app_name)["config"]
self.path = os.path.join(self.dir, "main.cfg")
self.path = force_path or os.path.join(self.dir, "main.cfg")
self.print = text.Output(app_name + ".ConfigFile")
self.seen_hashes = set()
@ -319,7 +333,7 @@ class ConfigFile:
# add new or otherwise missing options
with open(self.path, "a") as f:
for option in self.options.values():
if option.hash not in self.seen_hashes:
if option.hash not in self.seen_hashes and option.in_cfg_file:
self.print("adding <W>--<G>%s<.> to config file" %(option.name))
f.write(docs.config_file_item %(
@ -363,10 +377,13 @@ class Main:
self.print = text.Output(name)
self.options = OrderedDict()
self.options_by_abbr = OrderedDict()
self.docs = OrderedDict()
self.cfg = OptionRouter(self.options)
self.targets = []
self.invocation = cmd.format_invocation(sys.argv)
self.features = types.ndict()
self.last_command_line = None
self.using_alternate_config = False
for feature_name in self.default_features:
if feature_name in features:
@ -391,6 +408,23 @@ class Main:
sys.exit(rv)
def load_alternate_config(self, path):
"""
Resets the option's internal state and loads a different config file.
@while loading alternate config file
"""
if self.using_alternate_config:
return
else:
self.using_alternate_config = True
for option in self.options.values():
option.reset(True)
self.parse(self.last_command_line, path)
def add_option(self, *args, **kwargs):
"""
@while registering a new configuration option
@ -410,8 +444,31 @@ class Main:
self.options_by_abbr[option.abbr] = option
def add_doc(self, chapter, paragraphs):
# TODO
...
self.docs[chapter] = paragraphs
def dump(self):
"""
Prints the application's own configuration state as one long command line.
Skips action verbs.
"""
tokens = [sys.argv[0]] # FIXME breaks encapsulation
for option in self.options.values():
if option.is_boolean and option.count == 0:
if option.value:
tokens.append("<W>--<g>%s<.>" %(option.name))
else:
tokens.append("<R>--no-<g>%s<.>" %(option.name))
elif option.count > 0:
for raw_value in option._raw_value_list:
tokens.append("<W>--<g>%s<.> <M>%s<.>" %(option.name, cmd.format_invocation(str(x) for x in raw_value)))
self.print(" ".join(tokens), format="<t>")
def activate_debug(self):
raise NotImplementedError("debug is not yet here")
def enable_help(self, name="help", abbr="h"):
"""
@ -420,31 +477,98 @@ class Main:
@while adding help commandline options
"""
self.add_option("help", "Display the application configuration view.", self.help, abbr=abbr, in_cfg_file=False)
self.add_option("-help", "Display the library help and about views.", lambda: self.help(docs=docs.over_desc), in_cfg_file=False)
self.add_option(name, "Display the application configuration view.", self.help, abbr=abbr, in_cfg_file=False)
self.add_option("-help", "Display the library help and about views.", lambda: self.help(alternate_docs=docs.over_docs), in_cfg_file=False)
self.add_option("-config", "Path to an alternate config file.", self.load_alternate_config, count=1, in_cfg_file=False)
self.add_option("-dump", "Show a command line that exactly represents the application's state.", self.dump, in_cfg_file=False)
self.add_option("-debug", "Enable extra debug messages (sets <c>over<.>.<c>app<.>.<c>Main<.>.<y>debug<.> = <M>True<.>) Currently does nothing.", self.activate_debug, in_cfg_file=False)
def help(self, option=None, docs=None):
def help(self, alternate_docs=None):
"""
Displays a help text and exits the program.
If `option` == `docs` == None, full application help and configuration is displayed:
Displays a help text and exits the program:
- application name, version and license name
- application description and other documentation supplied with `Main.add_doc`
- a list of application options with their descriptions and current values
- a list of currently specified targets.
If `option` is set to an option's name, only that option will be described.
If `docs` is set to something else, only those docs will be displayed.
@while displaying help
"""
self.print("TODO called for help!")
print = text.Output("help", format="<t>", end="\n")
def parse_config(self):
# App name and version
print("[<W>Application<.>]")
print(" <W>%s<.>-<c>%s<.> licensed under the <W>%s<.>" %(self.name, self.version, self.license))
print(" using over-%s" %(version.str))
# App docs
print("")
for chapter, paragraphs in (alternate_docs or self.docs).items():
print("[<W>%s<.>]" %(chapter))
for paragraph in paragraphs:
print(paragraph, format=" <i><t>")
print("")
# App options
if not alternate_docs:
print("[<W>Options<.>]")
for option in self.options.values():
if option.show_in_help:
# option name and type
full_names = ["<W>--<g>%s<.>" %(option.name)]
abbr_names = []
if option.abbr:
abbr_names.append("<W>+<G>%s<.>" %(option.abbr))
if option.is_boolean and option.count == 0:
full_names.append("<R>--no-<g>%s<.>" %(option.name))
if option.abbr:
abbr_names.append("<R>-<G>%s<.>" %(option.abbr))
line_parts = [" " + " or ".join(full_names)]
if abbr_names:
line_parts.append(" (%s)" %(" or ".join(abbr_names)))
if not (option.count == 0 and not option.is_boolean): # don't display type on actions
line_parts.append("; type <B>%s<.>" %(serialize_callback(option.callback)))
if not option.overwrite:
line_parts.append(", <C>can be used more than once<.>")
print("".join(line_parts))
# Current value
if option.source != Option_sources.none:
for raw_value in option._raw_value_list:
print(" Current value (%s): <M>%s<.>" %(
serialize_source(option.source),
cmd.format_invocation(str(x) for x in raw_value)
))
# some misc flags
if not option.in_cfg_file:
print(" (<c>not in config file<.>)")
# the desc text
print(option.description, format=" <i><t>")
print("")
# Current targets, if any
if self.targets:
print("[<W>Targets<.>]")
print(" <M>%s<.>" %(cmd.format_invocation(self.targets)))
self.exit()
def parse_config(self, force_path=None):
if self.features.config_file:
self.config_file = ConfigFile(self.options, self.name, self.version)
self.config_file = ConfigFile(self.options, self.name, self.version, force_path)
else:
self.config_file = None
@ -468,9 +592,10 @@ class Main:
"""
command_line = command_line or sys.argv[1:]
self.last_command_line = command_line
# placing it here ensures --help is the last option in the list of options
if self.features.auto_add_help:
if self.features.auto_add_help and "help" not in self.options:
self.enable_help()
last_read_option = None
@ -478,6 +603,7 @@ class Main:
temporary_payload = []
guard_passed = False
invocation = [sys.argv[0]]
scheduled_actions = []
for top_token in command_line:
# if it's a group
@ -511,9 +637,13 @@ class Main:
last_read_option = option
if option.count == 0:
if option.is_boolean:
# single booleans are handled specially because of their --option/--no-option forms
if option.is_boolean and option.count == 0:
option.set_value([option_is_positive], Option_sources.command_line)
else:
# --action verbs just want their callbacks called
scheduled_actions.append(option.callback)
else:
remaining_payload_tokens = option.count
temporary_payload = []
@ -532,12 +662,15 @@ class Main:
self.invocation = cmd.format_invocation(invocation)
def parse(self, command_line=None):
for action in scheduled_actions:
action()
def setup(self, command_line=None, force_config=None):
"""
@while assembling application configuration
@while determining application configuration
"""
self.parse_config()
self.parse_config(force_config)
self.parse_command_line(command_line)
def stack_tracer(self, exception_type, exception, trace):

View file

@ -1,13 +1,23 @@
#! /usr/bin/env python3
# encoding: utf-8
over_desc = [
('over.core: what, how and huh?', ['over.core is a Python 3 module that provides basic functionality for programs. Functionality such as configuration, commandline parsing, text handling and output, file handling, a non-interactive help system and (not all that much) more.']),
('Data Types', ['over.core currently supports 4 data types.', 'A §bbool§/ is either exactly §mTrue§/ or exactly §mFalse§/. Bool options that are §mTrue§/ look like this: §B--§goption§/. The same name only §mFalse§/ would be §B--no-§goption§/. Makes sense, doesn\'t it? Their short versions are either §B+§go§/ for §mTrue§/ or §B-§go§/ for §mFalse§/.', 'A §bstr§/ing is just any raw text. Remember to enclose it in quotes if it has spaces or other nasty characters in it.', 'An §bint§/eger is a whole number. Negative, zero, positive.', 'Finally, a §bfloat§/ is any real number.']),
('The Commandline Parser', ['This, in conjunction with the configuration system, is the strongest part of over.core and the very reason for its continued existence.', 'Each configurable option can be assigned to on the command line. Take an option named §Bres-file§/ as an example. To assign to it, you can use §B--§gres-file§/ §msomething§/ (it\'s case sensitive). That\'s it, now its value is §msomething§/! Pretty easy, right?', 'Now, you\'re probably thinking: \'I\'m not typing that all over again!\' Well, you don\'t have to! Options can have their short names. It\'s a single letter (again, case sensitive) with a plus or minus sign in front of it. So §B--§gres-file§/ §msomething§/ becomes §B+§gf§/ §msomething§/. That\'s much better, ain\'t it? And there\'s more. Short names can be grouped together. If you have a bunch of bool switches, like §B--§garmed§/ (short: §B+§gA§/), §B--no-§gsound§/ (short: §B-§gS§/), §B--no-§gstore§/ (short: §B-§gs§/) and §B--§gforce§/ (short: §B+§gF§/), you can group their shorts into groups with the same boolean value: §B-§gSs§/ §B+§gAF§/. You can even have a non-bool option in a group, it just has to be on the very right (because it needs to be followed by data): §B-§gSs§/ §B+§gAFf§/ §msomething§/. It doesn\'t matter if that group begins with §B+§/ or §B-§/.', 'If you use an option more than once, its last (rightmost) occurence applies. For example, after §B+§gf§/ §msomething§/ §B+§gf§/ §mor_other§/ is parsed, option §Bres-file§/ holds the value §mor_other§/. It goes the same for bools: after §B+§gA§/ §B--no-§garmed§/ §B+§gA§/, §Barmed§/ is §mTrue§/. However, if an option is §cplural§/, all occurences are used. Sequence §B+§gA§/ §B-§gA§/ §B+§gA§/ would be [§mTrue§/, §mFalse§/, §mTrue§/], and §B--§gnum§/ §m1§/ §B--§gnum§/ §m2§/ would end up looking like [§m1§/, §m2§/]. You don\'t need to type §B--§gnum§/ for every field either: §B--§gnum§/ §m1§/ §m2§/ would work exactly the same. That\'s because the parser keeps reading everything after a plural option (that takes data, i.e. not bools) right until it encounters two dashes, like those of a following option. You can use just the two dashes to stop the parsing manually, usually when you don\'t want to follow with a long option. Example: §B--§gnum§/ §m1§/ §m5§/ §m9§/ §B--§/. I repeat: a plural (non-bool) option needs to be terminated by two dashes. This wouldn\'t work: §B--§gnum§/ §m1§/ §m5§/ §m9§/ §B+§gf§/ §mthat_one§/, everything after §B--§gnum§/ would be consumed as its data, §Bincluding +§gf§/ §mthat_one§/.']),
('The Config File', ['If enabled in the program code, a config file will be generated when you first run it. By default, everything in the config file will be filled with default values and commented out. The general syntax as well as individual options are explained inside. If you run a newer version of the program that offers more configurable options, the config file will be automatically updated. If you\'d like to modify an option in the config file, first uncomment it and then change its value. Resolving order for option values is 1) default (hardcoded), 2) config file and 3) commandline arguments.']),
('Other', ['When enabled in the program, an over.core program offers two options: §B--§ghelp§/ and §B--§gover-help§/. The first describes the program and its options, including their types, current values and whether is their current value coming from defaults, config files or the command line. The second option displays the help you\'re reading right now. You may now guess which rank I hold in the Obvious Corps.', 'As the brighter amongst you might have noticed, over.core likes colors. A lot. Generally, I use blue for §bdata types§/, magenta for §mvalues§/, white and green for §B--§goptions§/ and reserve red and yellow for when §rshit hits§/ §ythe fan§/, where red letters usually tell you §Bwhy§/ and yellow ones tell you §Bwhat§/.', 'And it\'s not just colors! I like money, too. Push donations to §B1sekErApM4zh35RFW7qGWs5Yeo9EYWjyV§/, or catch me somewhere and force me to accept cash.'])
from collections import OrderedDict
over_docs = OrderedDict()
over_docs["What"] = ["<G>over<.> is a Python 3 library that attempts to provide essential and convenient application functionality. Configuration, command line parsing, text handling and output, a non-interactive help system, human-readable exception processing, subprocess execution and many more are available."]
over_docs["Why"] = ["It started with a need for a more powerful command line interface than what <c>argparse<.> can provide. Things snowballed on and several generations later..."]
over_docs["Options"] = [
"At the core of over is the command line parser (<c>over<.>.<c>app<.>.<c>Main<.>.<y>parse_command_line<.>). There are 6 basic design principles:",
'Options accept one or more words as arguments, e.g. <W>--<g>tag<.> <M>ARTIST "Within Temptation"<.> accepts two, <W>--<g>age<.> <M>31<.> accepts one.',
"Boolean options can accept 0 words - then they become flags (switches) and are toggled directly by their names: <W>--<g>enabled<.> evaluates to <M>True<.>, <R>--no-<g>enabled<.> to <M>False<.>.",
"Other flags (options that accept 0 words) directly trigger callbacks. Those are usually referred to as actions. A good example is <W>--<g>help<.>.",
"Options can be referred to by their abbreviated, single character names, if defined by the application. Abbreviated names are interchangeable with full names. Boolean flags get shortened from <W>--<g>enabled<.> to <W>+<G>E<.>, and <R>--no-<g>enabled<.> to <R>-<G>E<.>.",
"Multiple abbreviated options can be grouped together, with one condition: all except the last (rightmost) option must be flags or actions. For example <R>-<G>Exp<.> <M>primary<.> is the same as <R>--no-<g>enabled<.> <R>--no-<g>execute<.> <W>--<g>pool<.> <M>primary<.> or <W>+<G>Nv<.> <M>drop<.> expands to <W>--<g>normalize<.> <W>--<g>video<.> <M>drop<.>. Notice how the entire group takes on the boolean value of the leading <R>+<.> or <W>-<.>.",
'If an option is overwriting (<c>over<.>.<c>app<.>.<c>Option<.>.<y>overwrite<.> = <M>True<.>) the latest (rightmost) instance of the same option overwrites all previous ones. Otherwise, all instances are used. For example, <W>+<G>v<.> <M>drop<.> <W>+<G>v<.> <M>copy<.> <W>+<G>v<.> <M>x264<.> evaluates to <M>["x264"]<.> if the option is defined as overwriting, whereas if it was not it would evaluate to <M>["drop", "copy", "x264"]<.>.'
]
over_docs["Configuration sources"] = ["Each option can take its state either from (in order) the application default value, the config file, or the command line. Combining multiple sources (e.g. extending values in the config file with the command line) is not permitted - a new source always resets the option's state. It is possible for an option to have no value. Accessing it during runtime will generate an exception."]
over_docs["Config File"] = ["If enabled by the application, a config file will be generated when first executed. The config file will be populated with all known options, their descriptions and some instructions on how to proceed. If a newer version of the application that offers more configurable options is executed, the config file will be automatically updated."]
config_file_header = """# Configuration file for %s-%s
# Generated by over-%s

View file

@ -320,7 +320,7 @@ class Output:
<i> - indentation start marker
"""
def __init__(self, name, format="[%Y-%m-%d %H:%M:%S] <T> -- <n>, <i><t>", colors=True, end=".\n", indent=True, stream=sys.stderr):
def __init__(self, name, format="[%Y-%m-%d %H:%M:%S] <@> -- <n>, <i><t>", colors=True, end=".\n", indent=True, stream=sys.stderr):
self.name = name
self.format = format
self.colors = colors and stream.isatty()
@ -339,7 +339,7 @@ class Output:
end = end or self.end
output = datetime.datetime.now().strftime(format)
output = output.replace("<T>", tag)
output = output.replace("<@>", tag)
output = output.replace("<n>", self.name)
if self.indent and "<i>" in format:

View file

@ -4,5 +4,5 @@
major = 1 # VERSION_MAJOR_IDENTIFIER
minor = 99 # VERSION_MINOR_IDENTIFIER
# VERSION_LAST_MM 1.99
patch = 2 # VERSION_PATCH_IDENTIFIER
patch = 3 # VERSION_PATCH_IDENTIFIER
str = ".".join(str(v) for v in (major, minor, patch))

View file

@ -37,10 +37,5 @@ if __name__ == "__main__":
main.add_option("int-quad", "", int4, [45, 72, 97, 18], abbr="i", count=4)
main.add_option("int-quad-multi", "", int4, [45, 72, 97, 18], abbr="I", count=4, overwrite=False)
main.add_doc("Description", ["What it does.", "Another paragraph."])
main.parse()
for option in main.options.values():
if option.name not in ["help", "-help"]:
main.print("option <g>%s<.> = %s" %(option.name, option.value))
main.print("<m>targets<.>: %s" %(main.targets))
main.add_doc("Moar", ["When we launched Raspberry Pi Zero last November, its fair to say we were <W>blindsided<.> by the level of demand. We immediately sold every copy of MagPi issue 40 and every Zero in stock at our distributors; and every time a new batch of Zeros came through from the factory theyd sell out in minutes. To complicate matters, Zero then had to compete for factory space with Raspberry Pi 3, which was ramping for launch at the end of February.", "To connect the camera to the Zero, we offer a custom six-inch adapter cable. This converts from the fine-pitch connector format to the coarser pitch used by the camera board. Liz has a great picture of Mooncake, the official Raspberry Pi cat, attempting to eat the camera cable. She wont let me use it in this post so that you arent distracted from the pictures of the new Zero itself. Ive a feeling shell be tweeting it later today."])
main.setup()