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:
parent
720dfa7b09
commit
2757cd8e96
6 changed files with 247 additions and 69 deletions
226
over/app.py
226
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 <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 = ""
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue