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
|
@ -5,6 +5,7 @@ import sys
|
|||
|
||||
from . import app
|
||||
from . import aux
|
||||
from . import callback
|
||||
from . import cmd
|
||||
from . import docs
|
||||
from . import file
|
||||
|
|
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
|
||||
|
||||
def set_value(self, raw_value, source, skip_callback=False):
|
||||
self.reset() # sets self.source and self._value_list
|
||||
|
||||
if self.default != Option_sources.none:
|
||||
self.set_value(self.default, Option_sources.default)
|
||||
|
||||
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
36
over/callback.py
Normal 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
|
30
over/docs.py
30
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)
|
||||
"""
|
||||
|
|
|
@ -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
21
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()
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue