"""warnings2 - extended object-based warnings system
release 0.1 [dev] 2006-11-20; mailto:and@doxdesk.com
"""

version_info= (0,1)
import sys
assert sys.version_info>=(2,3), 'warnings2 module requires Python 2.3 or later'

import linecache
try:
    import threading
except ImportError:
    threading= None


# Severity of a warning, affects the default action taken.
#
SEVERITIES=(
    SEVERITY_INFO,    # completely ignored by default
    SEVERITY_WARNING, # standard warning, reported once per location by default
    SEVERITY_ERROR,   # serious warning, raised as an exception by default
    SEVERITY_FATAL    # critical warning, always raised as an exception
                      # a filter can request a FATAL warning is reported or
                      # stored, but it will still *also* be raised as an
                      # exception. Hence calling warn() on a FATAL warning can
                      # be guaranteed not to return to the caller.
)= range(4)

# Actions a filter may request in response to a warning. Equivalences to
# original warnings module action strings given in brackets.
#
ACTIONS=(
    ACTION_PASS,         # no action, ask next filter; end up doing default
    ACTION_IGNORE,       # throw warning away ('ignore')
    ACTION_STORE,        # append the warning object to current context.store
    ACTION_REPORT,       # issue warning text to error stream ('always')
    ACTION_RAISE,        # raise the warning as an exception ('error')
    ACTION_ONCE_LINE,    # issue report once per class+file+line ('default')
    ACTION_ONCE_FILE,    # issue report once per class+file ('module')
    ACTION_ONCE_ANYWHERE # issue report once per class globally ('once')
)= range(8)


# Warning base ________________________________________________________________

_Warning= Warning
class Warning(_Warning):
    """Base class extended warnings should inherit from
    """
    def __init__(self, message= '', severity= SEVERITY_WARNING):
        """Initialise a warning with a given error message

        Optional initialiser parameter 'severity' is a SEVERITY constant
        dictating how the context will handle the warning by default.
        Subclasses can either allow their users to pass a severity into their
        own initialiser, or have a fixed severity they pass through to
        Warning.__init__.
        """
        _Warning.__init__(self, message)
        if severity not in SEVERITIES:
            raise ValueError('Unknown severity %r' % severity)
        self.severity= severity

    def warn(self, stacklevel= 1):
        """Set warning condition and send to the current context to handle

        Set stacklevel>1 to make the warning appear to come from further up the
        call stack.
        """
        getContext().handleWarning(self, sys._getframe(stacklevel))


# Trivial utilities  __________________________________________________________

class _container:
    """Old-style class to hold arbitrary properties
    """

def _getline(filename, lineno, mg):
    """Call linecache, passing module globals (for import hacks) if allowed
    """
    if sys.version_info>=(2,5):
        return linecache.getline(filename, lineno, mg)
    else:
        return linecache.getline(filename, lineno)

class _StringStream(object):
    """Lame minimal StringIO for formatwarning compatibility
    """
    def __init__(self):
        self._written= []
    def write(self, s):
        self._written.append(s)
    def read(self):
        return ''.join(self._written)


# Context for handling warnings _______________________________________________

class Context(object):
    """Warning context, stores all settings for handling warnings

    New contexts can be created and set as the current context to swap between
    different handling scenarios. Also works as a Python 2.5+ 'with' statement
    context manager, so a new Context can be entered temporarily for a code
    block.
    """
    # Private state:
    # _filters - priority-ordered list of active filter functions;
    # _history - lookup to a list of already-reported-once warning classes;
    # _previouscontexts - old context to restore after 'with'-context-manager.

    def __init__(self, stream= None):
        """New warning-handling context, logging reports to a stream

        Stream defaults to stderr.
        """
        object.__init__(self)
        if stream is None:
            stream= sys.stderr
        self._stream= stream
        self._filters= []
        self._history= {}
        self.store= []
        self._previouscontexts= []


    def addFilter(self, filter, append= False):
        """Add a filter function to the active filter list

        The function should take arguments (warning, location), where location
        is a tuple (exception traceback, file path, line number, module name),
        and return an ACTION constant.

        If the 'append' argument is True, add to the lowest-priority end of the
        list, else highest priority.
        """
        if append:
            self._filters.append(filter)
        else:
            self._filters.insert(0, filter)

    def removeFilter(self, filter):
        """Remove a filter function from the active filter list
        """
        self._filters.remove(filter)


    def handleWarning(self, warning, frame= None):
        """Filter a given warning and perform the appropriate action
        """
        # Get severity. Old-style warnings are always normal WARNING severity.
        #
        if isinstance(warning, Warning):
            severity= warning.severity
        elif isinstance(warning, _Warning):
            severity= SEVERITY_WARNING
        else:
            raise ValueError('%r not a subclass of Warning' % warning)

        # Find the source of the warning by inspecting the innermost stack
        # frame. Pass these values to filters/reportWarning to save them
        # duplicating the same inspection code.
        #
        if frame is None:
            path, lineno, module= '<string>', 1, '__main__'
        else:
            path= frame.f_code.co_filename
            ext= path[-4:].lower()
            if ext in ('.pyc', '.pyo'):
                path= path[:-1]
            lineno= frame.f_lineno
            module= frame.f_globals.get('__name__', '__main__')
        location= (frame, path, lineno, module)

        # Ask filters in turn what to do with the warning until one says
        # something other than 'pass'.
        #
        action= ACTION_PASS
        for filter in self._filters:
            action= filter(warning, location)
            if action not in ACTIONS:
                raise ValueError('Unknown action %r' % action)
            if action!=ACTION_PASS:
                break

        # Default action.
        #
        if action==ACTION_PASS:
            if severity==SEVERITY_INFO:
                action= ACTION_IGNORE
            elif severity in (SEVERITY_ERROR, SEVERITY_FATAL):
                action= ACTION_RAISE
            else:
                action= ACTION_ONCE_LINE

        # If once-only reporting requested, check whether this class (or an
        # ancestor class) has been reported ONCE before.
        #
        if action in (ACTION_ONCE_LINE,ACTION_ONCE_FILE,ACTION_ONCE_ANYWHERE):
            if action==ACTION_ONCE_LINE:
                classes= self._history.setdefault(path, {}).setdefault(lineno, [])
            if action==ACTION_ONCE_FILE:
                classes= self._history.setdefault(path, {}).setdefault(None, [])
            if action==ACTION_ONCE_ANYWHERE:
                classes= self._history.setdefault(None, [])
            action= ACTION_REPORT
            for class_ in classes:
                if isinstance(warning, class_):
                    action= ACTION_IGNORE
                    break
            else:
                classes.append(warning.__class__)

        # Take action (if any).
        #
        if action==ACTION_STORE:
            self.store.append(warning)
        if action==ACTION_REPORT:
            try:
                self.reportWarning(warning, location)
            except IOError:
                pass
        if action==ACTION_RAISE or severity==SEVERITY_FATAL:
            # Escalate warning to error. It would be nice to raise it from the
            # supplied stack frame (ie. appearing to come from the warn() call)
            # but this is not currently possible with Python's built-in trace
            # objects.
            #
            raise warning


    def reportWarning(self, warning, location):
        """Format and output a report for a given warning
        """
        frame, path, lineno, _= location
        mg= {}
        if frame is not None:
            mg= frame.f_globals
        line= ''
        if not (path[:1]=='<' and path[-1:]=='>'):
            line= _getline(path, lineno, mg).strip()
        if line!='':
            line= '  %s\n' % line
        message= '%s:%i: %s: %s\n%s' % (
            path, lineno, warning.__class__.__name__, str(warning), line
        )
        self._stream.write(message)


    # Context as Python 2.5 'with' statement context manager, so one can
    # easily be temporarily entered for the course of a code block. Store the
    # old context in a stack structure so that the same Context can be entered
    # multiple times.
    #
    # Can also be used manually in any Python version by calling __enter__ at
    # the start then __exit__ in a 'finally' block.
    #
    # The 'as' value returned is the stored-warning list.
    #
    def __enter__(self):
        self._previouscontexts.append(getContext())
        setContext(self)
        return self.store

    def __exit__(self, t, v, tb):
        previous= self._previouscontexts.pop()
        setContext(previous)

    def copy(self):
        """Return a new context using same filters and sharing ONCE_ history

        Filters could then be added to or removed without affecting the
        original context's state, allowing it to be returned to later.

        Subclasses should override copy() if their initialiser has different
        arguments.
        """
        context= self.__class__(self._stream)
        context._filters= self._filters[:]
        context._history= self._history
        return context


class TerseContext(Context):
    """A warning context whose reports are reduced to fit on a single line
    """
    _WIDTH= 80
    def reportWarning(self, warning, location):
        _, _, lineno, module= location
        message= '%s:%i: %s: %s' % (
            module, lineno, warning,__class__.__name__, str(warning)
        )
        self._stream.write(message[:self._WIDTH])

class VerboseContext(Context):
    """A alternative warning context that reports full tracebacks
    """
    def reportWarning(self, warning, location):
        frame, _, _, _= location
        frames= []
        while frame is not None:
            frames.append(frame)
            frame= frame.f_back
        frames.reverse()

        if frames!=[]:
            self._stream.write('Warning traceback (most recent call last):\n')
        for frame in frames:
            self._stream.write('  File "%s", line %d, in %s' % (
                frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name
            ))
            line= ''
            if not (path[:1]=='<' and path[-1:]=='>'):
                line= _getline(filename, lineno, mg).strip()
            if line!='':
                self._stream.write('    %s\n' % line)

        message= '%s: %s\n\n' % (warning.__class__.__name__, str(warning))
        self._stream.write(message)


# Context management __________________________________________________________

# Thread context (if any) is stored as thread local (or in the thread object
# if thread locals unavailable, Python <2.4).
#
_global= _container()
_global.__warnings2_context__= Context()
if hasattr(threading, 'local'):
    _local= threading.local()
def _getThreadLocal():
    if hasattr(threading, 'local'):
        return _local
    else:
        return threading.currentThread()

def getContext(threadlocal= None):
    """Return the currently-active warnings-handling Context

    threadlocal= True: get context override for this thread or None if none set
    threadlocal= False: get global context for threads without a local override
    threadlocal= None (default): get active context, local if there is one,
    else global.
    """
    if threadlocal or threadlocal is None:
        context= getattr(_getThreadLocal(), '__warnings2_context__', None)
        if threadlocal or context is not None:
            return context
    return _global.__warnings2_context__


def setContext(context, threadlocal= None):
    """Replace the current Context

    threadlocal= True: set context override for this thread only
    threadlocal= False: set context for all threads without local context
    threadlocal= None (default): same as True unless Python has been
    compiled without threading.
    """
    store= _global
    if threadlocal or (threadlocal is None and threading is not None):
        store= _getThreadLocal()
    store.__warnings2_context__= context


# Convenience functions _______________________________________________________

def makeSimpleFilter(classes, action):
    """Factory function for trivial class-based warning filters

    Pass a Warning class or sequence of classes, and the action to take when
    any instance of this class or a subclass is raised.
    """
    if type(classes) is not type(Warning):
        classes= tuple(classes) # isinstance can work on tuple not any sequence
    def simplefilter(warning, location):
        if isinstance(warning, classes):
            return action
        return ACTION_PASS
    return simplefilter


def ignoring(*classes):
    """Make context based on current, but ignoring the given warning classes

    Ideal for use in a with-block (eg. with ignoring(DeprecationWarning): ...).
    """
    context= getContext().copy()
    context.addFilter(makeSimpleFilter(classes, ACTION_IGNORE))
    return context

def storing(*classes):
    """Make context based on current, but storing the given warning classes

    Because 'with context as...' assigns the context itself, you can use the
    'as' variable to retrieve the context.store list afterwards. eg.

        with storing(SyntaxWarning) as log:
            ...
        warningn= len(log.store)
    """
    context= getContext().copy()
    context.addFilter(makeSimpleFilter(classes, ACTION_STORE))
    return context

def reporting(*classes):
    """Make context based on current, but always reporting the given classes
    """
    context= getContext().copy()
    context.addFilter(makeSimpleFilter(classes, ACTION_REPORT))
    return context

def raising(*classes):
    """Make context based on current, but always raising the given classes
    """
    context= getContext().copy()
    context.addFilter(makeSimpleFilter(classes, ACTION_RAISE))
    return context


# Compatibility interface _____________________________________________________

# Module can be used as a drop-in replacement for the standard warnings module.

def warn(message, category= None, stacklevel= 1):
    """warnings-compatibility. Issue warning, or maybe ignore, store or raise
    """
    warning= _fixWarning(message, category)
    getContext().handleWarning(warning, sys._getframe(stacklevel))


def warn_explicit(
    message, category, filename, lineno,
    module= None, registry= None, module_globals= None
):
    """warnings-compatibility. Issue warning from explicit args
    """
    warning= _fixWarning(message, category)
    frame= _fixFrame(filename, lineno, module, module_globals)
    getContext().handleWarning(message, trace)


def showwarning(message, category, filename, lineno, file= None):
    """warnings-compatibility. Write a warning to a file

    Monkey-patching this will no longer be effective, as it is not called back,
    reporting is done in the Context class.
    """
    warning= _fixWarning(message, category)
    frame= _fixFrame(filename, lineno)
    location= frame, filename, lineno, filename
    getContext().reportWarning(message, location)

def formatwarning(message, category, filename, lineno):
    """warnings-compatibility. Function to format a warning the standard way
    """
    warning= _fixWarning(message, category)
    trace= _fixTrace(filename, lineno)
    location= trace, filename, lineno, filename
    stream= _stringStream()
    Context(stream).reportWarning(message, location)
    return stream.read()


_STRINGACTIONS= {
  'error': ACTION_RAISE, 'ignore': ACTION_IGNORE, 'always': ACTION_REPORT,
  'default': ACTION_ONCE_LINE, 'module': ACTION_ONCE_FILE,
  'once': ACTION_ONCE_ANYWHERE
}
def filterwarnings(
    action, message='', category=_Warning, module= '', lineno= 0, append= False
):
    """warnings-compatibility. Insert entry into the list of warnings filters
    """
    import re
    message, module= re.compile(message, re.I), re.compile(module, re.I)
    if action not in _STRINGACTIONS:
        raise ValueError('Unknown action %r' % action)
    action= _STRINGACTIONS[action]

    def filter(warning, location):
        _, _, thislineno, thismodule= location
        if (
            message.match(str(warning)) and module.match(thismodule) and
            isinstance(warning, category) and (lineno==0 or thislineno==lineno)
        ):
            return action
        return ACTION_PASS
    getContext().addFilter(filter, append)

def simplefilter(action, category= _Warning, lineno= 0, append= False):
    """warnings-compatibility. Insert a simple entry into the list of filters
    """
    if action not in _STRINGACTIONS:
        raise ValueError('Unknown action %r' % action)
    action= _STRINGACTIONS[action]

    def filter(warning, location):
        _, _, thislineno, _= location
        if isinstance(warning, category) and (lineno==0 or thislineno==lineno):
            return action
        return ACTION_PASS
    getContext().addFilter(filter, append)

def resetwarnings():
    """warnings-compatibility. Clear warning filters, so no filters are active
    """
    getContext()._filters= []


# Compatibility helpers
#
def _fixWarning(message, category):
    """Get a Warning object from possible compatibility arguments
    """
    if category is None:
        category= UserWarning
    if isinstance(message, _Warning):
        return message
    else:
        assert issubclass(category, _Warning)
        return category(message)

def _fixFrame(filename, lineno, module= None, module_globals= None):
    """Make a dummy traceback object from compatibility position arguments
    """
    if module_globals is None:
        module_globals= {'__name__': module or filename}
    frame= _container()
    frame.f_globals= module_globals
    frame.f_code= _container()
    frame.f_code.co_filename, frame.f_code. co_name= filename, '?'
    return frame


# Command-line -W option processing ___________________________________________

# Copied directly from the original warnings module

class _OptionError(Exception):
    """Exception used by option processing helpers."""
    pass

# Helper to process -W options passed via sys.warnoptions
def _processoptions(args):
    for arg in args:
        try:
            _setoption(arg)
        except _OptionError, msg:
            print >>sys.stderr, "Invalid -W option ignored:", msg

# Helper for _processoptions()
def _setoption(arg):
    import re
    parts = arg.split(':')
    if len(parts) > 5:
        raise _OptionError("too many fields (max 5): %r" % (arg,))
    while len(parts) < 5:
        parts.append('')
    action, message, category, module, lineno = [s.strip() for s in parts]
    action = _getaction(action)
    message = re.escape(message)
    category = _getcategory(category)
    module = re.escape(module)
    if module:
        module = module + '$'
    if lineno:
        try:
            lineno = int(lineno)
            if lineno < 0:
                raise ValueError
        except (ValueError, OverflowError):
            raise _OptionError("invalid lineno %r" % (lineno,))
    else:
        lineno = 0
    filterwarnings(action, message, category, module, lineno)

# Helper for _setoption()
def _getaction(action):
    if not action:
        return "default"
    if action == "all": return "always" # Alias
    for a in ('default', 'always', 'ignore', 'module', 'once', 'error'):
        if a.startswith(action):
            return a
    raise _OptionError("invalid action: %r" % (action,))

# Helper for _setoption()
def _getcategory(category):
    import re
    if not category:
        return _Warning
    if re.match("^[a-zA-Z0-9_]+$", category):
        try:
            cat = eval(category) # gnuk?!
        except NameError:
            raise _OptionError("unknown warning category: %r" % (category,))
    else:
        i = category.rfind(".")
        module = category[:i]
        klass = category[i+1:]
        try:
            m = __import__(module, None, None, [klass])
        except ImportError:
            raise _OptionError("invalid module name: %r" % (module,))
        try:
            cat = getattr(m, klass)
        except AttributeError:
            raise _OptionError("unknown warning category: %r" % (category,))
    if not issubclass(cat, _Warning):
        raise _OptionError("invalid warning category: %r" % (category,))
    return cat


# If this module is taking the place of warnings.py, consume -W args and by
# default ignore trivial warnings.
#
# Probably if this were a standard module these should become SEVERITY_INFO
# Warnings instead so the unfilitered default action would suffice.
#
_DEFAULT_IGNORE= [PendingDeprecationWarning]
try: _DEFAULT_IGNORE.append(ImportWarning)
except NameError: pass
    
if __name__=='warnings':
    _processoptions(sys.warnoptions)
    filter= makeSimpleFilter(_DEFAULT_IGNORE, ACTION_IGNORE)
    getContext(False).addFilter(filter)
