"""grouch.valuetype

Provides the ValueType class and its subclasses, which together
represent the Python type system as interpreted by Grouch.
"""

# created 2000/12/13, GPW (extracted from mems.tools.schema module)
# rewritten 2001/02/02, GPW (refactored overgrown ValueType class, and
#                            extracted parsing code to parsetype.py)
#
# Copyright (c) 2001 CNRI.  All Rights Reserved.

__revision__ = "$Id: valuetype.py,v 1.35 2001/09/05 14:21:23 gward Exp $"

import string
import new
import types

from grouch.util import is_class_object, is_instance_object, get_full_classname


# XXX Type-checking issues yet to be resolved
#
#  * if an "any" type has 'allow_none' false, does it allow None?
#    eg. if 'a' is declared "a : any notnone", can 'a' be None?
#  * ditto for boolean types:
#    if 'f' is decalred "f : boolean notnone", can 'f' be None?
#  * what does an empty/uninitialized union type mean?  no values at
#    all are valid?  perhaps only None should be allowed?


class ValueType:
    """
    Instance attributes:
      schema : ObjectSchema
        the object schema (atomic types, aliases, and class definitions)
        relative to which we should interpret this type
      allow_none : boolean = true
        are values allowed to be None?
    """

    # class attribute 'metatype' must be defined by each subclass
    metatype = None


    def __init__ (self, schema):
        self.schema = schema
        self.allow_none = 1

        assert self.__class__ is not ValueType,\
               "ValueType is an abstract class"
        assert self.metatype is not None,\
               "metatype must be set by subclass"


    # Subclasses must provide '__str__()'

    def __repr__ (self):
        return "<%s at %x: %s>" % (self.__class__.__name__, id(self), self)

    # Pickle support: every class that might ultimately contain some
    # AtomicType instance needs to implement this; it should call
    # '_fixup_atomic_types()' on all referenced ValueType instances.  See
    # the comment in AtomicType for details on pickle support.
    def _fixup_atomic_types (self, memo):
        pass

    # -- Accessors/modifiers -------------------------------------------

    def get_metatype (self):
        return self.metatype            # really a class attribute!


    # -- Predicates ----------------------------------------------------
    # XXX might be cleaner (and more extensible) to just "return 0"
    # everywhere here, and require subclasses to override and
    # "return 1" where applicable

    def is_atomic_type (self):
        return self.metatype == "atomic"

    def is_instance_type (self):
        return self.metatype in ("instance", "instance-container")

    def is_plain_instance_type (self):
        return self.metatype == "instance"

    def is_container_type (self):
        return self.metatype in ("container", "instance-container")

    def is_plain_container_type (self):
        return self.metatype == "container"

    def is_instance_container_type (self):
        return self.metatype == "instance-container"

    def is_union_type (self):
        return self.metatype == "union"

    def is_any_type (self):
        return self.metatype == "any"

    def is_boolean_type (self):
        return self.metatype == "boolean"

    def is_alias_type (self):
        return self.metatype == "alias"


    # -- Type-checking -------------------------------------------------

    def check_value (self, value, context):
        raise NotImplementedError, "subclasses must implement"

    def check_none_ok (self, value):
        return (value is None and self.allow_none)
    
    def check_unexpected_instance (self, value, context, allow_foreign=0):
        if not is_instance_object(value):
            return 1
        try:
            itype = self.schema.make_instance_type(value.__class__)
        except ValueError, msg:
            # XXX ugh, should probably have some custom exceptions
            msg = str(msg)              # just in case
            if not msg.startswith("class not in schema"):
                raise

            if allow_foreign:
                return 1
            else:
                context.add_error("found instance of class not in schema: %s" %
                                  get_full_classname(value.__class__))
                return 0
        else:
            return itype.check_value(value, context)

    def count_object (self, context):
        context.count_object(self)

    def uncount_object (self, context):
        context.uncount_object(self)


class AtomicType (ValueType):
    """
    Instance attributes:
      type : type
        the type object that values must match
      type_name : string
        the name of the atomic type
    """

    metatype = "atomic"

    def __init__ (self, schema):
        ValueType.__init__(self, schema)
        self.type = None
        self.type_name = None

    def __str__ (self):
        return self.type_name or "*no type*"

    # -- Pickle support ------------------------------------------------
    # (needed because you can't pickle type objects)

    # Pickling ObjectSchema instances (and their associated
    # ClassDefinitions and ValueTypes) is rather tricky because you can't
    # pickle type objects and because of the incestuous web of
    # interdependencies between the three classes.  We have to purge type
    # objects at pickle time, and then somehow reconstitute them at
    # unpickle time.
    #
    # Things gets tricky under the following circumstances:
    #  - the root of the pickle is an ObjectSchema
    #  - we're somewhere deep in the guts of the ObjectSchema and
    #    stumble across a AtomicType that wants to be unpickled
    # 
    # At this point, we need to reconstruct the AtomicType's 'type'
    # attribute, which it needs for type-checking.  We need to query the
    # ObjectSchema's 'atomic_types' dictionary to turn a type name (which
    # is what we pickled in lieu of the unpickleable type object) into its
    # type object.  However, since we're in the middle of unpickling the
    # ObjectSchema, its 'atomic_types' dictionary doesn't exist yet.  (See
    # ObjectSchema's '__{get,set}state__()'.)  We can't finish unpickling
    # the AtomicType because the ObjectSchema isn't finished unpickling,
    # and the ObjectSchema won't be usable until all of its AtomicTypes are
    # finished unpickling: Catch-22!
    #
    # The workaround is to add a "fixup" pass after unpickling is
    # all-but-finished.  (It's done as the last part of ObjectSchema's
    # '__setstate__()'.)  This gives control back to every AtomicType in
    # the schema just long enough for it to convert a type name to a type
    # object, using the now-fully-unpickled ObjectSchema.  The catch is
    # that almost every ValueType subclass needs to implement the fixup
    # method, just so we can be sure to hit every atomic type in the
    # schema.
    #
    # The catch is that pickling AtomicType instances on their own doesn't
    # work very well.  Executive summary: don't do it.  If you must, then
    # be sure you understand what you are doing, and call
    # '_fixup_atomic_types()' afterwards.
    #
    # Obviously, if the semantics of Python's pickle (specifically, the
    # order in which objects are unpickled) ever change significantly, we
    # could be in deep doo-doo here.

    def __getstate__ (self):
        if self.type is None:
            return self.__dict__
        else:
            from copy import copy
            dict = copy(self.__dict__)
            del dict['type']
            return dict

    def __setstate__ (self, dict):
        from grouch.schema import ObjectSchema
        self.__dict__.update(dict)

    def _fixup_atomic_types (self, memo):
        if not hasattr(self, 'type'):
            self.type = self.schema.get_atomic_type_object(self.type_name)


    def set_type (self, tipe):
        tipe = self.schema.get_atomic_type_object(tipe)
        self.type = tipe
        self.type_name = self.schema.get_atomic_type_name(tipe)

    def get_type (self):
        return self.type


    def check_value (self, value, context):
        self.count_object(context)
        vtype = type(value)
        if vtype is self.type or self.check_none_ok(value):
            return 1
        else:
            context.add_error("expected %s, got %s" %
                              (str(self), describe_value(value)))
            self.check_unexpected_instance(value, context)
            return 0

    def count_object (self, context):
        context.count_object(self, self.type_name)

    def uncount_object (self, context):
        context.uncount_object(self, self.type_name)

# class AtomicType


# XXX name collision w/ standard 'types' modules!
class InstanceType (ValueType):
    """
    Instance attributes:
      klass_name : string
        the (fully-qualified) name of the class
      klass_definition : ClassDefinition
        the definition of this class in the object schema (defines
        what attributes are allowed/expected and what their
        types are)
      klass : class
        a class object; values must be instances of this class

    XXX klass and klass_definition might be obsolete; they are now
    assigned only when needed, ie. in check_value()
    """

    metatype = "instance"

    def __init__ (self, schema):
        ValueType.__init__(self, schema)
        self.klass_name = None
        self.klass_definition = None
        self.klass = None

    def _fixup_atomic_types (self, memo):
        if self.klass_definition:
            self.klass_definition._fixup_atomic_types(memo)

    def __str__ (self):
        return self.klass_name or "*no class*"

    def set_class_name (self, klass_name):
        if type(klass_name) is not types.StringType:
            raise TypeError, "not a string: %r" % klass_name
        self.klass_name = klass_name

    def get_class_name (self):
        return self.klass_name


    # Helper methods for 'check_value()'

    def _find_classdef (self, value, context):
        klass = value.__class__
        if klass is self.klass:
            klass_def = self.klass_definition
        else:
            klass_def = self.schema.get_class_definition(klass)
            if not klass_def:
                context.add_error("expected instance of %s, "
                                  "got instance of unknown subclass %s"
                                  % (self.klass,
                                     get_full_classname(klass)))

        return klass_def
        
    def _check_attrs (self, value, klass_def, context):

        #print "checking " + repr(value)

        # First, make sure there are no unexpected attributes.  (Doing this
        # first -- in the instance dictionary's hash order -- means the
        # *real* type-checking loop, below, is done according to the order
        # of attributes in the class definition, ie. in a predictable,
        # canonical order.  This is a good thing, if only because it makes
        # writing tests easier.)

        if context.mode == "zodb":
            # prod ZODB into loading the object before relying on
            # the value of dir()
            dir(value)

        all_ok = 1
        actual_attrs = dir(value)
        for actual_attr in actual_attrs:
            if context.mode == "zodb" and actual_attr.startswith("_v_"):
                continue

            if not klass_def.get_attribute_type(actual_attr):
                val = getattr(value, actual_attr)
                context.add_error("found unexpected attribute '%s': %s" %
                                  (actual_attr, describe_value(val)))

                context.push_context('attr', actual_attr)
                self.check_unexpected_instance(val, context)
                context.pop_context()

                all_ok = 0

        # Now the real type-checking loop, in canonical (not hash)
        # order.  By-the-by, we also check for missing attributes here.
        for expected_attr in klass_def.all_attrs:
            if expected_attr in actual_attrs:
                val = getattr(value, expected_attr)
                expected_type = klass_def.get_attribute_type(expected_attr)
                context.push_context('attr', expected_attr)
                ok = expected_type.check_value(val, context)
                if not ok:
                    all_ok = 0

                context.pop_context()
            else:
                context.add_error("expected attribute '%s' not found" %
                                  expected_attr)
                all_ok = 0

        return all_ok

    def check_value (self, value, context):
        # get the special isinstance() that knows about ExtensionClass
        from grouch.util import isinstance, get_class_object

        # XXX from Jim Fulton on zodb-dev@zope.org, about how to
        # reduce memory use of the type-checking script:
        # 
        # You could modify your script to your script to "ghostify" the
        # objects, by calling _p_deactivate on them after you use them
        # to prevent the memory bloat.  This is what the Zope "find"
        # facility does when searching for objects.
        #
        # I think this is the place to do it, but this method would need
        # a bit of rearrangement.

        if self.klass is None:
            self.klass = get_class_object(self.klass_name)
        if self.klass_definition is None:
            self.klass_definition = \
                self.schema.get_class_definition(self.klass_name)

        self.count_object(context)
        if isinstance(value, self.klass):
            klass_def = self._find_classdef(value, context)
            if not klass_def:
                return 0
            
            if context.check_seen(value):
                # No new errors to add here, so act as though the guts of
                # the object are fine.
                self.uncount_object(context)
                return 1
            
                # XXX maybe the context should keep track of which
                # instances/containers had errors in them, so we can say
                # "second visit to object with errors" -- hmm, that'll only
                # work with instances, because a container might visited
                # twice with different declarations in force.

            #print "checking", repr(value)
            return self._check_attrs(value, klass_def, context)

        elif self.check_none_ok(value):
            return 1

        else:
            context.add_error("expected instance of %s, got %s" %
                              (self.klass, describe_value(value)))
            self.check_unexpected_instance(value, context)
            return 0

        # if/elif/else

    # check_value ()

    def count_object (self, context):
        context.count_object(self, self.klass_name)

    def uncount_object (self, context):
        context.uncount_object(self, self.klass_name)

# class InstanceType


class ContainerType (ValueType):
    """
    Instance attributes:
      allow_empty : boolean = true
        only for container and instance-container types: are
        values allowed to be empty containers?
    """

    metatype = "container"
    container_type = None               # subclasses must set

    def __init__ (self, schema):
        assert self.__class__ is not ContainerType,\
               "ContainerType is an abstract class"

        ValueType.__init__(self, schema)
        self.allow_empty = 1


    # -- Type-checking methods -----------------------------------------

    container_type_object = {
        "list" : types.ListType,
        "tuple" : types.TupleType,
        "dictionary" : types.DictionaryType,
        }

    def check_value (self, value, context):
        self.count_object(context)
        if type(value) is self.container_type_object[self.container_type]:
            if len(value) == 0 and self.container_type != 'tuple':
                if not self.allow_empty:
                    context.add_error("empty %s not allowed" %
                                      self.container_type)
                    return 0
                else:
                    return 1

            return self.check_contents(value, context)
        elif self.check_none_ok(value):
            return 1
        else:
            context.add_error("expected %s, got %s" %
                              (self.container_type, describe_value(value)))
            self.check_unexpected_instance(value, context)
            return 0

    def count_object (self, context):
        context.count_object(self, self.container_type)

    def uncount_object (self, context):
        context.uncount_object(self, self.container_type)

# class ContainerType


class ListType (ContainerType):
    """
    Instance attributes:
      element_type : ValueType
        the type that all elements of the list must be
    """

    container_type = "list"

    def __init__ (self, schema):
        ContainerType.__init__(self, schema)
        self.element_type = None

    def _fixup_atomic_types (self, memo):
        self.element_type._fixup_atomic_types(memo)

    def __str__ (self):
        return "[%s]" % self.element_type

    def set_element_type (self, element_type):
        self.element_type = self.schema.get_type(element_type)

    set_list_type = set_element_type    # backwards compat.

    def get_element_type (self):
        return self.element_type


    # -- Type-checking methods -----------------------------------------

    def check_contents (self, value, context):
        i = 0
        all_ok = 1
        for item in value:
            context.push_context('item', i)
            ok = self.element_type.check_value(item, context)
            if not ok:
                all_ok = 0
            context.pop_context()
            i = i + 1
            
        return all_ok


class TupleType (ContainerType):
    """
    Instance attributes:
      element_types : [ValueType]
        the type of each "slot" in the tuple (values of this type
        must be tuples of the same length as 'element_types', and
        each element of the tuple must match the corresponding
        type in 'element_types'
      extended : boolean = false
        if true, this type is for "extended" tuples
    """

    container_type = "tuple"

    def __init__ (self, schema):
        ContainerType.__init__(self, schema)
        self.element_types = []
        self.extended = 0

    def _fixup_atomic_types (self, memo):
        for subtype in self.element_types:
            subtype._fixup_atomic_types(memo)

    def __str__ (self):
        types = self.element_types
        if self.extended:
            if len(types) == 1:
                return "(%s*,)" % types[0]
            else:
                types = map(str, types)
                return ("(%s, %s*)" %
                        (string.join(types[0:-1], ", "), types[-1]))

        else:                           # regular tuple type
            if len(types) == 1:
                return "(%s,)" % types[0]
            else:
                types = map(str, types)
                return "(%s)" % string.join(types, ", ")

    def set_element_types (self, element_types):
        if type(element_types) not in (types.TupleType, types.ListType):
            raise TypeError, \
                  "not a sequence of type objects: %s" % `element_types`
        self.element_types = map(self.schema.get_type, element_types)

    def set_extended (self, extended):
        self.extended = extended

    set_tuple_type = set_element_types  # backwards compat.

    def get_element_types (self):
        return self.element_types

    def is_extended (self):
        return self.extended

    def check_contents (self, value, context):
        types = self.element_types
        ext = self.extended

        # First, just check the length; if len(element_types) == N:
        #  - regular tuples must be of length N
        #  - extended tuples must be of length >= N-1
        #    (last item repeats 0 .. * times)

        if (not ext and len(value) != len(types)):
            context.add_error("expected tuple of length %d, "
                              "got one of length %d" %
                              (len(types), len(value)))
            all_ok = 0
        elif (ext and len(value) < len(types)-1):
            context.add_error("expected tuple of at least length %d, "
                              "got one of length %d" %
                              (len(types)-1, len(value)))
            all_ok = 0
        else:
            all_ok = 1

        # Length is OK, so now check the actual elements
        if ext:                         # check the whole tuple
            n = len(value)
        else:                           # check only what we have types for
            n = min(len(value), len(types))

        for i in range(n):
            context.push_context('item', i)
            if ext and i >= len(types):
                cur_type = types[-1]
            else:
                cur_type = types[i]

            if not cur_type.check_value(value[i], context):
                all_ok = 0
            context.pop_context()

        return all_ok


class DictionaryType (ContainerType):
    """
    Instance attributes:
      key_type : ValueType
      value_type : ValueType

    """

    container_type = "dictionary"

    def __init__ (self, schema):
        ContainerType.__init__(self, schema)
        self.key_type = None
        self.value_type = None

    def _fixup_atomic_types (self, memo):
        self.key_type._fixup_atomic_types(memo)
        self.value_type._fixup_atomic_types(memo)

    def __str__ (self):
        return "{ %s : %s }" % (self.key_type, self.value_type)

    def set_key_type (self, key_type):
        self.key_type = self.schema.get_type(key_type)

    def set_value_type (self, value_type):
        self.value_type = self.schema.get_type(value_type)

    def set_item_types (self, key_type, value_type):
        self.key_type = self.schema.get_type(key_type)
        self.value_type = self.schema.get_type(value_type)

    set_dictionary_type = set_item_types # backwards compat.


    def get_key_type (self):
        return self.key_type

    def get_value_type (self):
        return self.value_type

    def get_item_types (self):
        return (self.key_type, self.value_type)


    def check_contents (self, value, context):
        all_ok = 1
        for key in value.keys():
            context.push_context('dkey', key)
            if not self.key_type.check_value(key, context):
                all_ok = 0

            context.replace_context('dval', key)
            val = value[key]
            if not self.value_type.check_value(val, context):
                all_ok = 0

            context.pop_context()

        return all_ok


class InstanceContainerType (InstanceType):
    """
    Instance attributes:
      container_type : ContainerType
    """

    metatype = "instance-container"

    def __init__ (self, schema):
        InstanceType.__init__(self, schema)
        self.container_type = None

    def _fixup_atomic_types (self, memo):
        InstanceType._fixup_atomic_types(self, memo)
        self.container_type._fixup_atomic_types(memo)

    def __str__ (self):
        return "%s %s" % (self.klass_name, self.container_type)

    def set_container_type (self, container_type):
        if not isinstance(container_type, ContainerType):
            raise TypeError, \
                  "not a ContainerType instance: %s" % `container_type`
        self.container_type = container_type

    def get_container_type (self):
        return self.container_type

    def get_element_type (self):
        if not isinstance(self.container_type, ListType):
            raise RuntimeError, "not a list type"
        return self.container_type.get_element_type()

    def get_element_types (self):
        if not isinstance(self.container_type, TupleType):
            raise RuntimeError, "not a tuple type"
        return self.container_type.get_element_types()

    def get_item_types (self):
        if not isinstance(self.container_type, DictionaryType):
            raise RuntimeError, "not a dictionary type"
        return self.container_type.get_item_types()


    def check_value (self, value, context):
        cont_ok = self.container_type.check_contents(value, context)
        inst_ok = InstanceType.check_value(self, value, context)
        return inst_ok and cont_ok


class UnionType (ValueType):
    """
    Instance attributes:
      union_types : [ValueType]
        a set of types; values must match one of them
    """

    metatype = "union"

    def __init__ (self, schema):
        ValueType.__init__(self, schema)
        self.union_types = None

    def _fixup_atomic_types (self, memo):
        for subtype in self.union_types:
            subtype._fixup_atomic_types(memo)

    def __str__ (self):
        types = map(str, self.union_types)
        return string.join(types, " | ")

    def set_union_types (self, union_types):
        if type(union_types) is not types.ListType:
            raise TypeError, "not a list of type objects: %s" % `union_types`
        union_types = map(self.schema.get_type, union_types)
        self.union_types = union_types

    def get_union_types (self):
        return self.union_types

    def check_value (self, value, context):

        self.count_object(context)

        # Temporarily turn off error reporting if it's on; we want to
        # replace any errors from sub-types with our own error message.
        save_report = context.report_errors
        context.report_errors = 0

        types = self.union_types
        for subtype in types:
            ok = subtype.check_value(value, context)
            subtype.uncount_object(context)
            if ok:
                context.report_errors = save_report
                return 1
            else:
                # Forget about this error message -- if *all* checks fail,
                # then we'll generate a new message; if *any* pass, then
                # there's no error.
                context.forget_errors(1)
        else:
            context.report_errors = save_report

            if len(types) == 0:
                prefix = "no valid values for empty union type"
            else:
                # Ugh, can forget about I18N of this code...
                tnames = map(str, types)
                if len(types) == 1:
                    tlist = tnames[0]
                elif len(types) == 2:
                    tlist = "%s or %s" % tuple(tnames)
                else:
                    tlist = (string.join(tnames[0:-1], ", ") +
                             ", or " + tnames[-1])
                prefix = "expected " + tlist

            context.add_error(prefix + "; got %s" % describe_value(value))
            self.check_unexpected_instance(value, context)
            return 0

    # check_value ()

    def count_object (self, context):
        context.count_object(self, str(self))

    def uncount_object (self, context):
        context.uncount_object(self, str(self))

# class UnionType


class AnyType (ValueType):
    """
    Instance attributes: none
      allow_any_instance : boolean
        if true (the default), class instances of any class are allowed;
        otherwise, instances must be of classes known to the containing
        object schema.
    """

    metatype = "any"

    def __init__ (self, schema):
        ValueType.__init__(self, schema)
        self.allow_any_instance = 1

    def __str__ (self):
        return "any"

    def set_allow_any_instance (self, allow_any_instance):
        self.allow_any_instance = allow_any_instance

    def check_value (self, value, context):
        self.count_object(context)
        if value is None and not self.allow_none:
            context.add_error("expected anything except None")
            return 0
        else:
            return self.check_unexpected_instance(
                value, context, allow_foreign=self.allow_any_instance)


class BooleanType (ValueType):
    """
    Instance attributes: none
    """

    metatype = "boolean"

    def __init__ (self, schema):
        ValueType.__init__(self, schema)

    def __str__ (self):
        return "boolean"

    def check_value (self, value, context):
        self.count_object(context)
        if value in (0, 1, None):
            return 1
        else:
            context.add_error("expected a boolean, got %s" %
                              describe_value(value))
            self.check_unexpected_instance(value, context)
            return 0

class AliasType (ValueType):
    """
    Instance attributes:
      alias_name : string
        only for alias types: the name of this type (the alias)
      alias_type : ValueType
        the aliased type (what 'alias_name' stands for)
    """

    metatype = "alias"

    def __init__ (self, schema):
        ValueType.__init__(self, schema)
        self.alias_name = None
        self.alias_type = None

    def _fixup_atomic_types (self, memo):
        self.alias_type._fixup_atomic_types(memo)

    def __str__ (self):
        return self.alias_name

    def set_alias_type (self, alias_name, alias_type):
        self.alias_name = alias_name
        self.alias_type = alias_type

    def get_alias_type (self):
        return self.alias_type

    def check_value (self, value, context):
        self.count_object(context)
        return self.alias_type.check_value(value, context)

    def count_object (self, context):
        context.count_object(self, self.alias_name)

    def uncount_object (self, context):
        context.uncount_object(self, self.alias_name)

# -- Utility functions -------------------------------------------------

def describe_value (value):
    if is_instance_object(value):
        # It's an instance of either a vanilla Python class or an
        # ExtensionClass.
        return "instance of %s" % get_full_classname(value.__class__)
    elif value is None:
        return "None"
    else:
        if (type(value) in (types.ListType,
                            types.TupleType,
                            types.DictionaryType)
            and len(value) > 10):
            str_value = "... elided ..."
        elif type(value) is types.StringType and len(value) > 100:
            str_value = value[0:20] + " ... " + value[-20:]
        else:
            str_value = repr(value)
            
        return "%s (%s)" % (type(value).__name__, str_value)
