#!/usr/bin/env python3
'General classes and functions for use in pysilfont scripts'
__url__ = 'https://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2014-2023 SIL International (https://www.sil.org)'
__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)'
__author__ = 'David Raymond'

from glob import glob
from collections import OrderedDict
import sys, os, argparse, datetime, shutil, csv, configparser

import silfont

class loggerobj(object):
    # For handling log messages.
    # Use S for severe errors caused by data, parameters supplied by user etc
    # Use X for severe errors caused by bad code to get traceback exception

    def __init__(self, logfile=None, loglevels="", leveltext="",  loglevel="W", scrlevel="P"):
        self.logfile = logfile
        self.loglevels = loglevels
        self.leveltext = leveltext
        self.errorcount = 0
        self.warningcount = 0
        if not self.loglevels: self.loglevels = {'X': 0,       'S': 1,       'E': 2,       'P': 3,       'W': 4,       'I': 5,       'V': 6}
        if not self.leveltext: self.leveltext = ('Exception ', 'Severe:   ', 'Error:    ', 'Progress: ', 'Warning:  ', 'Info:     ', 'Verbose:  ')
        super(loggerobj, self).__setattr__("loglevel", "E") # Temp values so invalid log levels can be reported
        super(loggerobj, self).__setattr__("scrlevel", "E") #
        self.loglevel = loglevel
        self.scrlevel = scrlevel

    def __setattr__(self, name, value):
        if name in ("loglevel", "scrlevel"):
            if value in self.loglevels:
                (minlevel, minnum) = ("E",2) if name == "loglevel" else ("S", 1)
                if self.loglevels[value] < minnum:
                    value = minlevel
                    self.log(name + " increased to minimum level of " + minlevel, "E")
            else:
                self.log("Invalid " + name + " value: " + value, "S")
        super(loggerobj, self).__setattr__(name, value)
        if name == "scrlevel" : self._basescrlevel = value # Used by resetscrlevel

    def log(self, logmessage, msglevel="W"):
        levelval = self.loglevels[msglevel]
        message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S ") + self.leveltext[levelval] + str(logmessage)
        #message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[0:22] +" "+ self.leveltext[levelval] + logmessage  ## added milliseconds for timing tests
        if levelval <= self.loglevels[self.scrlevel]: print(message)
        if self.logfile and levelval <= self.loglevels[self.loglevel]: self.logfile.write(message + "\n")
        if msglevel == "S":
            print("\n **** Fatal error - exiting ****")
            sys.exit(1)
        if msglevel == "X": assert False, message
        if msglevel == "E": self.errorcount += 1
        if msglevel == "W": self.warningcount += 1

    def raisescrlevel(self, level): # Temporarily increase screen logging
        if level not in self.loglevels or level == "X" : self.log("Invalid scrlevel: " + level, "X")
        if self.loglevels[level] > self.loglevels[self.scrlevel]:
            current = self.scrlevel
            self.scrlevel = level
            self._basescrlevel = current
            self.log("scrlevel raised to " + level, "I")

    def resetscrlevel(self):
        self.scrlevel = self._basescrlevel


class parameters(object):
    # Object for holding parameters information, organised by class (eg logging)

    # Default parameters for use in pysilfont modules
    #   Names must be case-insensitively unique across all parameter classes
    #   Parameter types are deduced from the default values

    def __init__(self):
        # Default parameters for all modules
        defparams = {}
        defparams['system'] = {'version': silfont.__version__, 'copyright': silfont.__copyright__}  # Code treats these as read-only
        defparams['logging'] = {'scrlevel': 'P', 'loglevel': 'W'}
        defparams['backups'] = {'backup': True, 'backupdir': 'backups', 'backupkeep': 5}
        # Default parameters for UFO module
        defparams['outparams'] = OrderedDict([ # Use ordered dict so parameters show in logical order with -h p
            ("UFOversion", ""),  # UFOversion - defaults to existing unless a value is supplied
            ("indentIncr",       "  "),   # XML Indent increment
            ("indentFirst",      "  "),   # First XML indent
            ("indentML",         False),  # Should multi-line string values be indented?
            ("plistIndentFirst", ""),     # First indent amount for plists
            ('precision', 6),             # Decimal precision to use in XML output - both for real values and for attributes if float
            ("floatAttribs", ['xScale', 'xyScale', 'yxScale', 'yScale', 'angle']),  # Used with precision above
            ("intAttribs", ['pos', 'width', 'height', 'xOffset', 'yOffset', 'x', 'y']),
            ("sortDicts",        True),   # Should dict elements be sorted alphabetically?
            ("renameGlifs",      True),   # Rename glifs based on UFO3 suggested algorithm
            ("format1Glifs",     False),  # Force output format 1 glifs including UFO2-style anchors (for use with FontForge
            ("glifElemOrder",    ['advance', 'unicode', 'note',   'image',  'guideline', 'anchor', 'outline', 'lib']),  # Order to output glif elements
            ("attribOrders.glif",['pos', 'width', 'height', 'fileName', 'base', 'xScale', 'xyScale', 'yxScale', 'yScale', 'xOffset', 'yOffset',
                                  'x', 'y', 'angle', 'type', 'smooth', 'name', 'format', 'color', 'identifier'])
            ])
        defparams['ufometadata'] = {"checkfix": "check"}   # Apply metadata fixes when reading UFOs

        self.paramshelp = {} # Info used when outputting help about parame options
        self.paramshelp["classdesc"] = {
            "logging": "controls the level of log messages go to screen or log files.",
            "backups": "controls backup settings for scripts that output fonts - by default backups are made if the output font is overwriting the input font",
            "outparams": "Output options for UFOs - cover UFO version and normalization",
            "ufometadata": "controls if UFO metadata be checked, or checked and fixed"
        }
        self.paramshelp["paramsdesc"] = {
            "scrlevel": "Logging level for screen messages - one of S,E,P.W,I or V",
            "loglevel": "Logging level for log file messages - one of E,P.W,I or V",
            "backup": "Should font backups be made",
            "backupdir": "Directory to use for font backups",
            "backupkeep": "How many backups to keep",
            "indentIncr": "XML Indent increment",
            "indentFirst": "First XML indent",
            "indentML": "Should multi-line string values be indented?",
            "plistIndentFirst": "First indent amount for plists",
            "sortDicts": "Should dict elements be sorted alphabetically?",
            "precision": "Decimal precision to use in XML output - both for real values and for attributes if numeric",
            "renameGlifs": "Rename glifs based on UFO3 suggested algorithm",
            "UFOversion": "UFOversion to output - defaults to version of the input UFO",
            "format1Glifs": "Force output format 1 glifs including UFO2-style anchors (was used with FontForge; no longer needed)",
            "glifElemOrder": "Order to output glif elements",
            "floatAttribs": "List of float attributes - used when setting decimal precision",
            "intAttribs": "List of attributes that should be integers",
            "attribOrders.glif": "Order in which to output glif attributes",
            "checkfix": "Should check & fix tests be done - one of None, Check or Fix"
        }
        self.paramshelp["defaultsdesc"] = { # For use where default needs clarifying with text
            "indentIncr" : "<two spaces>",
            "indentFirst": "<two spaces>",
            "plistIndentFirst": "<No indent>",
            "UFOversion": "<Existing version>"
        }

        self.classes = {}  # Dictionary containing a list of parameters in each class
        self.paramclass = {}  # Dictionary of class name for each parameter name
        self.types = {}  # Python type for each parameter deduced from initial values supplied
        self.listtypes = {}  # If type is dict, the type of values in the dict
        self.logger = loggerobj()
        defset = _paramset(self, "default", "defaults")
        self.sets = {"default": defset}
        self.lcase = {}  # Lower case index of parameters names
        for classn in defparams:
            self.classes[classn] = []
            for parn in defparams[classn]:
                value = defparams[classn][parn]
                self.classes[classn].append(parn)
                self.paramclass[parn] = classn
                self.types[parn] = type(value)
                if type(value) is list: self.listtypes[parn] = type(value[0])
                super(_paramset, defset).__setitem__(parn, value)  # __setitem__ in paramset does not allow new values!
                self.lcase[parn.lower()] = parn

    def addset(self, name, sourcedesc=None, inputdict=None, configfile=None, copyset=None):
        # Create a subset from one of a dict, config file or existing set
        # Only one option should used per call
        # sourcedesc should be added for user-supplied data (eg config file) for reporting purposes
        dict = {}
        if configfile:
            config = configparser.ConfigParser()
            config.read_file(open(configfile, encoding="utf-8"))
            if sourcedesc is None: sourcedesc = configfile
            for classn in config.sections():
                for item in config.items(classn):
                    parn = item[0]
                    if self.paramclass[parn] == "system":
                        self.logger.log("Can't change " + parn + " parameter via config file", "S")
                    val = item[1].strip('"').strip("'")
                    dict[parn] = val
        elif copyset:
            if sourcedesc is None: sourcedesc = "Copy of " + copyset
            for parn in self.sets[copyset]:
                dict[parn] = self.sets[copyset][parn]
        elif inputdict:
            dict = inputdict
        if sourcedesc is None: sourcedesc = "unspecified source"
        self.sets[name] = _paramset(self, name, sourcedesc, dict)

    def printhelp(self):
        phelp = self.paramshelp
        print("\nMost pysilfont scripts have -p, --params options which can be used to change default behaviour of scripts.  For example '-p scrlevel=w' will log warning messages to screen \n")
        print("Listed below are all such parameters, grouped by purpose.  Not all apply to all scripts - "
              "in partucular outparams and ufometadata only apply to scripts using pysilfont's own UFO code")
        for classn in ("logging", "backups", "ufometadata", "outparams"):
            print("\n" + classn[0].upper() + classn[1:] + " - " + phelp["classdesc"][classn])
            for param in self.classes[classn]:
                if param == "format1Glifs": continue # Param due to be phased out
                paramdesc = phelp["paramsdesc"][param]
                paramtype = self.types[param].__name__
                defaultdesc = phelp["defaultsdesc"][param] if param in phelp["defaultsdesc"] else self.sets["default"][param]
                print('    {:<20}: {}'.format(param, paramdesc))
                print('       (Type: {:<6} Default: {})'.format(paramtype + ",", defaultdesc))
        print("\nNote parameter names are case-insensitive\n")
        print("For more help see https://github.com/silnrsi/pysilfont/blob/master/docs/parameters.md\n")

class _paramset(dict):
    # Set of parameter values
    def __init__(self, params, name, sourcedesc, inputdict=None):
        if inputdict is None: inputdict = {}
        self.name = name
        self.sourcedesc = sourcedesc  # Description of source for reporting
        self.params = params  # Parent parameters object
        for parn in inputdict:
            if params.paramclass[parn] == "system": # system values can't be changed
                if inputdict[parn] != params.sets["default"][parn]:
                    self.params.logger.log("Can't change " + parn + " - system parameters can't be changed", "X")
                else:
                    super(_paramset, self).__setitem__(parn, inputdict[parn])
            else:
                self[parn] = inputdict[parn]

    def __setitem__(self, parn, value):
        origvalue = value
        origparn = parn
        parn = parn.lower()
        if self.params.paramclass[origparn] == "system":
            self.params.logger.log("Can't change " + parn + " - system parameters are read-only", "X")
        if parn not in self.params.lcase:
            self.params.logger.log("Invalid parameter " + origparn + " from " + self.sourcedesc, "S")
        else:
            parn = self.params.lcase[parn]
        ptyp = self.params.types[parn]
        if ptyp is bool:
            value = str2bool(value)
            if value is None: self.params.logger.log(self.sourcedesc+" parameter "+origparn+" must be boolean: " + origvalue, "S")
        if ptyp is list:
            if type(value) is not list: value = value.split(",")  # Convert csv string into list
            if len(value) < 2: self.params.logger.log(self.sourcedesc+" parameter "+origparn+" must have a list of values: " + origvalue, "S")
            valuesOK = True
            listtype = self.params.listtypes[parn]
            for i, val in enumerate(value):
                if listtype is bool:
                    val = str2bool(val)
                    if val is None: self.params.logger.log (self.sourcedesc+" parameter "+origparn+" must contain boolean values: " + origvalue, "S")
                    value[i] = val
                if type(val) != listtype:
                    valuesOK = False
                    badtype = str(type(val))
            if not valuesOK: self.params.logger.log("Invalid "+badtype+" parameter type for "+origparn+": "+self.params.types[parn], "S")
        if parn in ("loglevel", "scrlevel"):  # Need to check log level is valid before setting it since otherwise logging will fail
            value = value.upper()
            if value not in self.params.logger.loglevels: self.params.logger.log (self.sourcedesc+" parameter "+parn+" invalid", "S")
        super(_paramset, self).__setitem__(parn, value)

    def updatewith(self, update, sourcedesc=None, log=True):
        # Update a set with values from another set
        if sourcedesc is None: sourcedesc = self.params.sets[update].sourcedesc
        for parn in self.params.sets[update]:
            oldval = self[parn] if parn in self else ""
            self[parn] = self.params.sets[update][parn]
            if log and oldval != "" and self[parn] != oldval:
                old = str(oldval)
                new = str(self[parn])
                if old != old.strip() or new != new.strip():   # Add quotes if there are leading or trailing spaces
                    old = '"'+old+'"'
                    new = '"'+new+'"'
                self.params.logger.log(sourcedesc + " parameters: changing "+parn+" from " + old + " to " + new, "I")


class csvreader(object):    # Iterator for csv files, skipping comments and checking number of fields
    def __init__(self, filename, minfields=0, maxfields=999, numfields=None, logger=None):
        self.filename = filename
        self.minfields = minfields
        self.maxfields = maxfields
        self.numfields = numfields
        self.logger = logger if logger else loggerobj()   # If no logger supplied, will just log to screen
        # Open the file and create reader
        try:
            file = open(filename, "rt", encoding="utf-8")
        except Exception as e:
            print(e)
            sys.exit(1)
        self.file = file
        self.reader = csv.reader(file)
        # Find the first non-comment line then reset so __iter__ still returns the first line
        # This is so scripts can analyse first line (eg to look for headers) before starting iterating
        self.firstline = None
        self._commentsbeforefirstline = -1
        while not self.firstline:
            row = next(self.reader, None)
            if row is None: logger.log("Input csv is empty or all lines are comments or blank", "S")
            self._commentsbeforefirstline += 1
            if row == []: continue  # Skip blank lines
            if row[0].lstrip().startswith("#"): continue  # Skip comments - ie lines starting with  #
            self.firstline = row
        file.seek(0) # Reset the csv and skip comments
        for i in range(self._commentsbeforefirstline): next(self.reader, None)

    def __setattr__(self, name, value):
        if name == "numfields" and value is not None:  # If numfields is changed, reset min and max fields
            self.minfields = value
            self.maxfields = value
        super(csvreader, self).__setattr__(name, value)

    def __iter__(self):
        for row in self.reader:
            self.line_num = self.reader.line_num - 1 - self._commentsbeforefirstline # Count is out due to reading first line in __init__
            if row == []: continue  # Skip blank lines
            if row[0].lstrip().startswith("#"): continue  # Skip comments - ie lines starting with  #
            if len(row) < self.minfields or len(row) > self.maxfields:
                self.logger.log("Invalid number of fields on line " + str(self.line_num) + " in "+self.filename, "E" )
                continue
            yield row


def execute(tool, fn, scriptargspec, chain = None):
    # Function to handle parameter parsing, font and file opening etc in command-line scripts
    # Supports opening (and saving) fonts using PysilFont UFO (UFO), fontParts (FP) or fontTools (FT)
    # Special handling for:
    #   -d  variation on -h to print extra info about defaults
    #   -q  quiet mode - only output a single line with count of errors (if there are any)
    #   -l  opens log file and also creates a logger function to write to the log file
    #   -p  other parameters. Includes backup settings and loglevel/scrlevel settings for logger
    #       for UFOlib scripts, also includes all outparams keys and ufometadata settings

    argspec = list(scriptargspec)

    chainfirst = False
    if chain == "first": # If first call to execute has this set, only do the final return part of chaining
        chainfirst = True
        chain = None

    params = chain["params"] if chain else parameters()
    logger = chain["logger"] if chain else params.logger  # paramset has already created a basic logger
    argv   = chain["argv"]   if chain else sys.argv

    if tool == "UFO":
        from silfont.ufo import Ufont
    elif tool == "FT":
        from fontTools import ttLib
    elif tool == "FP":
        from fontParts.world import OpenFont
    elif tool == "" or tool is None:
        tool = None
    else:
        logger.log("Invalid tool in call to execute()", "X")
        return
    basemodule = sys.modules[fn.__module__]
    poptions = {}
    poptions['prog'] = splitfn(argv[0])[1]
    poptions['description'] = basemodule.__doc__
    poptions['formatter_class'] = argparse.RawDescriptionHelpFormatter
    epilog = "For more help options use -h ?.  For more documentation see https://github.com/silnrsi/pysilfont/blob/master/docs/scripts.md#" + poptions['prog'] + "\n\n"
    poptions['epilog'] = epilog + "Version: " + params.sets['default']['version'] + "\n" + params.sets['default']['copyright']

    parser = argparse.ArgumentParser(**poptions)
    parser._optionals.title = "other arguments"


    # Add standard arguments
    standardargs = {
            'quiet': ('-q', '--quiet', {'help': 'Quiet mode - only display severe errors', 'action': 'store_true'}, {}),
            'log': ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile'}),
            'params': ('-p', '--params', {'help': 'Other parameters - see parameters.md for details', 'action': 'append'}, {'type': 'optiondict'}),
            'nq': ('--nq', {'help': argparse.SUPPRESS, 'action': 'store_true'}, {})}

    suppliedargs = []
    for a in argspec:
        argn = a[:-2][-1]  # [:-2] will give either 1 or 2, the last of which is the full argument name
        if argn[0:2] == "--": argn = argn[2:]  # Will start with -- for options
        suppliedargs.append(argn)
    for arg in sorted(standardargs):
        if arg not in suppliedargs: argspec.append(standardargs[arg])

    defhelp = False
    if "-h" in argv: # Look for help option supplied
        pos = argv.index("-h")
        if pos < len(argv)-1: # There is something following -h!
            opt = argv[pos+1]
            if opt in ("d", "defaults"):
                defhelp = True # Normal help will be displayed with default info displayed by the epilog
                deffiles = []
                defother = []
            elif opt in ("p", "params"):
                params.printhelp()
                sys.exit(0)
            else:
                if opt != "?":
                    print("Invalid -h value")
                    print("-h ? displays help options")
                print("-h d (or -h defaults) lists details of default values for arguments and parameters")
                print("-h p (or -h params) gives help on parameters that can be set with -p or --params")
                sys.exit(0)

    quiet = True if "-q" in argv and '--nq' not in argv else False
    if quiet: logger.scrlevel = "S"

    # Process the supplied argument specs, add args to parser, store other info in arginfo
    arginfo = []
    logdef = None
    for a in argspec:
        # Process all but last tuple entry as argparse arguments
        nonkwds = a[:-2]
        kwds = a[-2]
        try:
            parser.add_argument(*nonkwds, **kwds)
        except Exception as e:
            print(f'nonkwds: {nonkwds}, kwds: {kwds}')
            print(e)
            sys.exit(1)

        # Create ainfo, a dict of framework keywords using argument name
        argn = nonkwds[-1]  # Find the argument name from first 1 or 2 tuple entries
        if argn[0:2] == "--": # Will start with -- for options
            argn = argn[2:].replace("-", "_") # Strip the -- and replace any - in name with _
        ainfo=dict(a[-1]) #Make a copy so original argspec is not changed
        for key in ainfo: # Check all keys are valid
            if key not in ("def", "type", "optlog") : logger.log("Invalid argspec framework key: " + key, "X")
        ainfo['name']=argn
        if argn == 'log':
            logdef = ainfo['def'] if 'def' in ainfo else None
            optlog = ainfo['optlog'] if 'optlog' in ainfo else False
        arginfo.append(ainfo)
        if defhelp:
            arg = nonkwds[0]
            if 'def' in ainfo:
                defval = ainfo['def']
                if argn == 'log' and logdef: defval += " in logs subdirectory"
                deffiles.append([arg, defval])
            elif 'default' in kwds:
                defother.append([arg, kwds['default']])

    # if -h d specified, change the help epilog to info about argument defaults
    if defhelp:
        if not (deffiles or defother):
            deftext = "No defaults for parameters/options"
        else:
            deftext = "Defaults for parameters/options - see user docs for details\n"
        if deffiles:
            deftext = deftext + "\n  Font/file names\n"
            for (param, defv) in deffiles:
                deftext = deftext + '    {:<20}{}\n'.format(param, defv)
        if defother:
            deftext = deftext + "\n  Other parameters\n"
            for (param, defv) in defother:
                deftext = deftext + '    {:<20}{}\n'.format(param, defv)
        parser.epilog = deftext + "\n\n" + parser.epilog

    # Parse the command-line arguments. If errors or -h used, procedure will exit here
    args = parser.parse_args(argv[1:])

    # Process the first positional parameter to get defaults for file names
    fppval = getattr(args, arginfo[0]['name'])
    if isinstance(fppval, list): # When nargs="+" or nargs="*" is used a list is returned
        (fppath, fpbase, fpext) = splitfn(fppval[0])
        if len(fppval) > 1 : fpbase = "wildcard"
    else:
        if fppval is None: fppval = ""  # For scripts that can be run with no positional parameters
        (fppath, fpbase, fpext) = splitfn(fppval)  # First pos param use for defaulting

    # Process parameters
    if chain:
        execparams = params.sets["main"]
        args.params = {}  # clparams not used when chaining
    else:
        # Read config file from disk if it exists
        configname = os.path.join(fppath, "pysilfont.cfg")
        if os.path.exists(configname):
            params.addset("config file", configname, configfile=configname)
        else:
            params.addset("config file")  # Create empty set
        if not quiet and "scrlevel" in params.sets["config file"]: logger.scrlevel = params.sets["config file"]["scrlevel"]

        # Process command-line parameters
        clparams = {}
        if 'params' in args.__dict__:
            if args.params is not None:
                for param in args.params:
                    x = param.split("=", 1)
                    if len(x) != 2:
                        logger.log("params must be of the form 'param=value'", "S")
                    if x[1] == "\\t": x[1] = "\t"  # Special handling for tab characters
                    clparams[x[0]] = x[1]

        args.params = clparams
        params.addset("command line", "command line", inputdict=clparams)
        if not quiet and "scrlevel" in params.sets["command line"]: logger.scrlevel = params.sets["command line"]["scrlevel"]

        # Create main set of parameters based on defaults then update with config file values and command line values
        params.addset("main", copyset="default")
        params.sets["main"].updatewith("config file")
        params.sets["main"].updatewith("command line")
        execparams = params.sets["main"]

    # Set up logging
    if chain:
        setattr(args, 'logger', logger)
        args.logfile = logger.logfile
    else:
        logfile = None
        logname = args.log if 'log' in args.__dict__ and args.log is not None else ""
        if 'log' in args.__dict__:
            if logdef is not None and (logname != "" or optlog == False):
                (path, base, ext) = splitfn(logname)
                (dpath, dbase, dext) = splitfn(logdef)
                if not path:
                    if base and ext:  # If both specified then use cwd, ie no path
                        path = ""
                    else:
                        path = (fppath if dpath == "" else os.path.join(fppath, dpath))
                        path = os.path.join(path, "logs")
                if not base:
                    if dbase == "":
                        base = fpbase
                    elif dbase[0] == "_":  # Append to font name if starts with _
                        base = fpbase + dbase
                    else:
                        base = dbase
                if not ext and dext: ext = dext
                logname = os.path.join(path, base+ext)
            if logname == "":
                logfile = None
            else:
                (logname, logpath, exists) = fullpath(logname)
                if not exists:
                    (parent,subd) = os.path.split(logpath)
                    if subd == "logs" and os.path.isdir(parent): # Create directory if just logs subdir missing
                        logger.log("Creating logs subdirectory in " + parent, "P")
                        os.makedirs(logpath, exist_ok=True)
                    else: # Fails, since missing dir is probably a typo!
                        logger.log("Directory " + parent + " does not exist", "S")
                logger.log('Opening log file for output: ' + logname, "P")
                try:
                    logfile = open(logname, "w", encoding="utf-8")
                except Exception as e:
                    print(e)
                    sys.exit(1)
                args.log = logfile
        # Set up logger details
        logger.loglevel = execparams['loglevel'].upper()
        logger.logfile = logfile
        if not quiet: logger.scrlevel = "E"  # suppress next log message from screen
        logger.log("Running:  " + " ".join(argv), "P")
        if not quiet: logger.scrlevel = execparams['scrlevel'].upper()
        setattr(args, 'logger', logger)

# Process the argument values returned from argparse

    outfont = None
    infontlist = []
    for c, ainfo in enumerate(arginfo):
        aval = getattr(args, ainfo['name'])
        if ainfo['name'] in ('params', 'log'): continue  # params and log already processed
        atype = None
        adef = None
        if 'type' in ainfo:
            atype = ainfo['type']
            if atype not in ('infont', 'outfont', 'infile', 'outfile', 'incsv', 'filename', 'optiondict'):
                logger.log("Invalid type of " + atype + " supplied in argspec", "X")
            if atype != 'optiondict':  # All other types are file types, so adef must be set, even if just to ""
                adef = ainfo['def'] if 'def' in ainfo else ""
            if adef is None and aval is None:  # If def explicitly set to None then this is optional
                setattr(args, ainfo['name'], None)
                continue

        if c == 0:
            if aval is None : logger.log("Invalid first positional parameter spec", "X")
            if aval[-1] in ("\\","/"): aval = aval[0:-1]  # Remove trailing slashes
        else:  #Handle defaults for all but first positional parameter
            if adef is not None:
                if not aval: aval = ""
#                if aval == "" and adef == "":  # Only valid for output font parameter
#                    if atype != "outfont":
#                        logger.log("No value suppiled for " + ainfo['name'], "S")
#                        ## Not sure why this needs to fail - we need to cope with other optional file or filename parameters
                (apath, abase, aext) = splitfn(aval)
                (dpath, dbase, dext) = splitfn(adef)  # dpath should be None
                if not apath:
                    if abase and aext:  # If both specified then use cwd, ie no path
                        apath = ""
                    else:
                        apath = fppath
                if not abase:
                    if dbase == "":
                        abase = fpbase
                    elif dbase[0] == "_":  # Append to font name if starts with _
                        abase = fpbase + dbase
                    else:
                        abase = dbase
                if not aext:
                    if dext:
                        aext = dext
                    elif (atype == 'outfont' or atype == 'infont'): aext = fpext
                aval = os.path.join(apath, abase+aext)

        # Open files/fonts
        if atype == 'infont':
            if tool is None:
                logger.log("Can't specify a font without a font tool", "X")
            infontlist.append((ainfo['name'], aval))  # Build list of fonts to open when other args processed
        elif atype == 'infile':
            logger.log('Opening file for input: '+aval, "P")
            try:
                aval = open(aval, "r", encoding="utf-8")
            except Exception as e:
                print(e)
                sys.exit(1)
        elif atype == 'incsv':
            logger.log('Opening file for input: '+aval, "P")
            aval = csvreader(aval, logger=logger)
        elif atype == 'outfile':
            (aval, path, exists) = fullpath(aval)
            if not exists:
                logger.log("Output file directory " + path + " does not exist", "S")
            logger.log('Opening file for output: ' + aval, "P")
            try:
                aval = open(aval, 'w', encoding="utf-8")
            except Exception as e:
                print(e)
                sys.exit(1)
        elif atype == 'outfont':
            if tool is None:
                logger.log("Can't specify a font without a font tool", "X")
            outfont = aval
            outfontpath = apath
            outfontbase = abase
            outfontext = aext

        elif atype == 'optiondict':  # Turn multiple options in the form ['opt1=a', 'opt2=b'] into a dictionary
            avaldict={}
            if aval is not None:
                for option in aval:
                    x = option.split("=", 1)
                    if len(x) != 2:
                        logger.log("options must be of the form 'param=value'", "S")
                    if x[1] == "\\t": x[1] = "\t"  # Special handling for tab characters
                    avaldict[x[0]] = x[1]
            aval = avaldict

        setattr(args, ainfo['name'], aval)

# Open fonts - needs to be done after processing other arguments so logger and params are defined

    for name, aval in infontlist:
        if chain and name == 'ifont':
            aval = chain["font"]
        else:
            if tool == "UFO": aval = Ufont(aval, params=params)
            if tool == "FT" : aval = ttLib.TTFont(aval)
            if tool == "FP" : aval = OpenFont(aval)
        setattr(args, name, aval)  # Assign the font object to args attribute

# All arguments processed, now call the main function
    setattr(args, "paramsobj", params)
    setattr(args, "cmdlineargs", argv)
    newfont = fn(args)
# If an output font is expected and one is returned, output the font
    if chainfirst: chain = True # Special handling for first call of chaining
    if newfont:
        if chain:  # return font to be handled by chain()
            return (args, newfont)
        else:
            if outfont:
                # Backup the font if output is overwriting original input font
                if outfont == infontlist[0][1]:
                    backupdir = os.path.join(outfontpath, execparams['backupdir'])
                    backupmax = int(execparams['backupkeep'])
                    backup = str2bool(execparams['backup'])

                    if backup:
                        if not os.path.isdir(backupdir):  # Create backup directory if not present
                            try:
                                os.mkdir(backupdir)
                            except Exception as e:
                                print(e)
                                sys.exit(1)
                        backupbase = os.path.join(backupdir, outfontbase+outfontext)
                        # Work out backup name based on existing backups
                        nums = sorted([int(i[len(backupbase)+1-len(i):-1]) for i in glob(backupbase+".*~")])  # Extract list of backup numbers from existing backups
                        newnum = max(nums)+1 if nums else 1
                        backupname = backupbase+"."+str(newnum)+"~"
                        # Backup the font
                        logger.log("Backing up input font to "+backupname, "P")
                        shutil.copytree(outfont, backupname)
                        # Purge old backups
                        for i in range(0, len(nums) - backupmax + 1):
                            backupname = backupbase+"."+str(nums[i])+"~"
                            logger.log("Purging old backup "+backupname, "I")
                            shutil.rmtree(backupname)
                    else:
                        logger.log("No font backup done due to backup parameter setting", "I")
                # Output the font
                if tool in ("FT", "FP"):
                    logger.log("Saving font to " + outfont, "P")
                    newfont.save(outfont)
                else:  # Must be Pyslifont Ufont
                    newfont.write(outfont)
            else:
                logger.log("Font returned to execute() but no output font is specified in arg spec", "X")
    elif chain:             # ) When chaining return just args - the font can be accessed by args.ifont
        return (args, None) # ) assuming that the script has not changed the input font

    if logger.errorcount or logger.warningcount:
        message = "Command completed with " + str(logger.errorcount) + " errors and " + str(logger.warningcount) + " warnings"
        if logger.scrlevel in ("S", "E") and logname != "":
            if logger.scrlevel == "S" or logger.warningcount: message = message + " - see " + logname
        if logger.errorcount:
            if quiet: logger.raisescrlevel("E")
            logger.log(message, "E")
            logger.resetscrlevel()
        else:
            logger.log(message, "P")
        if logger.scrlevel == "P" and logger.warningcount: logger.log("See log file for warning messages or rerun with '-p scrlevel=w'", "P")
    else:
        logger.log("Command completed with no warnings", "P")

    return (args, newfont)


def chain(argv, function, argspec, font, params, logger, quiet):  # Chain multiple command-line scripts using UFO module together without writing font to disk
    ''' argv is a command-line call to a script in sys.argv format.  function and argspec are from the script being called.
    Although input font name must be supplied for the command line to be parsed correctly by execute() it is not used - instead the supplied
    font object is used. Similarly -params, logfile and quiet settings in argv are not used by execute() when chaining is used'''
    if quiet and "-q" not in argv: argv.append("-q")
    logger.log("Chaining to " + argv[0], "P")
    font = execute("UFO", function, argspec, 
        {'argv' : argv, 
            'font'  : font,
            'params': params,
            'logger': logger,
            'quiet' : quiet})
    logger.log("Returning from " + argv[0], "P")
    return font


def splitfn(fn):  # Split filename into path, base and extension
    if fn:  # Remove trailing slashes
        if fn[-1] in ("\\","/"): fn = fn[0:-1]
    (path, base) = os.path.split(fn)
    (base, ext) = os.path.splitext(base)
    # Handle special case where just a directory is supplied
    if ext == "":  # If there's an extension, treat as file name, eg a ufo directory
        if os.path.isdir(fn):
            path = fn
            base = ""
    return (path, base, ext)


def str2bool(v):  # If v is not a boolean, convert from string to boolean
    if type(v) == bool: return v
    v = v.lower()
    if v in ("yes", "y", "true", "t", "1"):
        v = True
    elif v in ("no", "n", "false", "f", "0"):
        v = False
    else:
        v = None
    return v

def fullpath(filen): # Changes file name to one with full path and checks directory exists
    fullname = os.path.abspath(filen)
    (fpath,dummy) = os.path.split(fullname)
    return fullname, fpath, os.path.isdir(fpath)
