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

@ -5,6 +5,7 @@ import sys
from . import app
from . import aux
from . import callback
from . import cmd
from . import docs
from . import file

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 = ""

36
over/callback.py Normal file
View file

@ -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

View file

@ -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)
"""

View file

@ -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))

21
test.py
View file

@ -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()