From 2757cd8e969914c0705a3eb2fa0dc1f1e33cb326 Mon Sep 17 00:00:00 2001 From: Martinez Date: Tue, 17 May 2016 16:48:58 +0200 Subject: [PATCH] Major improvements, deduplication and simplification in over.app. - over.app.ConfigFile can create empty config files and read all options from them - over.callback was created to hold commandline parser callbacks - over.app.Option.is_boolean hint added - major improvements and increased robustness of over.app.Main command line parser --- over/__init__.py | 1 + over/app.py | 226 +++++++++++++++++++++++++++++++++++++---------- over/callback.py | 36 ++++++++ over/docs.py | 30 ++++--- over/version.py | 2 +- test.py | 21 ++--- 6 files changed, 247 insertions(+), 69 deletions(-) create mode 100644 over/callback.py diff --git a/over/__init__.py b/over/__init__.py index f17a48f..7329eb5 100644 --- a/over/__init__.py +++ b/over/__init__.py @@ -5,6 +5,7 @@ import sys from . import app from . import aux +from . import callback from . import cmd from . import docs from . import file diff --git a/over/app.py b/over/app.py index bffb935..10b55d4 100644 --- a/over/app.py +++ b/over/app.py @@ -7,13 +7,22 @@ from collections import OrderedDict import enum import sys import re +import os +import shlex + +try: + import xdg.BaseDirectory as xdg_bd +except: + xdg_bd = None # -------------------------------------------------- # Local imports +from . import callback as callback_module from . import cmd from . import docs from . import text from . import types +from . import version # -------------------------------------------------- # Exceptions @@ -41,6 +50,13 @@ class ReadingUnsetOption(Exception): pass +class UnknownFeature(Exception): + """ + This requested feature is not know to this version of over. + """ + + pass + class OptionNameUsed(Exception): """ This option's name or abbreviation is already used by a previously defined option. @@ -48,26 +64,38 @@ class OptionNameUsed(Exception): pass +class OptionSyntaxError(Exception): + """ + This Option rejected the value passed to it. + """ + + pass + class IncompleteArguments(Exception): @property def description(self): option_name, option_count, option_remaining = self.args + received = option_count - option_remaining - if option_remaining == option_count: - return "option --%s takes a %d-word argument but has received none" %(option_name, option_count) - else: - return "option --%s takes a %d-word argument but has only received %d" %(option_name, option_count, option_count - option_remaining) + return "option --%s takes a %d-word argument but has received %s" %(option_name, option_count, received or "none") + +class NotAList(Exception): + """ + Option defaults must be in a list. + """ + + pass # -------------------------------------------------- class Option_sources(enum.Enum): none = 0 default = 1 - config = 2 - cmdline = 3 + config_file = 2 + command_line = 3 class Option: - def __init__(self, name, description, callback, default=Option_sources.none, count=0, overwrite=True, abbr=None, in_cfg_file=True, show_in_help=True): + def __init__(self, name, description, callback, default=Option_sources.none, count=0, overwrite=True, abbr=None, in_cfg_file=True, show_in_help=True, is_boolean=None): self.name = name self.description = description self.callback = callback @@ -78,17 +106,21 @@ class Option: self.in_cfg_file = in_cfg_file self.show_in_help = show_in_help - self.reset(True) - - def reset(self, default=False): - if not default or self.default == Option_sources.none: - self.source = Option_sources.none - self._value = None if self.overwrite else [] + if is_boolean is None: + self.is_boolean = callback in (bool, callback_module.boolean, callback_module.booleans) else: - self.source = Option_sources.default - self._value = self.default + 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) - def set_value(self, raw_value, source, skip_callback=False): + def reset(self): + self.source = Option_sources.none + self._value_list = [] + + def set_value(self, raw_value, source): """ Adds a value to this option's state. If the `source` is different from `self.source`, resets the state. @@ -100,26 +132,38 @@ class Option: self.source = source - if skip_callback: - value = raw_value - else: + if type(raw_value) not in (tuple, list): + raise NotAList(self.name, raw_value) + + if self.count > 0: # bools have count = 0 yet they still get one arg + l = len(raw_value) + + if l != self.count: + raise IncompleteArguments(self.name, self.count, self.count - l) + + try: value = self.callback(*raw_value) + except ValueError: + raise OptionSyntaxError(self.name, repr(raw_value)) if self.overwrite: - self._value = value + self._value_list = [value] else: - self._value.append(value) + self._value_list.append(value) @property def value(self): - if self.source == Option_sources.none: + if not self._value_list: raise ReadingUnsetOption("option --%s has no value" %(self.name)) else: - return self._value + if self.overwrite: + return self._value_list[0] + else: + return self._value_list # -------------------------------------------------- -class ConfigRouter: +class OptionRouter: def __init__(self, options): self.options = options @@ -152,7 +196,7 @@ def expand_group(token, options): except KeyError: raise UnknownAbbreviation(abbr) - if option.callback == bool: + if option.is_boolean: output.append("--%s%s" %("" if group_positive else "no-", option.name)) else: output.append("--%s" %(option.name)) @@ -161,9 +205,96 @@ def expand_group(token, options): # -------------------------------------------------- +def get_xdg_paths(appname): + """ + Returns absolute directory paths {"data": ..., "config": ...} for application data and config storage. + """ + + if xdg_bd: + xdg_data = xdg_bd.xdg_data_home + xdg_config = xdg_bd.xdg_config_home + else: + xdg_data = os.path.expanduser('~/.local/share') + xdg_config = os.path.expanduser('~/.config') + + return { + "data": os.path.join(xdg_data, appname), + "config": os.path.join(xdg_config, appname) + } + +# -------------------------------------------------- + +class ConfigFile: + """ + Config file object. Takes a {name: Option} dictionary and a file path, and: + - opens the file, loads all option values in it into Options + - unknown Options present in the file generate exceptions + - missing options are appended to the file + """ + + def __init__(self, options, app_name, app_version): + self.options = options + self.dir = get_xdg_paths(app_name)["config"] + self.path = os.path.join(self.dir, "main.cfg") + self.print = text.Output(app_name + ".ConfigFile") + + self.read_config() + self.sync_config(app_name, app_version) + + def read_config(self): + """ + @while reading the config file + """ + + if os.path.exists(self.path): + # read all options in the file + with open(self.path) as f: + for line in f: + line = line.strip() + + if line and line[0] != "#": # ignore comments and empty lines + L, R = (t.strip() for t in line.split("=")) + + try: + option = self.options[L] + except KeyError: + raise UnknownOption(L) + + if option.count > 1: + args = shlex.split(R) + else: + args = [R] + + option.set_value(args, Option_sources.config_file) + + def sync_config(self, app_name, app_version): + """ + @while updating the config file with new options + """ + + # create the config dir + if not os.path.exists(self.dir): + self.print("created config directory %s<.>" %(self.dir)) + os.mkdir(self.dir) + + # if the file doesn't exist, create it with a boilerplate header + if not os.path.exists(self.path): + with open(self.path, "w") as f: + f.write(docs.config_file_header %(app_name, app_version, version.str)) + self.print("created empty config file %s<.>" %(self.path)) + + # add new or otherwise missing options + with open(self.path, "a") as f: + ... + + def __repr__(self): + return "ConfigFile(%s)" %(self.path) + +# -------------------------------------------------- + class Main: default_features = { - "use_cfg_file": False, + "config_file": False, "auto_add_help": True, "handle_exceptions": True } @@ -185,7 +316,7 @@ class Main: self.print = text.Output(name) self.options = OrderedDict() self.options_by_abbr = OrderedDict() - self.cfg = ConfigRouter(self.options) + self.cfg = OptionRouter(self.options) self.targets = [] self.invocation = cmd.format_invocation(sys.argv) self.features = types.ndict() @@ -196,9 +327,9 @@ class Main: else: self.features[feature_name] = self.default_features[feature_name] - if self.features.use_cfg_file: - # TODO ensure it exists, update it with new values, etc. - ... + for feature_name in features: + if feature_name not in self.default_features: + raise UnknownFeature(feature_name) if self.features.handle_exceptions: sys.excepthook = self.stack_tracer @@ -265,10 +396,12 @@ class Main: self.print("TODO called for help!") def parse_config(self): - # TODO - ... + if self.features.config_file: + self.config_file = ConfigFile(self.options, self.name, self.version) + else: + self.config_file = None - def parse_cmdline(self, cmdline=None): + def parse_command_line(self, command_line=None): """ Parses arguments on the commandline. @@ -287,7 +420,7 @@ class Main: @while parsing commandline arguments """ - cmdline = cmdline or sys.argv[1:] + command_line = command_line or sys.argv[1:] # placing it here ensures --help is the last option in the list of options if self.features.auto_add_help: @@ -299,8 +432,9 @@ class Main: guard_passed = False invocation = [sys.argv[0]] - for top_token in cmdline: - if not (remaining_payload_tokens or guard_passed) and len(top_token) >= 2 and top_token[0] in "+-" and top_token[1] != "-": # it's a group + for top_token in command_line: + # if it's a group + if not (remaining_payload_tokens or guard_passed) and len(top_token) >= 2 and top_token[0] in "+-" and top_token[1] != "-": expanded = expand_group(top_token, self.options) else: expanded = [top_token] @@ -313,12 +447,12 @@ class Main: guard_passed = True else: option_name = token[2:] - option_is_negative = False + option_is_positive = True if option_name not in self.options: if option_name[:3] == "no-": option_name = option_name[3:] - option_is_negative = True + option_is_positive = False try: option = self.options[option_name] @@ -327,9 +461,9 @@ class Main: last_read_option = option - # booleans are handled specially - if option.callback == bool: - option.set_value(not option_is_negative, Option_sources.cmdline, skip_callback=True) + # 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: remaining_payload_tokens = option.count temporary_payload = [] @@ -339,7 +473,7 @@ class Main: remaining_payload_tokens -= 1 if remaining_payload_tokens == 0: - last_read_option.set_value(temporary_payload, Option_sources.cmdline) + last_read_option.set_value(temporary_payload, Option_sources.command_line) else: self.targets.append(token) @@ -348,13 +482,13 @@ class Main: self.invocation = cmd.format_invocation(invocation) - def parse(self, cmdline=None): + def parse(self, command_line=None): """ @while assembling application configuration """ self.parse_config() - self.parse_cmdline(cmdline) + self.parse_command_line(command_line) def stack_tracer(self, exception_type, exception, trace): """ @@ -363,7 +497,7 @@ class Main: Over Exception handler - prints human readable tracebacks. """ - self.print("uncontained exception<.> %s<.> encountered" %(exception_type.__name__), self.print.tl.fail, end=":\n") + self.print("uncontained exception<.> %s<.> raised" %(exception_type.__name__), self.print.tl.fail, end=":\n") # todo top level program name # todo final level exception name and text @@ -407,7 +541,7 @@ class Main: if hasattr(exception, "description"): reason = ": %s<.>" %(exception.description) elif exception.args: - reason = ": %s<.>" %(" ".join(exception.args)) + reason = ": %s<.>" %(" ".join(str(a) for a in exception.args)) else: reason = "" diff --git a/over/callback.py b/over/callback.py new file mode 100644 index 0000000..521b1b9 --- /dev/null +++ b/over/callback.py @@ -0,0 +1,36 @@ +#! /usr/bin/env python3 +# encoding: utf-8 + +# -------------------------------------------------- + +def boolean(arg): + """ + Converts + - "True", "true" or "1" ⇒ True + - "False", "false" or "0" ⇒ False + + @while converting argument to bool + """ + + if arg in (True, "True", "true", "1"): + return True + elif arg in (False, "False", "false", "0"): + return False + else: + raise ValueError(arg) + +def booleans(*args): + """ + Converts each + - "True", "true" or "1" ⇒ True + - "False", "false" or "0" ⇒ False + + @while converting arguments to bools + """ + + out = [] + + for arg in args: + out.append(boolean(arg)) + + return out diff --git a/over/docs.py b/over/docs.py index b720caf..f6da298 100644 --- a/over/docs.py +++ b/over/docs.py @@ -9,19 +9,25 @@ over_desc = [ ('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.']) ] -config_header = """# Configuration file for %s-%s -# Created using over-%s +config_file_header = """# Configuration file for %s-%s +# Generated by over-%s # -# Syntax -# There are 4 data types: bool, int, float and str (string). -# * Bools are exactly True or False. -# * Ints are negative integers, positive integers and zero. -# * Floats are all real numbers. They don\'t need to have a decimal point. -# * Strings need to be enclosed in double quotes, e.g. 'like this'. +# There are three types of lines: +# - an empty line (contains no non-whitespace characters) +# - a comment (the first non-whitespace character is a #) +# - an assignment (option-name = option-value) # -# An option is either singular or plural. Plurals are arrays, so they ecpect to be -# in comma-separated lists: ['this', 'is', 'a', 'str', 'example']. +# Option names are the literal names used in app setup. # -# Only lines beginning with a \'#\' are treated as comments. +# Option values are the same as on the command line, no +# quoting or escaping is needed unless required for +# multi-argument options with spaces in values. +# +# Non-overwriting options (overwrite = False) work as +# expected as well, simply specify the option more than +# once. +# +# Examples: +# TODO :) -""" %(0, 0, 0) +""" diff --git a/over/version.py b/over/version.py index 428c4f1..64d0952 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 = 0 # VERSION_PATCH_IDENTIFIER +patch = 1 # VERSION_PATCH_IDENTIFIER str = ".".join(str(v) for v in (major, minor, patch)) diff --git a/test.py b/test.py index 5f4a700..6befbbc 100755 --- a/test.py +++ b/test.py @@ -21,20 +21,21 @@ class ConfigurationError(Exception): # -------------------------------------------------- -def tag_callback(tag, value): - return "%s: %s" %(tag, value) +def noop(*args): + return args -def tri_callback(a, b, c): - return "%s, %s and %s" %(a, b, c) +def int4(*args): + return [int(x) for x in args] if __name__ == "__main__": - main = over.app.Main("Over App Test", version.str, "LICENSE") + main = over.app.Main("new-over-test", version.str, "LICENSE", features={"config_file": True}) # name, description, callback, default=Option_sources.none, count=0, overwrite=True, abbr=None, in_cfg_file=True, show_in_help=True - main.add_option("armed", "Description.", bool, True, abbr="A") - main.add_option("verbose", "Description.", bool, True, abbr="v") - main.add_option("read", "Description.", str, ["test"], count=1, overwrite=False, abbr="R") - main.add_option("tag", "Description.", tag_callback, [], count=2, overwrite=False, abbr="t") - main.add_option("tristate", "Description.", tri_callback, [], count=3, overwrite=True) + main.add_option("boolean-single", "", over.callback.boolean, [False], abbr="1") + main.add_option("boolean-triple", "", over.callback.booleans, [False, False, False], abbr="3", count=3) + main.add_option("str-single", "", str, ["kek"], abbr="s", count=1) + main.add_option("str-quad", "", noop, ["ze", "kek", "is", "bek"], abbr="4", count=4) + 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()