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
This commit is contained in:
Martinez 2016-05-17 16:48:58 +02:00
parent 720dfa7b09
commit 2757cd8e96
6 changed files with 247 additions and 69 deletions

View file

@ -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 <c>%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 <c>%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("<R>uncontained exception<.> <Y>%s<.> encountered" %(exception_type.__name__), self.print.tl.fail, end=":\n")
self.print("<R>uncontained exception<.> <Y>%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 = ": <R>%s<.>" %(exception.description)
elif exception.args:
reason = ": <R>%s<.>" %(" ".join(exception.args))
reason = ": <R>%s<.>" %(" ".join(str(a) for a in exception.args))
else:
reason = ""