Files @ d51c41490d1f
Branch filter:

Location: NPO-Accounting/import2ledger/import2ledger/config.py

Brett Smith
config: Get default date format from locale.
import argparse
import configparser
import contextlib
import datetime
import locale
import logging
import os.path
import pathlib

import babel
import babel.numbers
from . import errors, strparse

class Configuration:
    HOME_PATH = pathlib.Path(os.path.expanduser('~'))
    DEFAULT_CONFIG_PATH = pathlib.Path(HOME_PATH, '.config', 'import2ledger.ini')
    DEFAULT_ENCODING = locale.getpreferredencoding()
    LOCALE = babel.core.Locale.default()
    TODAY = datetime.date.today()

    def __init__(self, stdout, stderr):
        self.stdout = stdout
        self.stderr = stderr
        self.argparser = self._build_argparser()
        self.error = self.argparser.error
        self.conffile = self._build_conffile()

    def _build_argparser(self):
        parser = argparse.ArgumentParser()
        parser.add_argument(
            '--config-file', '-C', metavar='PATH', type=pathlib.Path,
            action='append',
            help="Path of a configuration file to read",
        )
        parser.add_argument(
            '--use-config', '-c', metavar='SECTION',
            help="Read settings from this section of the configuration file",
        )
        parser.add_argument(
            'input_paths', metavar='PATH',
            nargs='+',
            help="Path to generate Ledger entries from.",
        )

        out_args = parser.add_argument_group(
            "default overrides",
            description="These options take priority over settings in the "
            "[DEFAULT] section of your config file, but not other sections.",
        )
        out_args.add_argument(
            '--date-format', '-D', metavar='FORMAT',
            help="C-style date format to use to parse dates given in other "
            "options. This format will also be used for configuration parsing "
            "and output generation when no date format is specified in your "
            "configuration file(s). If not specified, read from the [Dates] "
            "section of your configuration, or else your locale.",
        )
        out_args.add_argument(
            '--date-range', metavar='DATE-DATE',
            help="Only import entries in this date range, inclusive. "
            "You can omit either side of the range.",
        )
        out_args.add_argument(
            '--default-date', '--date', '-d', metavar='DATE',
            help="Date to assign entries when the source doesn't "
            "provide one. Default today.",
        )
        out_args.add_argument(
            '--loglevel', '-L', metavar='LEVEL',
            choices=['debug', 'info', 'warning', 'error', 'critical'],
            help="Log messages at this level and above. Default warning.",
        )
        out_args.add_argument(
            '--output-path', '-O', metavar='PATH',
            help="Path of file to append entries to, or '-' for stdout (default).",
        )
        out_args.add_argument(
            '--signed-currency', '--sign', metavar='CODE',
            action='append', dest='signed_currencies',
            help="Currency code to use currency sign for in Ledger entry amounts. "
            "Can be specified multiple times.",
        )
        out_args.add_argument(
            '--signed-currency-format', '--sign-format', '-S', metavar='FORMAT',
            help="Unicode number pattern to use for signed currencies in Ledger entry amounts",
        )
        out_args.add_argument(
            '--unsigned-currency-format', '--unsign-format', '-U', metavar='FORMAT',
            help="Unicode number pattern to use for unsigned currencies in Ledger entry amounts",
        )

        return parser

    def _build_conffile(self):
        return configparser.ConfigParser(
            comment_prefixes='#',
            defaults={
                'loglevel': 'WARNING',
                'output_path': '-',
                'signed_currencies': ','.join(babel.numbers.get_territory_currencies(
                    self.LOCALE.territory, start_date=self.TODAY)),
                'signed_currency_format': '¤#,##0.###;¤-#,##0.###',
                'unsigned_currency_format': '#,##0.### ¤¤',
            })

    def _read_conffiles(self):
        if self.args.config_file is None:
            self.args.config_file = []
            if self.DEFAULT_CONFIG_PATH.exists():
                self.args.config_file.append(self.DEFAULT_CONFIG_PATH)
        conffile_paths = [path.as_posix() for path in self.args.config_file]
        read_files = self.conffile.read(conffile_paths)
        for expected_path, read_path in zip(conffile_paths, read_files):
            if read_path != expected_path:
                self.error("failed to read configuration file {!r}".format(expected_path))

    def _s_to_path(self, s):
        return None if s == '-' else pathlib.Path(s)

    def _strpdate(self, date_s, date_fmt):
        try:
            return strparse.date(date_s, date_fmt)
        except ValueError as error:
            raise errors.UserInputConfigurationError(error.args[0], date_s)

    def _reformat_date(self, date_s, in_fmt, out_fmt):
        return self._strpdate(date_s, in_fmt).strftime(out_fmt)

    def _read_dates_args(self):
        dates_section = self.get_section('Dates')
        try:
            out_fmt = dates_section['date format']
        except KeyError:
            if self.args.date_format is None:
                out_fmt = self.LOCALE.date_formats['short'].format % {
                    'd': '%d',
                    'dd': '%d',
                    'M': '%m',
                    'MM': '%m',
                    'MMM': '%b',
                    'MMMM': '%B',
                    'y': '%Y',
                    'yy': '%Y',
                    'yyy': '%Y',
                    'yyyy': '%Y',
                }
            else:
                out_fmt = self.args.date_format
            self.conffile[self.conffile.default_section]['date format'] = out_fmt.replace('%', '%%')
        in_fmt = self.args.date_format or out_fmt
        if self.args.default_date is not None:
            dates_section['default date'] = self._reformat_date(
                self.args.default_date, in_fmt, out_fmt)
        if self.args.date_range is not None:
            if self.args.date_range.startswith('-'):
                pivot = 0
            elif self.args.date_range.endswith('-'):
                pivot = len(self.args.date_range) - 1
            else:
                seps_at = [index for index, c in enumerate(self.args.date_range) if c == '-']
                seps_count = len(seps_at)
                if seps_count % 2:
                    pivot = seps_at[(seps_count - 1) // 2]
                else:
                    start_s = end_s = self.args.date_range
                    pivot = None
            if pivot is not None:
                start_s = self.args.date_range[:pivot]
                end_s = self.args.date_range[pivot + 1:]
            if start_s:
                dates_section['import start date'] = self._reformat_date(
                    start_s, in_fmt, out_fmt)
            else:
                dates_section.pop('import start date', None)
            if end_s:
                dates_section['import end date'] = self._reformat_date(
                    end_s, in_fmt, out_fmt)
            else:
                dates_section.pop('import end date', None)

    def read_args(self, arglist):
        self.args = self.argparser.parse_args(arglist)
        self._read_conffiles()
        default_secname = self.conffile.default_section
        default_section = self.get_section(default_secname)
        if self.args.use_config is None:
            self.args.use_config = default_secname
        elif not self.conffile.has_section(self.args.use_config):
            self.error("section {!r} not found in config file".format(self.args.use_config))
        self.args.input_paths = [self._s_to_path(s) for s in self.args.input_paths]
        if self.args.loglevel is not None:
            default_section['loglevel'] = self.args.loglevel
        if self.args.output_path is not None:
            default_section['output_path'] = self.args.output_path
        self._read_dates_args()

    @contextlib.contextmanager
    def _open_path(self, path, fallback_file, *args, **kwargs):
        if path is None:
            yield fallback_file
        else:
            with path.open(*args, **kwargs) as open_file:
                yield open_file

    def get_section(self, section_name):
        if section_name is None:
            section_name = self.args.use_config
        try:
            self.conffile.add_section(section_name)
        except (configparser.DuplicateSectionError, ValueError):
            pass
        return self.conffile[section_name]

    def _get_from_dict(self, confdict, section_name=None):
        if section_name is None:
            section_name = self.args.use_config
        try:
            return confdict[section_name]
        except KeyError:
            return confdict[self.conffile.default_section]

    def date_in_want_range(self, date, section_name=None):
        return date.toordinal() in self._get_from_dict(self.date_ranges, section_name)

    def get_default_date(self, section_name=None):
        return self._get_from_dict(self.dates, section_name)

    def get_loglevel(self, section_name=None):
        section_config = self.get_section(section_name)
        level_name = section_config['loglevel']
        try:
            return getattr(logging, level_name.upper())
        except AttributeError:
            raise errors.UserInputConfigurationError("not a valid loglevel", level_name)

    def get_output_path(self, section_name=None):
        section_config = self.get_section(section_name)
        return self._s_to_path(section_config['output_path'])

    def open_output_file(self, section_name=None):
        path = self.get_output_path(section_name)
        return self._open_path(path, self.stdout, 'a')

    def setup_logger(self, logger, section_name=None):
        logger.setLevel(self.get_loglevel(section_name))