"""Support for checking Scenic types.
This module provides a system for checking that values passed to Scenic operators and
functions have the expected types. The top-level function `toTypes` and its
specializations `toType`, `toVector`, `toScalar`, etc. can also *coerce* closely-related
types into the desired type in some cases. For lazily-evaluated values (random values and
delayed arguments of specifiers), it may not be possible to determine the type at object
creation time: in such cases these functions return a lazily-evaluated object that
performs the type check either during specifier resolution or sampling as needed.
In general, the only objects which are coercible to a type T are instances of that type,
together with `Distribution` objects whose **_valueType** is a type coercible to T (and
therefore whose sampled value can be coerced to T). However, we also have the following
exceptional rules:
* Coercible to a scalar (type `float`):
* Instances of `numbers.Real` (coerced by calling `float` on them);
this includes NumPy types such as `numpy.single`
* Coercible to a heading (type `Heading`):
* Anything coercible to a scalar
* Any type with a **toHeading** method (including `OrientedPoint`)
* Coercible to a vector (type `Vector`):
* Tuples and lists of length 2 or 3
* Any type with a **toVector** method (including `Point`)
* Coercible to a `Behavior`:
* Subclasses of `Behavior` (coerced by calling them with no arguments)
* `None` (considered to have type `Behavior` for convenience)
"""
import inspect
import numbers
import sys
import typing
from typing import get_args as get_type_args, get_origin as get_type_origin
from scenic.core.distributions import (
Distribution,
RejectionException,
StarredDistribution,
TupleDistribution,
distributionFunction,
supportInterval,
toDistribution,
)
from scenic.core.errors import saveErrorLocation
from scenic.core.lazy_eval import (
DelayedArgument,
needsLazyEvaluation,
requiredProperties,
valueInContext,
)
## Basic types
[docs]class Heading(float):
"""Dummy class used as a target for type coercions to headings."""
pass
[docs]def underlyingType(thing):
"""What type this value ultimately evaluates to, if we can tell."""
if isinstance(thing, Distribution):
return thing._valueType
elif isinstance(thing, TypeChecker) and len(thing.types) == 1:
return thing.types[0]
elif isinstance(thing, TypeChecker):
return object
else:
return type(thing)
[docs]def isA(thing, ty):
"""Is this guaranteed to evaluate to a member of the given Scenic type?"""
# TODO: Remove this hack once type system is smarter.
if not isinstance(underlyingType(thing), type):
return False
return issubclass(underlyingType(thing), ty)
[docs]def unifyingType(opts): # TODO improve?
"""Most specific type unifying the given values."""
# Gather underlying types of all options
types = []
for opt in opts:
if isinstance(opt, StarredDistribution):
ty = underlyingType(opt)
typeargs = get_type_args(ty)
if typeargs == ():
types.append(ty)
else:
for ty in typeargs:
if ty is not Ellipsis:
types.append(ty)
else:
types.append(underlyingType(opt))
# Compute unifying type
return unifierOfTypes(types)
[docs]def unifierOfTypes(types):
"""Most specific type unifying the given types."""
# If all types are equal, unifier is trivial
first = types[0]
if all(ty == first for ty in types):
return first
# Otherwise, erase type parameters which we don't understand
simpleTypes = []
for ty in types:
origin = get_type_origin(ty)
simpleTypes.append(origin if origin else ty)
# Scalar types unify to float
if all(issubclass(ty, numbers.Real) for ty in simpleTypes):
return float
# For all other types, find first common ancestor in MRO
# (ignoring type parameters and skipping ABCs)
mro = inspect.getmro(simpleTypes[0])
for ancestor in mro:
if inspect.isabstract(ancestor):
continue
if all(issubclass(ty, ancestor) for ty in simpleTypes):
return ancestor
raise AssertionError(f"broken MRO for types {types}")
## Type coercions (for internal use -- see the type checking API below)
[docs]def canCoerceType(typeA, typeB):
"""Can values of typeA be coerced into typeB?"""
originA = get_type_origin(typeA)
if originA is typing.Union:
# only raise an error now if none of the possible types will work;
# we'll do more careful checking at runtime
return any(canCoerceType(ty, typeB) for ty in get_type_args(typeA))
elif originA:
# erase type parameters which we don't know how to use
typeA = originA
if typeB == float:
return issubclass(typeA, numbers.Real)
elif typeB == Heading:
from scenic.core.vectors import Orientation
return (
canCoerceType(typeA, float)
or hasattr(typeA, "toHeading")
or issubclass(typeA, Orientation)
)
elif hasattr(typeB, "_canCoerceType"):
return typeB._canCoerceType(typeA)
else:
return issubclass(typeA, typeB)
[docs]def canCoerce(thing, ty, exact=False):
"""Can this value be coerced into the given type?"""
tt = underlyingType(thing)
if canCoerceType(tt, ty):
return True
elif (not exact) and isinstance(thing, Distribution):
return True # fall back on type-checking at runtime
else:
return False
[docs]def coerce(thing, ty, error="wrong type"):
"""Coerce something into the given type.
Used internally by `toType`, etc.; this function should not otherwise be
called directly.
"""
assert canCoerce(thing, ty), (thing, ty)
# If we are in any of the exceptional cases (see the module documentation above),
# select the appropriate helper function for performing the coercion.
realType = ty
if ty == float:
coercer = coerceToFloat
elif ty == Heading:
coercer = coerceToHeading
ty = numbers.Real
realType = float
else:
coercer = getattr(ty, "_coerce", None)
# Special case: can coerce TupleDistribution directly to Vector.
# (all other distributions require the usual checking below)
from scenic.core.vectors import Vector
if isinstance(thing, TupleDistribution) and ty is Vector:
length = len(thing)
if not (2 <= length <= 3):
msg = f"expected vector, got {thing.builder.__name__} of length {length}"
raise TypeError(f"{error} ({msg})")
return Vector(*thing)
if isinstance(thing, Distribution):
# This is a random value. If we can prove that it will always have the desired
# type, we return it unchanged; otherwise we wrap it in a TypecheckedDistribution
# for sample-time typechecking.
vt = thing._valueType
origin = get_type_origin(vt)
if origin is typing.Union:
possibleTypes = get_type_args(vt)
elif origin:
# Erase type parameters which we don't understand
possibleTypes = (origin,)
else:
possibleTypes = (vt,)
if all(issubclass(possible, ty) for possible in possibleTypes):
return thing # no coercion necessary
else:
return TypecheckedDistribution(thing, realType, error, coercer=coercer)
elif coercer:
# The destination type has special coercion rules: call the appropriate helper.
try:
return coercer(thing)
except CoercionFailure as e:
raise TypeError(f"{error} ({e.args[0]})") from None
else:
# Only instances of the destination type can be coerced into it; since coercion
# is possible, we must have such an instance, and can return it unchanged.
return thing
[docs]class CoercionFailure(Exception):
"""Raised by coercion functions when coercion is impossible.
Only used internally; will be converted to a parse error for reporting to
the user.
"""
pass
def coerceToFloat(thing) -> float:
return float(thing)
def coerceToHeading(thing) -> Heading:
from scenic.core.vectors import Orientation
if isinstance(thing, Orientation):
return thing.yaw
h = thing.toHeading() if hasattr(thing, "toHeading") else float(thing)
return h
[docs]class TypecheckedDistribution(Distribution):
"""Distribution which typechecks its value at sampling time.
Only for internal use by the typechecking system; introduced by `coerce` when it is
unable to guarantee that a random value will have the correct type after sampling.
Note that the type check is not a purely passive operation, and may actually
transform the sampled value according to the coercion rules above (e.g. a sampled
`Point` will be converted to a `Vector` in a context which expects the latter).
"""
_deterministic = True
def __init__(self, dist, ty, errorMessage, coercer=None):
super().__init__(dist, valueType=ty)
self._dist = dist
self._errorMessage = errorMessage
self._coercer = coercer
if not coercer:
# Erase any type parameters, which we don't attempt to check
origin = get_type_origin(ty)
self._checkType = origin if origin else ty
self._loc = saveErrorLocation()
def sampleGiven(self, value):
val = value[self._dist]
suffix = None
# Attempt to coerce sampled value into the desired type.
if self._coercer:
if canCoerceType(type(val), self._valueType):
try:
return self._coercer(val)
except CoercionFailure as e:
suffix = f" ({e.args[0]})"
elif isinstance(val, self._checkType):
return val
# Coercion failed, so we have a type error.
if suffix is None:
suffix = f" (expected {self._valueType.__name__}, got {type(val).__name__})"
exc = TypeError(self._errorMessage + suffix)
exc._scenic_location = self._loc
raise exc
def conditionTo(self, value):
self._dist.conditionTo(value)
def supportInterval(self):
return supportInterval(self._dist)
def __repr__(self):
return f"TypecheckedDistribution({self._dist!r}, {self._valueType!r})"
[docs]def coerceToAny(thing, types, error):
"""Coerce something into any of the given types, raising an error if impossible.
Only for internal use by the typechecking system; called from `toTypes`.
Raises:
TypeError: if it is impossible to coerce the value into any of the types.
"""
for ty in types:
if canCoerce(thing, ty):
return coerce(thing, ty, error)
from scenic.syntax.veneer import verbosePrint
verbosePrint(
f"Failed to coerce {thing} of type {underlyingType(thing)} to {types}",
file=sys.stderr,
)
raise TypeError(error)
## Top-level type checking/conversion API
[docs]def toTypes(thing, types, typeError="wrong type"):
"""Convert something to any of the given types, raising an error if impossible.
Types are tried in the order they are given: the first one compatible with the given
value is used. Coercions of closely-related types may take place as described in the
module documentation above.
If the given value requires lazy evaluation, this function returns a `TypeChecker`
object that performs the type conversion after specifier resolution.
Args:
thing: Value to convert.
types: Sequence of one or more destination types.
typeError (str): Message included in exception raised on failure.
Raises:
TypeError: if the given value is not one of the given types and cannot
be converted to any of them.
"""
thing = toDistribution(thing)
if needsLazyEvaluation(thing):
# cannot check the type now; create proxy object to check type after evaluation
return TypeChecker(thing, types, typeError)
else:
return coerceToAny(thing, types, typeError)
[docs]def toType(thing, ty, typeError="wrong type"):
"""Convert something to a given type, raising an error if impossible.
Equivalent to `toTypes` with a single destination type.
"""
return toTypes(thing, (ty,), typeError)
[docs]def toScalar(thing, typeError="non-scalar in scalar context"):
"""Convert something to a scalar, raising an error if impossible.
See `toTypes` for details.
"""
return toType(thing, float, typeError)
[docs]def toHeading(thing, typeError="non-heading in heading context"):
"""Convert something to a heading, raising an error if impossible.
See `toTypes` for details.
"""
return toType(thing, Heading, typeError)
[docs]def toOrientation(thing, typeError="non-orientation in orientation context"):
"""Convert something to an orientation, raising an error if impossible.
See `toTypes` for details.
"""
from scenic.core.vectors import Orientation
return toType(thing, Orientation, typeError)
[docs]def toVector(thing, typeError="non-vector in vector context"):
"""Convert something to a vector, raising an error if impossible.
See `toTypes` for details.
"""
from scenic.core.vectors import Vector
return toType(thing, Vector, typeError)
[docs]def evaluateRequiringEqualTypes(func, thingA, thingB, typeError="type mismatch"):
"""Evaluate the func, assuming thingA and thingB have the same type.
If func produces a lazy value, it should not have any required properties beyond
those of thingA and thingB.
Raises:
TypeError: if thingA and thingB do not have the same type.
"""
if not needsLazyEvaluation(thingA) and not needsLazyEvaluation(thingB):
if underlyingType(thingA) is not underlyingType(thingB):
raise TypeError(typeError)
return func()
else:
# cannot check the types now; create proxy object to check types after evaluation
return TypeEqualityChecker(func, thingA, thingB, typeError)
## Proxy objects for lazy type checking
[docs]class TypeChecker(DelayedArgument):
"""Checks that a given lazy value has one of a given list of types."""
def __init__(self, arg, types, error):
def check(context):
val = arg.evaluateIn(context)
return coerceToAny(val, types, error)
super().__init__(requiredProperties(arg), check)
self.inner = arg
self.types = types
def __repr__(self):
return f"TypeChecker({self.inner!r},{self.types!r})"
[docs]class TypeEqualityChecker(DelayedArgument):
"""Evaluates a function after checking that two lazy values have the same type."""
def __init__(self, func, checkA, checkB, error):
props = requiredProperties(checkA) | requiredProperties(checkB)
def check(context):
ca = valueInContext(checkA, context)
cb = valueInContext(checkB, context)
if underlyingType(ca) is not underlyingType(cb):
raise TypeError(error)
return valueInContext(func(), context)
super().__init__(props, check)
self.inner = func
self.checkA = checkA
self.checkB = checkB
def __repr__(self):
return f"TypeEqualityChecker({self.inner!r},{self.checkA!r},{self.checkB!r})"
## Utilities
[docs]def is_typing_generic(tp):
"""Whether this is a pre-3.9 generic type from the typing module."""
return isinstance(tp, typing._GenericAlias)