Source code for WDL.Type

"""
WDL data types

WDL has both atomic types such as ``Int``, ``Boolean``, and ``String``; and
parametric types like ``Array[String]`` and
``Map[String,Array[Array[Float]]]``. Here, each type is represented by an
immutable instance of a Python class inheriting from ``WDL.Type.Base``. Such
types are associated with expressions, statically prior to evaluation, as well
as with values and identifier bindings after evaluation.

An atomic type like ``Int`` is represented by ``WDL.Type.Int()``. Atomic types
can be checked either with ``isinstance(t,WDL.Type.Int)``, which ignores the
possible optional quantifier (thus satisfied by ``Int`` or ``Int?``), or with
``t == WDL.Type.Int(optional=True)`` to include the quantifier in the
comparison.

A parametric type like ``Array[String]`` is represented by
``WDL.Type.Array(WDL.Type.String())``. Any kind of array satisfies
``isinstance(t,WDL.Type.Array)``, and
``WDL.Type.Array(WDL.Type.String()) == WDL.Type.Array(WDL.Type.String())``, but
for example
``WDL.Type.Array(WDL.Type.String()) != WDL.Type.Array(WDL.Type.Float())``.

The type classes include a method indicating if a value of the type can be
coerced to some other desired type, according to the following rules:

1. ``Int`` coerces to ``Float``
2. ``Boolean``, ``Int``, ``Float``, and ``File`` coerce to ``String``
3. ``String`` coerces to ``File``, ``Int``, and ``Float``
4. ``Array[T]`` coerces to ``String`` provided ``T`` does as well
5. ``T`` coerces to ``T?`` but the reverse is not true in general*
6. ``Array[T]+`` coerces to ``Array[T]`` but the reverse is not true in general*

(*) The reverse coercions are statically permitted in expressions set up with
``Expr.infer_type(check_quant=False)`` although they may fail at runtime. This
also enables coercion of ``T`` to ``Array[T]+`` (an array of length 1).

.. inheritance-diagram:: WDL.Type
   :top-classes: WDL.Type.Base
"""
import copy
from abc import ABC
from typing import Optional, Tuple, Dict, Iterable, Set, List


[docs]class Base(ABC): """The abstract base class for WDL types Each specific type inherits from this base, e.g.:: assert issubclass(WDL.Type.Int, WDL.Type.Base) assert isinstance(WDL.Type.Array(WDL.Type.Int()), WDL.Type.Base) All instances are immutable. """ _optional: bool = False # immutable!!! # pos is set on Type objects instantiated by the WDL syntax parser (mainly in Decl). Other Type # objects are instantiated in other ways (e.g. Value describing itself), so will not have pos. pos: "Optional[SourcePosition]" = None
[docs] def coerces(self, rhs: "Base", check_quant: bool = True) -> bool: """ True if this is the same type as, or can be coerced to, ``rhs``. :param check_quant: when ``False``, disables static enforcement of the optional (?) type quantifier """ try: self.check(rhs, check_quant) except TypeError: return False return True
[docs] def check(self, rhs: "Base", check_quant: bool = True) -> None: """ Verify this is the same type as, or can be coerced to ``rhs``. The ``TypeError`` exception raised otherwise MAY include a specific error message (but not if the obvious "cannot coerce self to rhs" suffices). :param check_quant: when ``False``, disables static enforcement of the optional (?) type quantifier """ if not check_quant and isinstance(rhs, Array) and self.coerces(rhs.item_type, check_quant): # coerce T to Array[T] return if type(self).__name__ != type(rhs).__name__ and not isinstance(rhs, Any): raise TypeError() self._check_optional(rhs, check_quant)
def _check_optional(self, rhs: "Base", check_quant: bool) -> None: if check_quant and (self.optional and not rhs.optional and not isinstance(rhs, Any)): raise TypeError() @property def optional(self) -> bool: """ :type: bool True when the type has the optional quantifier, ``T?``""" return self._optional @property def parameters(self) -> Iterable["Base"]: """ :type: Iterable[WDL.Type.Base] The type's parameters, if any (e.g. item type of Array; left & right types of Pair; etc.) """ return []
[docs] def copy(self, optional: Optional[bool] = None) -> "Base": """ copy(self, optional : Optional[bool] = None) -> WDL.Type.Base Create a copy of the type, possibly with a different setting of the ``optional`` quantifier. """ ans: "Base" = copy.copy(self) if optional is not None: ans._optional = optional return ans
def __str__(self) -> str: return type(self).__name__ + ("?" if self.optional else "") def __eq__(self, rhs: "Base") -> bool: return isinstance(rhs, Base) and str(self) == str(rhs)
[docs]class Any(Base): """ A symbolic type which coerces to any other type; used to represent e.g. the item type of an empty array literal, or the result of read_json(). The ``optional`` attribute shall be true only for WDL ``None`` literals, which coerce to optional types only. """ def __init__(self, optional: bool = False, null: bool = False) -> None: self._optional = null # True only for None literals def check(self, rhs: Base, check_quant: bool = True) -> None: """""" self._check_optional(rhs, check_quant)
[docs]class Boolean(Base): def __init__(self, optional: bool = False) -> None: self._optional = optional def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, String): return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class Float(Base): def __init__(self, optional: bool = False) -> None: self._optional = optional def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, String): return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class Int(Base): def __init__(self, optional: bool = False) -> None: self._optional = optional def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, Float): return self._check_optional(rhs, check_quant) if isinstance(rhs, String): return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class File(Base): def __init__(self, optional: bool = False) -> None: self._optional = optional def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, String): return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class Directory(Base): def __init__(self, optional: bool = False) -> None: self._optional = optional def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, String): return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class String(Base): def __init__(self, optional: bool = False) -> None: self._optional = optional def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, (File, Directory, Int, Float)): return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class Array(Base): """ Array type, parameterized by the type of the constituent items. """ item_type: Base # TODO: make immutable property """ :type: WDL.Type.Base ``item_type`` may be ``Any`` when not known statically, such as in a literal empty array ``[]``. """ _nonempty: bool def __init__(self, item_type: Base, optional: bool = False, nonempty: bool = False) -> None: assert item_type self.item_type = item_type assert isinstance(nonempty, bool) self._optional = optional self._nonempty = nonempty def __str__(self) -> str: ans = ( "Array[" + str(self.item_type) + "]" + ("+" if self.nonempty else "") + ("?" if self.optional else "") ) return ans @property def nonempty(self) -> bool: """ :type: bool True when the type has the nonempty quantifier, ``Array[T]+`` """ return self._nonempty @property def parameters(self) -> Iterable[Base]: yield self.item_type def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, Array): self.item_type.check(rhs.item_type, check_quant) return self._check_optional(rhs, check_quant) if isinstance(rhs, String): if self.item_type is not None: self.item_type.check(String()) return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs] def copy(self, optional: Optional[bool] = None, nonempty: Optional[bool] = None) -> Base: ans = super().copy(optional) if nonempty is not None: ans._nonempty = nonempty return ans
[docs]class Map(Base): """ Map type, parameterized by the (key,value) item type. """ item_type: Tuple[Base, Base] """ :type: Tuple[WDL.Type.Base,WDL.Type.Base] The key and value types may be ``Any`` when not known statically, such as in a literal empty map ``{}``. """ literal_keys: Optional[Set[str]] "" # Special use: Map[String,_] literal stores the key names here for potential use in # struct coercions where we need them. (Normally the Map type would record the common # type of the keys but not the keys themselves.) def __init__( self, item_type: Tuple[Base, Base], optional: bool = False, literal_keys: Optional[Set[str]] = None, ) -> None: self._optional = optional if item_type is None: item_type = (Any(), Any()) self.item_type = item_type self.literal_keys = literal_keys def __str__(self) -> str: return ( "Map[" + ( str(self.item_type[0]) + "," + str(self.item_type[1]) if self.item_type is not None else "" ) + "]" + ("?" if self.optional else "") ) @property def parameters(self) -> Iterable[Base]: yield self.item_type[0] yield self.item_type[1] def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, Map): self.item_type[0].check(rhs.item_type[0], check_quant) self.item_type[1].check(rhs.item_type[1], check_quant) return self._check_optional(rhs, check_quant) if isinstance(rhs, StructInstance) and self.literal_keys is not None: # struct assignment from map literal return _check_struct_members( {k: self.item_type[1] for k in self.literal_keys}, rhs, check_quant, ) if ( isinstance(rhs, StructInstance) and self.literal_keys is None and self.item_type[0] == String() ): # Allow attempt to runtime-coerce a non-literal Map[String,_] to StructInstance. # Unlike a literal, we don't (during static validation) know what the keys will be, so # we can't typecheck it thoroughly (Lint warning will apply). This is used initializing # structs from read_map() or read_object[s](). return super().check(rhs, check_quant)
[docs]class Pair(Base): """ Pair type, parameterized by the left and right item types. """ left_type: Base """ :type: WDL.Type.Base """ right_type: Base """ :type: WDL.Type.Base """ def __init__(self, left_type: Base, right_type: Base, optional: bool = False) -> None: self._optional = optional self.left_type = left_type self.right_type = right_type def __str__(self) -> str: return ( "Pair[" + (str(self.left_type) + "," + str(self.right_type)) + "]" + ("?" if self.optional else "") ) @property def parameters(self) -> Iterable[Base]: yield self.left_type yield self.right_type def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, Pair): self.left_type.check(rhs.left_type, check_quant) self.right_type.check(rhs.right_type, check_quant) return self._check_optional(rhs, check_quant) super().check(rhs, check_quant)
[docs]class StructInstance(Base): """ Type of an instance of a struct Not to be confused with struct type definition, :class:`WDL.Tree.StructTypeDef`. To find the ``WDL.Tree.StructTypeDef`` in the current ``doc: WDL.Tree.Document`` corresponding to ``ty: WDL.Type.StructInstance``, use ``doc.struct_typedefs[ty.type_name]``. """ type_name: str """ :type: str The struct type name with which the instance is declared; note that the same struct type can go by different names. """ members: Optional[Dict[str, Base]] """ :type: Dict[str,WDL.Type.Base] Names and types of the struct members, from the struct type definition (available after typechecking) """ def __init__(self, type_name: str, optional: bool = False) -> None: self._optional = optional self.type_name = type_name self.members = None def __str__(self) -> str: return self.type_name + ("?" if self.optional else "") def check(self, rhs: Base, check_quant: bool = True) -> None: """""" if isinstance(rhs, StructInstance): if self.type_id != rhs.type_id: raise TypeError() return self._check_optional(rhs, check_quant) super().check(rhs, check_quant) @property def type_id(self) -> str: """ :type: str A string canonically describing the member names and their types, excluding the struct type name; useful to unify aliased struct types. """ assert isinstance(self.members, dict) return _struct_type_id(self.members) @property def parameters(self) -> Iterable[Base]: assert self.members is not None return self.members.values()
def _struct_type_id(members: Dict[str, Base]) -> str: # generates a content hash of the struct type definition, used to recognize # equivalent struct types going by different aliases ans = [] for name, ty in sorted(members.items()): if isinstance(ty, StructInstance): assert ty.members ty = _struct_type_id(ty.members) + ("?" if ty.optional else "") else: ty = str(ty) ans.append(name + " : " + ty) return "struct(" + ", ".join(ans) + ")" class Object(Base): """""" # Represents the type of object{} literals and the known-only-at-runtime return value of # read_json(). We expect this to exist only transiently, just before attempting coercion to # a StructInstance with known member types. We hide this from docs to avoid confusion with # general (pre-WDL1.0) Object support, since it's only to support struct initialization. members: Dict[str, Base] def __init__(self, members: Dict[str, Base]) -> None: self.members = members def __str__(self) -> str: ans = [] for name, ty in sorted(self.members.items()): ans.append(name + " : " + str(ty)) return "object(" + ", ".join(ans) + ")" @property def parameters(self) -> Iterable[Base]: return self.members.values() def check(self, rhs: Base, check_quant: bool = True) -> None: if isinstance(rhs, StructInstance): return _check_struct_members(self.members, rhs, check_quant) if isinstance(rhs, Map): # Member names must coerce to the map key type, and each member type must coerce to the # map value type. String().check(rhs.item_type[0]) for vt in self.members.values(): vt.check(rhs.item_type[1], check_quant=check_quant) return if isinstance(rhs, (Any, Object)): # Don't worry about Object coercion because we expect a further coercion to # StructInstance to follow in short order, constraining the expected member types. return raise TypeError() def _check_struct_members( self_members: Dict[str, Base], rhs: StructInstance, check_quant: bool ) -> None: # shared routine for checking Map or Object type coercion, with useful error messages rhs_members = rhs.members assert rhs_members rhs_keys = set(rhs_members.keys()) self_keys = set(self_members.keys()) missing_keys = list(k for k in rhs_keys - self_keys if not rhs_members[k].optional) if missing_keys: raise TypeError( "missing non-optional member(s) in struct " f"{rhs.type_name}: {' '.join(sorted(missing_keys))}" ) for k in self_keys.intersection(rhs_keys): try: self_members[k].check(rhs_members[k], check_quant) except TypeError as exn: if len(exn.args): raise raise TypeError( f"type mismatch using {self_members[k]} to initialize " f"{rhs_members[k]} {k} member of struct {rhs.type_name}" )
[docs]def unify(types: List[Base], check_quant: bool = True, force_string: bool = False) -> Base: """ Given a list of types, compute a type to which they're all coercible, or :class:`WDL.Type.Any` if no more-specific inference is possible. :param force_string: permit last-resort unification to ``String`` even if no item is currently a ``String``, but all can be coerced """ if not types: return Any() # begin with first non-String type (as almost everything is coercible to string); or if # --no-quant-check, the first array type (as we can try to promote other T to Array[T]) t = next((t for t in types if not isinstance(t, (String, Any))), types[0]) if not check_quant: t = next((a for a in types if isinstance(a, Array) and not isinstance(a.item_type, Any)), t) t = t.copy() # pyre-ignore # potentially promote/generalize t to other types seen optional = False all_nonempty = True all_stringifiable = True for t2 in types: # recurse on parameters of compound types t_was_array_any = isinstance(t, Array) and isinstance(t.item_type, Any) if isinstance(t, Array) and isinstance(t2, Array) and not isinstance(t2.item_type, Any): t.item_type = unify([t.item_type, t2.item_type], check_quant, force_string) if isinstance(t, Pair) and isinstance(t2, Pair): t.left_type = unify([t.left_type, t2.left_type], check_quant, force_string) t.right_type = unify([t.right_type, t2.right_type], check_quant, force_string) if isinstance(t, Map) and isinstance(t2, Map): t.item_type = ( # pyre-ignore unify([t.item_type[0], t2.item_type[0]], check_quant, force_string), # pyre-ignore unify([t.item_type[1], t2.item_type[1]], check_quant, force_string), # pyre-ignore ) if not t_was_array_any and next((pt for pt in t.parameters if isinstance(pt, Any)), False): return Any() if isinstance(t, Object) and isinstance(t2, Object): # unifying Object types (generally transient, pending coercion to a StructInstance) for k in t2.members: if k in t.members: t.members[k] = unify([t.members[k], t2.members[k]]) else: # infer optionality of fields present only in some types t.members[k] = t2.members[k].copy(optional=True) # Int/Float, String/File if isinstance(t, Int) and isinstance(t2, Float): t = Float() if isinstance(t, String) and isinstance(t2, File): t = File() if isinstance(t, String) and isinstance(t2, Directory): t = Directory() # String if ( isinstance(t2, String) and not isinstance(t2, (File, Directory)) and not isinstance(t, (File, Directory)) and (not check_quant or not isinstance(t, Array)) and (not isinstance(t, (Pair, Map))) ): t = String() if not t2.coerces(String(optional=True), check_quant=check_quant): all_stringifiable = False # optional/nonempty if t.optional or t2.optional: optional = True if isinstance(t, Array) and not t.nonempty or isinstance(t2, Array) and not t2.nonempty: all_nonempty = False if isinstance(t, Array): t = t.copy(nonempty=all_nonempty) t = t.copy(optional=optional) # check all types are coercible to t for t2 in types: if not t2.coerces(t, check_quant=check_quant): if all_stringifiable and force_string: return String(optional=optional) return Any() return t