From ed947bf8e60746d0e989a973bc5bcb9920f8efe9 Mon Sep 17 00:00:00 2001 From: Martinez Date: Wed, 18 May 2016 00:40:42 +0200 Subject: [PATCH] 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 --- over/app.py | 199 ++++++++++++++++++++++++++++++++++++++++-------- over/docs.py | 22 ++++-- over/text.py | 4 +- over/version.py | 2 +- test.py | 9 +-- 5 files changed, 187 insertions(+), 49 deletions(-) diff --git a/over/app.py b/over/app.py index bd7d89d..43a0fef 100644 --- a/over/app.py +++ b/over/app.py @@ -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 - - if self.default != Option_sources.none: - self.set_value(self.default, Option_sources.default) + self.reset(True) # sets self.source and self._value_lists - 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 "None<.>" + elif source == Option_sources.default: + return "default" + elif source == Option_sources.config_file: + return "config file<.>" + elif source == Option_sources.command_line: + return "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 --%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("--%s<.>" %(option.name)) + else: + tokens.append("--no-%s<.>" %(option.name)) + elif option.count > 0: + for raw_value in option._raw_value_list: + tokens.append("--%s<.> %s<.>" %(option.name, cmd.format_invocation(str(x) for x in raw_value))) + + self.print(" ".join(tokens), format="") + + 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 over<.>.app<.>.Main<.>.debug<.> = 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="", end="\n") + + # App name and version + print("[Application<.>]") + print(" %s<.>-%s<.> licensed under the %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("[%s<.>]" %(chapter)) + + for paragraph in paragraphs: + print(paragraph, format=" ") + print("") + + # App options + if not alternate_docs: + print("[Options<.>]") + + for option in self.options.values(): + if option.show_in_help: + # option name and type + full_names = ["--%s<.>" %(option.name)] + abbr_names = [] + + if option.abbr: + abbr_names.append("+%s<.>" %(option.abbr)) + + if option.is_boolean and option.count == 0: + full_names.append("--no-%s<.>" %(option.name)) + + if option.abbr: + abbr_names.append("-%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 %s<.>" %(serialize_callback(option.callback))) + + if not option.overwrite: + line_parts.append(", 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): %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(" (not in config file<.>)") + + # the desc text + print(option.description, format=" ") + + print("") + + # Current targets, if any + if self.targets: + print("[Targets<.>]") + print(" %s<.>" %(cmd.format_invocation(self.targets))) + + self.exit() - def parse_config(self): + 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 - # 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) + if option.count == 0: + if option.is_boolean: + # single booleans are handled specially because of their --option/--no-option forms + 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 = [] @@ -531,13 +661,16 @@ class Main: raise IncompleteArguments(last_read_option.name, last_read_option.count, remaining_payload_tokens) self.invocation = cmd.format_invocation(invocation) + + for action in scheduled_actions: + action() - def parse(self, command_line=None): + 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): diff --git a/over/docs.py b/over/docs.py index 4daa88c..8caacb5 100644 --- a/over/docs.py +++ b/over/docs.py @@ -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"] = ["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 argparse<.> can provide. Things snowballed on and several generations later..."] +over_docs["Options"] = [ + "At the core of over is the command line parser (over<.>.app<.>.Main<.>.parse_command_line<.>). There are 6 basic design principles:", + 'Options accept one or more words as arguments, e.g. --tag<.> ARTIST "Within Temptation"<.> accepts two, --age<.> 31<.> accepts one.', + "Boolean options can accept 0 words - then they become flags (switches) and are toggled directly by their names: --enabled<.> evaluates to True<.>, --no-enabled<.> to False<.>.", + "Other flags (options that accept 0 words) directly trigger callbacks. Those are usually referred to as actions. A good example is --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 --enabled<.> to +E<.>, and --no-enabled<.> to -E<.>.", + "Multiple abbreviated options can be grouped together, with one condition: all except the last (rightmost) option must be flags or actions. For example -Exp<.> primary<.> is the same as --no-enabled<.> --no-execute<.> --pool<.> primary<.> or +Nv<.> drop<.> expands to --normalize<.> --video<.> drop<.>. Notice how the entire group takes on the boolean value of the leading +<.> or -<.>.", + 'If an option is overwriting (over<.>.app<.>.Option<.>.overwrite<.> = True<.>) the latest (rightmost) instance of the same option overwrites all previous ones. Otherwise, all instances are used. For example, +v<.> drop<.> +v<.> copy<.> +v<.> x264<.> evaluates to ["x264"]<.> if the option is defined as overwriting, whereas if it was not it would evaluate to ["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 diff --git a/over/text.py b/over/text.py index b8845ec..8a5ee45 100644 --- a/over/text.py +++ b/over/text.py @@ -320,7 +320,7 @@ class Output: - indentation start marker """ - def __init__(self, name, format="[%Y-%m-%d %H:%M:%S] -- , ", colors=True, end=".\n", indent=True, stream=sys.stderr): + def __init__(self, name, format="[%Y-%m-%d %H:%M:%S] <@> -- , ", 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("", tag) + output = output.replace("<@>", tag) output = output.replace("", self.name) if self.indent and "" in format: diff --git a/over/version.py b/over/version.py index 979dac3..04db4c4 100644 --- a/over/version.py +++ b/over/version.py @@ -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)) diff --git a/test.py b/test.py index e2ce076..7f1b38c 100755 --- a/test.py +++ b/test.py @@ -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 %s<.> = %s" %(option.name, option.value)) - - main.print("targets<.>: %s" %(main.targets)) + main.add_doc("Moar", ["When we launched Raspberry Pi Zero last November, it’s fair to say we were 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 they’d 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 won’t let me use it in this post so that you aren’t distracted from the pictures of the new Zero itself. I’ve a feeling she’ll be tweeting it later today."]) + main.setup()