"""$URL: svn+ssh://svn.mems-exchange.org/repos/trunk/grouch/lib/valuetype.py $
$Id: valuetype.py 24750 2004-07-21 15:26:51Z dbinger $

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

import string
import types
from sets import Set
from copy import copy

from grouch.util import \
     isinstance, is_instance_object, \
     get_class_object, \
     get_full_classname, get_type_name


# 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)


    # -- 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 simple_check (self, value):
        raise NotImplementedError, "subclasses must implement"

    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 AtomicType instances is a bit tricky, because each
    # AtomicType carries around a type object in its 'type' attribute,
    # but you can't pickle type objects.  Thus we have to get rid of the
    # type objects at pickle-time, but we can't reconstitute them at
    # unpickle-time, because we need the enclosing ObjectSchema to
    # lookup the type name from the type object.  Depending on what
    # order things were pickled in, our schema may or may not be
    # available or complete, so we can't depend on it being there.
    #
    # The solution is to leave the 'type' attribute None (which is what
    # it's set to at pickle-time), and then lookup the type object in
    # the schema only when we need it, ie. when either 'get_type()' or
    # 'check_value()' is called.

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


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

    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):
        if self.type is None:
            self.type = self.schema.get_atomic_type_object(self.type_name)
        return self.type


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

    def simple_check (self, value):
        vtype = type(value)
        if self.type is None:           # first use after unpickling?
            self.type = self.schema.get_atomic_type_object(self.type_name)
        return (vtype is self.type) or self.check_none_ok(value)

    def check_value (self, value, context):
        self.count_object(context)
        if self.simple_check(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 __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)
            #print "activated %r" % value

            if hasattr(value, '_p_oid'):
                obj_id = "oid %r, id %x" % (value._p_oid, id(value))
            else:
                obj_id = "id %x" % id(value)


        all_ok = 1

        # Some ExtensionClasses (OOBTree in particular) don't provide
        # a __dict__ for their instances; treat that as an empty
        # instance attribute dictionary.
        if hasattr(value, '__dict__'):
            actual_dict = vars(value)
        else:
            actual_dict = {}

        for (actual_attr, val) in actual_dict.items():
            if actual_attr.startswith('__') and actual_attr.endswith('__'):
                continue
            if context.mode == "zodb" and actual_attr.startswith("_v_"):
                continue

            if not klass_def.get_attribute_type(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 actual_dict.has_key(expected_attr):
                val = actual_dict[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

        if context.mode == "zodb" and hasattr(value, '_p_deactivate'):
            #print "deactivating %r" % value
            value._p_deactivate()

        return all_ok

    def simple_check (self, value):
        return (self.check_none_ok(value) or
                isinstance(value, get_class_object(self.klass_name)))

    def check_value (self, value, context):

        # 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,
        "set" : Set,
        }

    def simple_check (self, value):
        return (self.check_none_ok(value) or
                type(value) is self.container_type_object[self.container_type])

    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 __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 __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 __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 SetType (ContainerType):
    """
    Instance attributes:
      element_type : ValueType
        the type that all elements of the set must be
    """

    container_type = "set"

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

    def __str__ (self):
        return "{%s}" % self.element_type

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

    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) # XXX sets are unordered, fix?
            ok = self.element_type.check_value(item, context)
            if not ok:
                all_ok = 0
            context.pop_context()
            i = i + 1

        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 __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 simple_check (self, value):
        return (self.check_none_ok(value) or
                (self.container_type.simple_check(value) and
                 InstanceType.simple_check(self, value)))

    def check_value (self, value, context):
        if self.check_none_ok(value):
            return 1
        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 __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 simple_check (self, value):
        # XXX using sub-type's simple_check() here is not perfect:
        # consider what happens with a declaration like
        #   x : [int] | [string]
        # ...as long as x is a list, either branch is OK, so we'll
        # assume x is a list of ints.  If it's really a list of strings,
        # we'll barf unfairly all over it.  But it's not enough to
        # just go down one level into the list; what if that declaration
        # were
        #   x : [(int,string)] | [(string,int)]
        # ?  How far down do you have to go to differentiate the
        # branches of a UnionType?  I suspect this is a lost cause,
        # which is why I'm punting and leaving it obviously broken
        # (for the "[int] | [string]" case).

        for subtype in self.union_types:
            if subtype.simple_check(value):
                return subtype
        return None

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

        subtype = self.simple_check(value)
        if subtype:
            ok = subtype.check_value(value, context)
            subtype.uncount_object(context)
            return ok
        else:
            types = self.union_types
            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 simple_check (self, value):
        return value is not None or self.allow_none

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

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

    metatype = "boolean"

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

    def __str__ (self):
        return "boolean"

    def simple_check (self, value):
        return value in (0, 1, None)

    def check_value (self, value, context):
        self.count_object(context)
        if self.simple_check(value):
            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 __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 simple_check (self, value):
        return self.alias_type.simple_check(value)

    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)" % (get_type_name(type(value)), str_value)
