diff --git a/over/app.py b/over/app.py index dbd72b1..bd7d89d 100644 --- a/over/app.py +++ b/over/app.py @@ -9,6 +9,7 @@ import sys import re import os import shlex +import hashlib try: import xdg.BaseDirectory as xdg_bd @@ -112,6 +113,7 @@ class Option: self.abbr = abbr self.in_cfg_file = in_cfg_file self.show_in_help = show_in_help + self.hash = hashlib.sha1(name.encode("utf-8")).hexdigest() if is_boolean is None: self.is_boolean = callback in (bool, callback_module.boolean, callback_module.booleans) @@ -231,6 +233,28 @@ def get_xdg_paths(appname): # -------------------------------------------------- +def serialize_callback(f): + return "%s.%s" %(f.__module__, f.__name__) + +# -------------------------------------------------- + +def format_description(description): + colorless = text.render(description, colors=False) + lines = text.paragraph(colorless, 55, 2, join=False) + + # comment them out and assemble + return "\n".join("#" + line[1:] for line in lines) + +# -------------------------------------------------- + +def serialize_default(option): + if option.default is Option_sources.none: + return "" + else: + return cmd.format_invocation(str(x) for x in option.default) + +# -------------------------------------------------- + class ConfigFile: """ Config file object. Takes a {name: Option} dictionary and a file path, and: @@ -244,6 +268,7 @@ class ConfigFile: 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.seen_hashes = set() self.read_config() self.sync_config(app_name, app_version) @@ -259,20 +284,21 @@ class ConfigFile: 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) + if line: + if line[0] == "#": + m = re.findall("# ---- ([0-9a-f]{40}) ----", line) + if m: + self.seen_hashes.add(m[0]) else: - args = [R] - - option.set_value(args, Option_sources.config_file) + L, R = (t.strip() for t in line.split("=")) + + try: + option = self.options[L] + except KeyError: + raise UnknownOption(L) + + args = shlex.split(R) + option.set_value(args, Option_sources.config_file) def sync_config(self, app_name, app_version): """ @@ -292,7 +318,21 @@ class ConfigFile: # add new or otherwise missing options with open(self.path, "a") as f: - ... + for option in self.options.values(): + if option.hash not in self.seen_hashes: + self.print("adding --%s<.> to config file" %(option.name)) + + f.write(docs.config_file_item %( + option.hash, + ("--{0} or --no-{0}" if (option.is_boolean and option.count == 0) else "--{0}").format(option.name), + serialize_callback(option.callback), + option.count or "no", + "" if option.count == 1 else "s", + "the last instance counts" if option.overwrite else "can be specified multiple times", + format_description(option.description), + option.name, + serialize_default(option) + )) def __repr__(self): return "ConfigFile(%s)" %(self.path) diff --git a/over/cmd.py b/over/cmd.py index 5fce1f9..0b12703 100644 --- a/over/cmd.py +++ b/over/cmd.py @@ -19,6 +19,8 @@ def capture_output(stream, fifo): stream.close() +# -------------------------------------------------- + def char_in_str(chars, string): for char in chars: if char in string: @@ -26,8 +28,14 @@ def char_in_str(chars, string): return False +# -------------------------------------------------- + def format_invocation(args): - return " ".join(('"%s"' %(a) if char_in_str(" $()[];\\", a) else a) for a in args) + escaped = (arg.replace('"', '\\"') for arg in args) + + return " ".join(('"%s"' %(a) if char_in_str(' $()[];\\"', a) else a) for a in escaped) + +# -------------------------------------------------- class Command: """ diff --git a/over/docs.py b/over/docs.py index f6da298..4daa88c 100644 --- a/over/docs.py +++ b/over/docs.py @@ -31,3 +31,16 @@ config_file_header = """# Configuration file for %s-%s # TODO :) """ + +config_file_item = """# -------------------------------------------------- +# ---- %s ---- +# Option %s +# callback: %s +# takes %s argument%s +# %s +# +%s +# +#%s = %s + +""" diff --git a/over/text.py b/over/text.py index 2505bf5..b8845ec 100644 --- a/over/text.py +++ b/over/text.py @@ -193,12 +193,13 @@ def rfind(text, C, start, length): # return the original index at the same position as the last in colorless return start + (indices[len(indices_colorless) - 1] if indices_colorless else 0) -def paragraph(text, width=0, indent=0, stamp=None): +def paragraph(text, width=0, indent=0, stamp=None, join=True): """ str text text to format int width required line length; if 0, current terminal width will be used int indent how many spaces to indent the text with str stamp placed into the first line's indent (whether it fits is your problem) + bool join join lines into one string, otherwise return a list of individual lines Formats text into an indented paragraph that fits the terminal and returns it. Correctly handles colors. @@ -236,7 +237,7 @@ def paragraph(text, width=0, indent=0, stamp=None): lines[i] = indent_str + line # join - return "\n".join(lines) + return "\n".join(lines) if join else lines # -------------------------------------------------- diff --git a/over/version.py b/over/version.py index 64d0952..979dac3 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 = 1 # VERSION_PATCH_IDENTIFIER +patch = 2 # VERSION_PATCH_IDENTIFIER str = ".".join(str(v) for v in (major, minor, patch)) diff --git a/test.py b/test.py index 6befbbc..e2ce076 100755 --- a/test.py +++ b/test.py @@ -30,7 +30,7 @@ def int4(*args): if __name__ == "__main__": 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("boolean-single", "", over.callback.boolean, [False], abbr="1") + main.add_option("boolean-single", "ISO 8601 date for a new transfer (valid with +EHMU), defaults to current date. It's also used for relative times with --analysis-timeframe. You can use a day-count relative to today here.", 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)