diff -Nru undertime-1.6.0/debian/changelog undertime-1.7.0/debian/changelog --- undertime-1.6.0/debian/changelog 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/debian/changelog 2019-01-15 22:38:36.000000000 +0000 @@ -1,3 +1,10 @@ +undertime (1.7.0) unstable; urgency=medium + + * configuration file support + * show time below the table so users actually see it + + -- Antoine Beaupré Tue, 15 Jan 2019 17:38:36 -0500 + undertime (1.6.0) unstable; urgency=medium * support dateparser module, disabled in Debian because missing diff -Nru undertime-1.6.0/debian/control undertime-1.7.0/debian/control --- undertime-1.6.0/debian/control 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/debian/control 2019-01-15 22:38:36.000000000 +0000 @@ -9,6 +9,7 @@ python3-tz, python3-termcolor, python3-terminaltables, + python3-yaml, Standards-Version: 4.1.3 Homepage: https://gitlab.com/anarcat/undertime Vcs-Git: https://gitlab.com/anarcat/undertime.git diff -Nru undertime-1.6.0/README.rst undertime-1.7.0/README.rst --- undertime-1.6.0/README.rst 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/README.rst 2019-01-15 22:38:36.000000000 +0000 @@ -35,10 +35,11 @@ change time, you can also use the ``--date`` time to pick an arbitrary time for the meeting, using natural language (as parsed by the `dateparser`_ or `parsedatetime`_ modules, if available). The current -time is also shown, in bold. +time is also shown, in bold. A configuration file can be used to set +defaults. -Full usage is available with the ``--help`` flag and the -manpage. Instructions for how to contribute to the project are in +Summary usage is available with the ``--help`` flag and full help in +the manpage. Instructions for how to contribute to the project are in ``CONTRIBUTING.rst`` and there is a ``CODE_OF_CONDUCT.rst``. .. _list of known timezones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones diff -Nru undertime-1.6.0/setup.py undertime-1.7.0/setup.py --- undertime-1.6.0/setup.py 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/setup.py 2019-01-15 22:38:36.000000000 +0000 @@ -54,6 +54,7 @@ requires = [ 'dateparser', 'pytz', + 'pyyaml', 'termcolor', 'terminaltables', ] diff -Nru undertime-1.6.0/undertime undertime-1.7.0/undertime --- undertime-1.6.0/undertime 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/undertime 2019-01-15 22:38:36.000000000 +0000 @@ -5,9 +5,10 @@ This can be useful to select a common meeting time across multiple timezones easily. This takes into account daylight savings and -whatnot, and can schedule meetings in the future. Dates are parsed -with the dateparser or parsedatetime modules, if available, in that -order, see https://dateparser.readthedocs.io/en/latest/ and +whatnot, and can schedule meetings in the future. Default settings are +taken from ~/.config/undertime.yml. Dates are parsed with the +dateparser or parsedatetime modules, if available, in that order, see +https://dateparser.readthedocs.io/en/latest/ and https://github.com/bear/parsedatetime. ''' @@ -53,6 +54,7 @@ except ImportError: parsedatetime = None import pytz +import yaml # for tabulated data, i looked at other alternatives # humanfriendly has a tabulator: https://humanfriendly.readthedocs.io/en/latest/#module-humanfriendly.tables @@ -97,32 +99,128 @@ setattr(ns, self.dest, not option.startswith(self.negative)) -def arg_parser(): - parser = argparse.ArgumentParser(description=__description__, - epilog=__doc__) - parser.add_argument('timezones', nargs='*', - help='timezones to show [default: current timezone]') - parser.add_argument('--start', '-s', default=9, type=int, metavar='HOUR', - help='start of working day, in hours [default: %(default)s]') - parser.add_argument('--end', '-e', default=17, type=int, metavar='HOUR', - help='end of working day, in hours [default: %(default)s]') - parser.add_argument('--date', '-d', default=None, metavar='WHEN', - help='target date for the meeting, supports arbitrary dates like "in two weeks" [default: now]') - parser.add_argument('--colors', '--no-colors', action=NegateAction, - default=sys.stdout.isatty() and 'NO_COLOR' not in os.environ, - help='show colors [default: %(default)s]') - parser.add_argument('--default-zone', '--no-default-zone', action=NegateAction, - help='show current timezone first [default: %(default)s]') - parser.add_argument('--print-zones', action='store_true', - help='show valid timezones and exit') - default_level = 'WARNING' - parser.add_argument('-v', '--verbose', dest='loglevel', action='store_const', - const='INFO', default=default_level, - help='enable verbose messages') - parser.add_argument('--debug', dest='loglevel', action='store_const', - const='DEBUG', default=default_level, - help='enable debugging messages') - return parser +class ConfigAction(argparse.Action): + """add configuration file to current defaults. + + a *list* of default config files can be specified and will be + parsed when added by ConfigArgumentParser. + """ + def __init__(self, *args, **kwargs): + """the config action is a search path, so a list, so one or more argument""" + kwargs['nargs'] = 1 + super().__init__(*args, **kwargs) + + def __call__(self, parser, ns, values, option): + """change defaults for the namespace, still allows overriding + from commandline options""" + for path in values: + parser.set_defaults(**self.parse_config(path)) + + def parse_config(self, path): + """abstract implementation of config file parsing, should be overriden in subclasses""" + raise NotImplementedError() + + +class YamlConfigAction(ConfigAction): + """YAML config file parser action""" + def parse_config(self, path): + try: + with open(os.path.expanduser(path), 'r') as handle: + logging.debug('parsing path %s as YAML' % path) + return yaml.safe_load(handle) + except (FileNotFoundError, yaml.parser.ParserError) as e: + raise argparse.ArgumentError(self, e) + + +class ConfigArgumentParser(argparse.ArgumentParser): + """argument parser which supports parsing extra config files + + Config files specified on the commandline through the + YamlConfigAction arguments modify the default values on the + spot. If a default is specified when adding an argument, it also + gets immediately loaded. + + This will typically be used in a subclass, like this: + + self.add_argument('--config', action=YamlConfigAction, default=self.default_config()) + + """ + + def _add_action(self, action): + # this overrides the add_argument() routine, which is where + # actions get registered. it is done so we can properly load + # the default config file before the action actually gets + # fired. Ideally, we'd load the default config only if the + # action *never* gets fired (but still setting defaults for + # the namespace) but argparse doesn't give us that opportunity + # (and even if it would, it wouldn't retroactively change the + # Namespace object in parse_args() so it wouldn't work). + action = super()._add_action(action) + if isinstance(action, ConfigAction) and action.default is not None: + # fire the action, later calls can override defaults + try: + action(self, None, action.default, None) + logging.debug('loaded config file: %s' % action.default) + except argparse.ArgumentError as e: + # ignore errors from missing default + logging.debug('default config file %s error: %s' % (action.default, e)) + + def default_config(self): + """handy shortcut to detect commonly used config paths""" + return [os.path.join(os.environ.get('XDG_CONFIG_HOME', '~/.config/'), self.prog + '.yml')] + + +class LoggingAction(argparse.Action): + """change log level on the fly + + The logging system should be initialized befure this, using + `basicConfig`. + """ + def __init__(self, *args, **kwargs): + """setup the action parameters + + This enforces a selection of logging levels. It also checks if + const is provided, in which case we assume it's an argument + like `--verbose` or `--debug` without an argument. + """ + kwargs['choices'] = logging._nameToLevel.keys() + if 'const' in kwargs: + kwargs['nargs'] = 0 + super().__init__(*args, **kwargs) + + def __call__(self, parser, ns, values, option): + """if const was specified it means argument-less parameters""" + if self.const: + logging.getLogger('').setLevel(self.const) + else: + logging.getLogger('').setLevel(values) + + +class UndertimeArgumentParser(ConfigArgumentParser): + def __init__(self, *args, **kwargs): + """override constructor to setup our arguments and config files""" + super().__init__(description=__description__, epilog=__doc__, *args, **kwargs) + self.add_argument('timezones', nargs='*', + help='timezones to show [default: current timezone]') + self.add_argument('--start', '-s', default=9, type=int, metavar='HOUR', + help='start of working day, in hours [default: %(default)s]') + self.add_argument('--end', '-e', default=17, type=int, metavar='HOUR', + help='end of working day, in hours [default: %(default)s]') + self.add_argument('--date', '-d', default=None, metavar='WHEN', + help='target date for the meeting, for example "in two weeks" [default: now]') + self.add_argument('--colors', '--no-colors', action=NegateAction, + default=sys.stdout.isatty() and 'NO_COLOR' not in os.environ, + help='show colors [default: %(default)s]') + self.add_argument('--default-zone', '--no-default-zone', action=NegateAction, + help='show current timezone first [default: %(default)s]') + self.add_argument('--print-zones', action='store_true', + help='show valid timezones and exit') + self.add_argument('-v', '--verbose', action=LoggingAction, + const='INFO', help='enable verbose messages') + self.add_argument('--debug', action=LoggingAction, + const='DEBUG', help='enable debugging messages') + self.add_argument('--config', action=YamlConfigAction, + default=self.default_config()) def fmt_time(dt, args): @@ -154,8 +252,9 @@ def main(): - args = arg_parser().parse_args() - logging.basicConfig(format='%(levelname)s: %(message)s', level=args.loglevel) + logging.basicConfig(format='%(levelname)s: %(message)s', level='WARNING') + parser = UndertimeArgumentParser() + args = parser.parse_args() if args.print_zones: print("\n".join(pytz.all_timezones)) return @@ -184,8 +283,8 @@ table = DoubleTable(rows) for i in range(0, len(timezones)): table.justify_columns[i] = 'center' - print('Table generated for time: {}'.format(now)) print(table.table) + print('Table generated for time: {}'.format(now)) def guess_zones(timezones): diff -Nru undertime-1.6.0/undertime.1 undertime-1.7.0/undertime.1 --- undertime-1.6.0/undertime.1 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/undertime.1 2019-01-15 22:38:36.000000000 +0000 @@ -30,6 +30,9 @@ .IR WHEN \|] .RB [\| \-\-colors \||\| \-\-no\-colors \|] .RB [\| \-\-default\-zone \\|\| \-\-no\-default\-zone \|] +.RB [\| \-\-config +.IR FILE +.RB \|] .IR [timezones] .br .B undertime @@ -54,28 +57,27 @@ database. Spaces in arguments are replaced by underscores and all-caps versions of lowercase strings are tried as well. When failing to match a provided timezone against the internal list, a warning is emitted. A -complete list of timezones is shown when the \fB--print-zones\fP +complete list of timezones is shown when the \fB\-\-print\-zones\fP commandline option is provided. .PP Colors are used to highlight the "work hours" where possible meeting times could overlap and the current time is marked in bold. Colors are replaced by an underscore suffix and the current time by a star if colors are disabled. You can change those work hours with the -\fB--start\fP and \fB--end\fP flags. Because daylight savings may -actually change time, you can also use the \fB--date\fP time to pick +\fB\-\-start\fP and \fB\-\-end\fP flags. Because daylight savings may +actually change time, you can also use the \fB\-\-date\fP time to pick an arbitrary time for the meeting, using natural language (as parsed by the parsedatetime library). The current time is also shown, in bold. - .SH OPTIONS .TP -.B \-\-start HOUR, \-s HOUR +.BI \-\-start\ HOUR \fR,\ \fB\-s\ HOUR start of working day, in hours .TP -.B \-\-end HOUR, \-e HOUR +.BI \-\-end\ HOUR \fR,\ \fB\-e\ HOUR end of working day, in hours .TP -.B \-\-date WHEN, \-d WHEN +.BI \-\-date\ WHEN \fR,\ \fB\-d\ WHEN target date for the meeting, supports arbitrary dates like "in two weeks" .TP @@ -88,15 +90,11 @@ .B \-\-print-zones show valid timezones and exit .TP +.BI \-\-config\ FILE +load YAML config \fIFILE\fP as defaults. +.TP .B \-h, \-\-help Show summary of options and defaults -.SH EXAMPLES -.TP -\fBundertime \fI"New York" "Los Angeles" Paris -Display possible meeting times for three major cities. -.TP -\fB undertime -s \fI8 \fB-e \fI18 \fB--date \fI2014-03-15 -Show a longer work day in march 2014. .SH ENVIRONMENT .TP .B TZ @@ -106,6 +104,44 @@ If this environment variable is defined, no color will be shown unless the \-\-colors flag is passed. Comforming to .URL http://no-color.org/ "NO_COLOR informal standard" +.SH FILES +.TP +.I ~/.config/undertime.yml $XDG_CONFIG_DIR/undertime.yml +The configuration file loaded by default, if present. The +configuration file is formatted as a YAML dictionary, where keys are +the long option names (without dashes) and the values are their +values. Comments start with the \fI#\fR character. Options provided as +arguments override the defaults set in the configuration file, +particularly timezones. For example, this means timezones specified in +the configuration file are ignored ignored when also specified as +arguments. The \fB\-\-config\fR parameter allows you to load another +configuration file, on top of the default one, using the same logic, +allowing you to have multiple "presets". +.SH EXAMPLE +.SS Command line options +.TP +\fBundertime \fI"New York" "Los Angeles" Paris +Display possible meeting times for three major cities. +.TP +\fBundertime -s \fI8 \fB-e \fI18 \fB--date \fI2014-03-15 +Show a longer work day in march 2014. +.SS Configuration file +.TP +Set the above timezones as default: +.EX +timezones: [US/Central, US/Pacific, Europe/Paris] +.EE +.TP +Set the work day to be between 10 and 1800 hours: +.EX +start: 10 +end: 18 +.EE +.TP +Disable colors: +.EX +colors: false +.EE .SH SEE ALSO .BR tzselect(8) .BR tzfile(5) diff -Nru undertime-1.6.0/undertime.py undertime-1.7.0/undertime.py --- undertime-1.6.0/undertime.py 2019-01-14 19:29:10.000000000 +0000 +++ undertime-1.7.0/undertime.py 2019-01-15 22:38:36.000000000 +0000 @@ -5,9 +5,10 @@ This can be useful to select a common meeting time across multiple timezones easily. This takes into account daylight savings and -whatnot, and can schedule meetings in the future. Dates are parsed -with the dateparser or parsedatetime modules, if available, in that -order, see https://dateparser.readthedocs.io/en/latest/ and +whatnot, and can schedule meetings in the future. Default settings are +taken from ~/.config/undertime.yml. Dates are parsed with the +dateparser or parsedatetime modules, if available, in that order, see +https://dateparser.readthedocs.io/en/latest/ and https://github.com/bear/parsedatetime. ''' @@ -53,6 +54,7 @@ except ImportError: parsedatetime = None import pytz +import yaml # for tabulated data, i looked at other alternatives # humanfriendly has a tabulator: https://humanfriendly.readthedocs.io/en/latest/#module-humanfriendly.tables @@ -97,32 +99,128 @@ setattr(ns, self.dest, not option.startswith(self.negative)) -def arg_parser(): - parser = argparse.ArgumentParser(description=__description__, - epilog=__doc__) - parser.add_argument('timezones', nargs='*', - help='timezones to show [default: current timezone]') - parser.add_argument('--start', '-s', default=9, type=int, metavar='HOUR', - help='start of working day, in hours [default: %(default)s]') - parser.add_argument('--end', '-e', default=17, type=int, metavar='HOUR', - help='end of working day, in hours [default: %(default)s]') - parser.add_argument('--date', '-d', default=None, metavar='WHEN', - help='target date for the meeting, supports arbitrary dates like "in two weeks" [default: now]') - parser.add_argument('--colors', '--no-colors', action=NegateAction, - default=sys.stdout.isatty() and 'NO_COLOR' not in os.environ, - help='show colors [default: %(default)s]') - parser.add_argument('--default-zone', '--no-default-zone', action=NegateAction, - help='show current timezone first [default: %(default)s]') - parser.add_argument('--print-zones', action='store_true', - help='show valid timezones and exit') - default_level = 'WARNING' - parser.add_argument('-v', '--verbose', dest='loglevel', action='store_const', - const='INFO', default=default_level, - help='enable verbose messages') - parser.add_argument('--debug', dest='loglevel', action='store_const', - const='DEBUG', default=default_level, - help='enable debugging messages') - return parser +class ConfigAction(argparse.Action): + """add configuration file to current defaults. + + a *list* of default config files can be specified and will be + parsed when added by ConfigArgumentParser. + """ + def __init__(self, *args, **kwargs): + """the config action is a search path, so a list, so one or more argument""" + kwargs['nargs'] = 1 + super().__init__(*args, **kwargs) + + def __call__(self, parser, ns, values, option): + """change defaults for the namespace, still allows overriding + from commandline options""" + for path in values: + parser.set_defaults(**self.parse_config(path)) + + def parse_config(self, path): + """abstract implementation of config file parsing, should be overriden in subclasses""" + raise NotImplementedError() + + +class YamlConfigAction(ConfigAction): + """YAML config file parser action""" + def parse_config(self, path): + try: + with open(os.path.expanduser(path), 'r') as handle: + logging.debug('parsing path %s as YAML' % path) + return yaml.safe_load(handle) + except (FileNotFoundError, yaml.parser.ParserError) as e: + raise argparse.ArgumentError(self, e) + + +class ConfigArgumentParser(argparse.ArgumentParser): + """argument parser which supports parsing extra config files + + Config files specified on the commandline through the + YamlConfigAction arguments modify the default values on the + spot. If a default is specified when adding an argument, it also + gets immediately loaded. + + This will typically be used in a subclass, like this: + + self.add_argument('--config', action=YamlConfigAction, default=self.default_config()) + + """ + + def _add_action(self, action): + # this overrides the add_argument() routine, which is where + # actions get registered. it is done so we can properly load + # the default config file before the action actually gets + # fired. Ideally, we'd load the default config only if the + # action *never* gets fired (but still setting defaults for + # the namespace) but argparse doesn't give us that opportunity + # (and even if it would, it wouldn't retroactively change the + # Namespace object in parse_args() so it wouldn't work). + action = super()._add_action(action) + if isinstance(action, ConfigAction) and action.default is not None: + # fire the action, later calls can override defaults + try: + action(self, None, action.default, None) + logging.debug('loaded config file: %s' % action.default) + except argparse.ArgumentError as e: + # ignore errors from missing default + logging.debug('default config file %s error: %s' % (action.default, e)) + + def default_config(self): + """handy shortcut to detect commonly used config paths""" + return [os.path.join(os.environ.get('XDG_CONFIG_HOME', '~/.config/'), self.prog + '.yml')] + + +class LoggingAction(argparse.Action): + """change log level on the fly + + The logging system should be initialized befure this, using + `basicConfig`. + """ + def __init__(self, *args, **kwargs): + """setup the action parameters + + This enforces a selection of logging levels. It also checks if + const is provided, in which case we assume it's an argument + like `--verbose` or `--debug` without an argument. + """ + kwargs['choices'] = logging._nameToLevel.keys() + if 'const' in kwargs: + kwargs['nargs'] = 0 + super().__init__(*args, **kwargs) + + def __call__(self, parser, ns, values, option): + """if const was specified it means argument-less parameters""" + if self.const: + logging.getLogger('').setLevel(self.const) + else: + logging.getLogger('').setLevel(values) + + +class UndertimeArgumentParser(ConfigArgumentParser): + def __init__(self, *args, **kwargs): + """override constructor to setup our arguments and config files""" + super().__init__(description=__description__, epilog=__doc__, *args, **kwargs) + self.add_argument('timezones', nargs='*', + help='timezones to show [default: current timezone]') + self.add_argument('--start', '-s', default=9, type=int, metavar='HOUR', + help='start of working day, in hours [default: %(default)s]') + self.add_argument('--end', '-e', default=17, type=int, metavar='HOUR', + help='end of working day, in hours [default: %(default)s]') + self.add_argument('--date', '-d', default=None, metavar='WHEN', + help='target date for the meeting, for example "in two weeks" [default: now]') + self.add_argument('--colors', '--no-colors', action=NegateAction, + default=sys.stdout.isatty() and 'NO_COLOR' not in os.environ, + help='show colors [default: %(default)s]') + self.add_argument('--default-zone', '--no-default-zone', action=NegateAction, + help='show current timezone first [default: %(default)s]') + self.add_argument('--print-zones', action='store_true', + help='show valid timezones and exit') + self.add_argument('-v', '--verbose', action=LoggingAction, + const='INFO', help='enable verbose messages') + self.add_argument('--debug', action=LoggingAction, + const='DEBUG', help='enable debugging messages') + self.add_argument('--config', action=YamlConfigAction, + default=self.default_config()) def fmt_time(dt, args): @@ -154,8 +252,9 @@ def main(): - args = arg_parser().parse_args() - logging.basicConfig(format='%(levelname)s: %(message)s', level=args.loglevel) + logging.basicConfig(format='%(levelname)s: %(message)s', level='WARNING') + parser = UndertimeArgumentParser() + args = parser.parse_args() if args.print_zones: print("\n".join(pytz.all_timezones)) return @@ -184,8 +283,8 @@ table = DoubleTable(rows) for i in range(0, len(timezones)): table.justify_columns[i] = 'center' - print('Table generated for time: {}'.format(now)) print(table.table) + print('Table generated for time: {}'.format(now)) def guess_zones(timezones):