#
# CHANGES
#
# 2023, Lewis Blake, Arjo Segers
#   Formatted using "black".
#

# -------------------------------------------------
# help
# -------------------------------------------------

"""
***********************
``utopya_tools`` module
***********************

The ``utopya_tools`` module provides access to a number
of general purpose methods and classes.

Overview
========

The following methods and classes are provided:

* A method is provided to import classes from a module using a path-like name:

  * :py:meth:`ImportClass`

* Two methods are provided to create directories:

  * :py:meth:`CreateFilePath`
  * :py:meth:`CreateDirs`

* The following tools facilitate (re)writting of text files:

  * :py:meth:`WriteTextFile`
  * :py:meth:`UpdateTextFile`

* A class is provided to call a subprocess with more informative exception handling:

  * :py:class:`Call`


Methods and classes
===================
"""


# -------------------------------------------------
# modules
# -------------------------------------------------

# modules:
import logging


# -------------------------------------------------
# routines
# -------------------------------------------------


def ImportClass(moduleclass):

    """
    Import class from a module. Specify the `moduleclass` as a string::

        [path/]module.classname

    The path to the module is optional; if not specified, the module
    should be available on the search path.
    Example::

        mycls = ImportClass( '/path/to/utopya/v1.4/py/utopya.UtopyaBase' )

    """

    # modules:
    import os
    import sys
    import time
    import imp

    # import importlib

    # check ..
    if "." not in moduleclass:
        logging.error("Argument `moduleclass` should have form: <modulename>.<classname>")
        raise ValueError
    # endif

    # split at last dot:
    modulename, classname = moduleclass.rsplit(".", 1)

    # module name might include path:
    modulepath, modulename = os.path.split(modulename)
    # extend path ?
    if len(modulepath) > 0:
        sys.path.insert(0, modulepath)

    # import this module with the specified class:
    try:
        # ~ this sometimes fails if module file was just copied to a newly created directory ...
        # mod = __import__( modulename, fromlist=[classname] )
        # ~ this seems to work for the moment ...
        fp, pathname, description = imp.find_module(modulename, sys.path)
        mod = imp.load_module(modulename, fp, pathname, description)
    except ImportError:
        logging.error('Could not import module "%s" ...' % (modulename))
        logging.error("Search path:")
        for p in sys.path:
            logging.error("  %s" % p)
            modulefile = os.path.join(p, modulename + ".py")
            if os.path.isfile(modulefile):
                logging.error("    FOUND: %s" % modulefile)
        # endfor
        logging.error("Error from importing:")
        raise
    except:
        raise
    # endtry

    # create object, prepare for errors:
    try:
        cls = mod.__dict__[classname]
    except KeyError:
        logging.error("Class '%s' not defined in module '%s'" % (classname, modulename))
        sys.exit()
    except:
        logging.error(
            "Unknown error importing '%s' class from '%s' module" % (classname, modulename)
        )
        sys.exit()
    # endtry

    # remove extra path if necessary:
    if len(modulepath) > 0:
        sys.path.pop(0)

    # ok
    return cls


# enddef ImportClass


# ***


def CreateFilePath(filename):

    """
    Create directory in provided 'filename' if not present yet.
    """

    # modules:
    import os

    # directory name:
    dirname = os.path.dirname(filename)
    # defined ?
    if len(dirname) > 0:
        # not present yet ?
        if not os.path.isdir(dirname):
            os.makedirs(dirname)
        # endif
    # endif


# enddef CreateFilePath

# *


def CreateDirs(dirname, renew=False):

    """
    Create directory (including subdirs) if not present yet.
    If renew flag is set, remove existing version first.
    """

    # external:
    import os
    import shutil

    # already present ?
    if os.path.isdir(dirname):
        # remove existing directory ?
        if renew:
            # info ...
            logging.info("remove existing %s ..." % dirname)
            # use the chainsaw:
            shutil.rmtree(dirname)
        # endif
    # endif

    # not present (anymore) ?
    if not os.path.isdir(dirname):
        # info ...
        logging.info("create new directory %s ..." % dirname)
        # create directory including parents if necesary:
        os.makedirs(dirname)
    # endif


# enddef CreateDirs


# ***


def WriteTextFile(filename, text):

    """
    Write a text file.

    Arguments:

    * filename   : target file name
    * text       : list of strings, a line should end with the newline character '\\\\n'

    """

    # external:
    import os

    ## info ...
    # logging.debug( '    write %s ...' % filename )

    # create path if necessary:
    CreateFilePath(filename)

    # write new text:
    f = open(filename, "w")
    f.writelines(text)
    f.close()


# enddef WriteTextFile


# ***


def UpdateTextFile(filename, newtext):

    """
    Replace a file by a new text if the later differs
    from the current content.

    Arguments:

    * filename   : target file name
    * newtext    : list of strings, a line should end with the newline character '\\\\n'

    """

    # external:
    import os

    ## info ...
    # logging.info( '  update %s ...' % filename )
    # file exists alread?
    if os.path.exists(filename):
        # read current content:
        f = open(filename, "r")
        oldtext = f.readlines()
        f.close()
        # differences ?
        rewrite = newtext != oldtext
        ## info ...
        # for iline in range(len(oldtext)) :
        #    if iline < len(newtext) :
        #        if oldtext[iline] != newtext[iline] :
        #            logging.info( '    first different lines:' )
        #            logging.info( '      old: %s' % oldtext[iline] )
        #            logging.info( '      new: %s' % newtext[iline] )
        #            break
        #        #endif
        #    #endif
        ##endfor
    else:
        # no file yet, always rewrite:
        rewrite = True
    # endif

    # write file ?
    if rewrite:
        # for info message:
        stat = "replace"
        # write new text:
        f = open(filename, "w")
        f.writelines(newtext)
        f.close()
    else:
        # for info message:
        stat = "keep"
    # endif


# enddef UpdateTextFile

# *


def ReadNcVariable(filename, varname):

    """
    Return data and attributes from netcdf variable.

    Arguments:
      filename   :  input file (netcdf)
      varname    :  (path to) variable

    Return values:
      data     : array with data values
      attrs    : dictionairy with the variable attributes
    """

    # modules:
    import os
    import netCDF4

    # check ..
    if not os.path.isfile(filename):
        logging.error("file not found: %s" % filename)
        raise Exception
    # endif

    # open:
    ncid = netCDF4.Dataset(filename, "r")

    # main group:
    gid = ncid
    vname = varname
    vpath = "/"
    # groups?
    while "/" in vname:
        # split:
        gname, vname = vname.split("/", 1)
        # check ...
        if gname not in gid.groups.keys():
            logging.error('group "%s" not found in: %s' % (gname, filename))
            raise Exception
        # endif
        # new group:
        gid = gid.groups[gname]
        # extend:
        vpath = vpath + gname + "/"
    # endwhile
    # check ...
    if vname not in gid.variables.keys():
        logging.error('variable "%s" not found in group "%s" in: %s' % (vname, vpath, filename))
        raise Exception
    # endif
    # select:
    varid = gid.variables[vname]
    # read:
    data = varid[:]
    # attributes:
    attrs = {}
    for key in varid.ncattrs():
        attrs[key] = varid.getncattr(key)
    # endfor

    # close:
    ncid.close()

    # ok
    return data, attrs


# enddef ReadNcVariable


# -------------------------------------------------
# subprocess
# -------------------------------------------------


class Call(object):

    """
    Class to call a subprocess and trap exceptions.

    This routine differs from those provided by the standard
    :py:mod:`subprocess` module in the following ways.

    * In case the command could not be executed a :py:exc:`CallingError` is raised.
      The original command as well as the error message are included in the exception.
    * If the command returns with a non-zero exit status a :py:exc:`StatusError` is raised.
    * Standard output and standard error are written using the :py:mod:`logging` module.
      The standard output is written with loglevel `logging.INFO` if verbose=True,
      and otherwise with loglevel 'logging.DEBUG'.
      The standard error is written with level `logging.ERROR`.
      All lines written by the logging module are preceded by the optional indent.
      With the 'err2out' flag enable, std.error is written to std.output,
      which is sometimes useful to maintain the order in which messages are issued.

    The command and optional keyword arguments are passed to the
    :py:class:`subprocess.Popen` class.
    The opional input (list of str objects) will be passed to
    its :py:meth:`subprocess.Popen.communicate` method.

    Returns an object with attributes:

    * retcode   (integer)
    * stdout    (list of strings)
    * stderr    (list of strings)

    Example of usage::

        # modules:
        import logging

        # list file, log result outside call:
        try :
            p = Call( ['/bin/ls','-l'] )
        except CallingError as err :
            logging.error( str(err) )
            raise Exception
        except StatusError as err :
            for line in err.stderr : logging.error(line)
            logging.error( str(err) )
            raise Exception
        except Exception as err :
            logging.error( 'unknown error : %s' % err )
            raise Exception
        #endtry
        # display output:
        for line in p.stdout : logging.info(line)
        for line in p.stderr : logging.error(line)

        # idem, but exception handling and logging is done in the call:
        p = Call( ['/bin/ls','-l'] )


    """

    def __init__(self, command, input=None, verbose=True, indent="", err2out=False, **kwargs):

        # external:
        import subprocess

        # init outputs:
        self.stdout = []
        self.stderr = []
        self.returncode = -999

        # error destination:
        stderr_dest = subprocess.PIPE
        if err2out:
            stderr_dest = subprocess.STDOUT

        # call suprocess, catch calling errors:
        try:
            p = subprocess.Popen(
                command,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=stderr_dest,
                **kwargs
            )
        except Exception as err:
            logging.error(indent + str(err))
            raise CallingError(command, err)
        # endtry

        # also input provided?
        if input is not None:

            # send input if necessary ;
            # wait for process to terminate, receive standard output and error:
            stdout_str, stderr_str = p.communicate(input=input)

            # remove final end-of-lines:
            stdout_str = stdout_str.strip("\n")
            stderr_str = stderr_str.strip("\n")

            # store lists:
            if len(stdout_str) > 0:
                self.stdout = stdout_str.split("\n")
            else:
                self.stdout = []
            # endif
            if len(stderr_str) > 0:
                self.stderr = stderr_str.split("\n")
            else:
                self.stderr = []
            # endif

            # write standard output and error:
            # output messages:
            if verbose:
                for line in self.stdout:
                    logging.info(indent + line)
            else:
                for line in self.stdout:
                    logging.debug(indent + line)
            # endif
            # output errors:
            for line in self.stderr:
                logging.error(indent + line)

        else:

            # wait for process to terminate:
            while p.poll() == None:
                # read next line from standard output:
                line = p.stdout.readline()
                # filled?
                if len(line) > 0:
                    # convert:
                    line = line.strip().decode("utf-8")
                    # show:
                    # output message:
                    if verbose:
                        logging.info(indent + line)
                    else:
                        logging.debug(indent + line)
                    # endif
                    # store:
                    self.stdout.append(line)
                # endif
            # endwhile

            # read remaing lines:
            for line in p.stdout.readlines():
                # convert:
                line = line.strip().decode("utf-8")
                # snow:
                if verbose:
                    logging.info(indent + line)
                else:
                    logging.debug(indent + line)
                # endif
                # store:
                self.stdout.append(line)
            # endfor

            # error lines?
            if p.stderr is not None:
                # read error lines:
                for line in p.stderr.readlines():
                    # convert:
                    line = line.strip().decode("utf-8")
                    # output message:
                    logging.error(indent + line)
                    # store:
                    self.stderr.append(line)
                # endfor
            # endif

        # endif # input provided?

        # store return code:
        self.returncode = p.returncode
        # error ?
        if self.returncode != 0:
            # return status error:
            raise StatusError(command, self.returncode, self.stdout, self.stderr)
        # endif

    # enddef __init__


# endclass Call

# *


class CallingError(Exception):

    """
    This exception is raised when an attempt to call a command fails.
    The following attributes are defined:

    * command   : str object or list of str objects with the command that failed
    * strerror  : error message raised by command

    Use the 'str()' method to obtain a nicely formatted message
    including the command and the error message.

    Example of usage::

        try :
            ...
        except CallingError as err :
            print( str(err) )
    """

    def __init__(self, command, strerror):
        """
        Store exception attributes.
        """
        # store:
        self.command = command
        self.strerror = strerror

    # enddef __init__

    def __str__(self):
        """
        Return formatted erorr message.
        """
        # error message:
        return 'Error from calling "%s" : %s' % (self.command, self.strerror)

    # enddef __str__


# endclass CallingError

# *


class StatusError(Exception):

    """
    This exception is raised when a called command returns a non-zero exit status.
    The following attributes are defined:

    * command   : str object (list of) with original command
    * returncode : integer return status
    * stdout     : str list
    * stderr     : str list

    Use the 'str()' method to obtain a nicely formatted message
    including the command and the return code.

    Example of usage::

        try :
            ...
        except StatusError as err :
            print( str(err) )
    """

    def __init__(self, command, returncode, stdout, stderr):
        """
        Store exception attributes.
        """
        # store:
        self.command = command
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr

    # enddef __init__

    def __str__(self):
        """
        Format error message.
        """
        # error message:
        return 'Call "%s" returned non-zero status %i' % (str(self.command), self.returncode)

    # enddef __str__


# endclass StatusError

# -------------------------------------------------
# end
# -------------------------------------------------
