import typing
from enum import Enum as PythonEnum
from typing import TYPE_CHECKING, Dict, Iterator, Optional, Sequence, cast
import dagster._check as check
from dagster._annotations import public
from dagster._builtins import BuiltinEnum
from dagster._config import UserConfigSchema
from dagster._serdes import whitelist_for_serdes
if TYPE_CHECKING:
from .snap import ConfigSchemaSnapshot, ConfigTypeSnap
@whitelist_for_serdes
class ConfigTypeKind(PythonEnum):
ANY = "ANY"
SCALAR = "SCALAR"
ENUM = "ENUM"
SELECTOR = "SELECTOR"
STRICT_SHAPE = "STRICT_SHAPE"
PERMISSIVE_SHAPE = "PERMISSIVE_SHAPE"
SCALAR_UNION = "SCALAR_UNION"
MAP = "MAP"
# Closed generic types
ARRAY = "ARRAY"
NONEABLE = "NONEABLE"
@staticmethod
def has_fields(kind: "ConfigTypeKind") -> bool:
check.inst_param(kind, "kind", ConfigTypeKind)
return kind == ConfigTypeKind.SELECTOR or ConfigTypeKind.is_shape(kind)
@staticmethod
def is_closed_generic(kind: "ConfigTypeKind") -> bool:
check.inst_param(kind, "kind", ConfigTypeKind)
return (
kind == ConfigTypeKind.ARRAY
or kind == ConfigTypeKind.NONEABLE
or kind == ConfigTypeKind.SCALAR_UNION
or kind == ConfigTypeKind.MAP
)
@staticmethod
def is_shape(kind: "ConfigTypeKind") -> bool:
check.inst_param(kind, "kind", ConfigTypeKind)
return kind == ConfigTypeKind.STRICT_SHAPE or kind == ConfigTypeKind.PERMISSIVE_SHAPE
@staticmethod
def is_selector(kind: "ConfigTypeKind") -> bool:
check.inst_param(kind, "kind", ConfigTypeKind)
return kind == ConfigTypeKind.SELECTOR
class ConfigType:
"""
The class backing DagsterTypes as they are used processing configuration data.
"""
def __init__(
self,
key: str,
kind: ConfigTypeKind,
given_name: Optional[str] = None,
description: Optional[str] = None,
type_params: Optional[Sequence["ConfigType"]] = None,
):
self.key: str = check.str_param(key, "key")
self.kind: ConfigTypeKind = check.inst_param(kind, "kind", ConfigTypeKind)
self.given_name: Optional[str] = check.opt_str_param(given_name, "given_name")
self._description: Optional[str] = check.opt_str_param(description, "description")
self.type_params: Optional[Sequence[ConfigType]] = (
check.sequence_param(type_params, "type_params", of_type=ConfigType)
if type_params
else None
)
# memoized snap representation
self._snap: Optional["ConfigTypeSnap"] = None
@property
def description(self) -> Optional[str]:
return self._description
@staticmethod
def from_builtin_enum(builtin_enum: typing.Any) -> "ConfigType":
check.invariant(BuiltinEnum.contains(builtin_enum), "param must be member of BuiltinEnum")
return _CONFIG_MAP[builtin_enum]
def post_process(self, value):
"""
Implement this in order to take a value provided by the user
and perform computation on it. This can be done to coerce data types,
fetch things from the environment (e.g. environment variables), or
to do custom validation. If the value is not valid, throw a
PostProcessingError. Otherwise return the coerced value.
"""
return value
def get_snapshot(self) -> "ConfigTypeSnap":
from .snap import snap_from_config_type
if self._snap is None:
self._snap = snap_from_config_type(self)
return self._snap
def type_iterator(self) -> Iterator["ConfigType"]:
yield self
def get_schema_snapshot(self) -> "ConfigSchemaSnapshot":
from .snap import ConfigSchemaSnapshot
return ConfigSchemaSnapshot({ct.key: ct.get_snapshot() for ct in self.type_iterator()})
@whitelist_for_serdes
class ConfigScalarKind(PythonEnum):
INT = "INT"
STRING = "STRING"
FLOAT = "FLOAT"
BOOL = "BOOL"
# Scalars, Composites, Selectors, Lists, Optional, Any
class ConfigScalar(ConfigType):
def __init__(
self, key: str, given_name: Optional[str], scalar_kind: ConfigScalarKind, **kwargs: object
):
self.scalar_kind = check.inst_param(scalar_kind, "scalar_kind", ConfigScalarKind)
super(ConfigScalar, self).__init__(
key, kind=ConfigTypeKind.SCALAR, given_name=given_name, **kwargs # type: ignore
)
class BuiltinConfigScalar(ConfigScalar):
def __init__(self, scalar_kind, description=None):
super(BuiltinConfigScalar, self).__init__(
key=type(self).__name__,
given_name=type(self).__name__,
scalar_kind=scalar_kind,
description=description,
)
class Int(BuiltinConfigScalar):
def __init__(self):
super(Int, self).__init__(scalar_kind=ConfigScalarKind.INT, description="")
class String(BuiltinConfigScalar):
def __init__(self):
super(String, self).__init__(scalar_kind=ConfigScalarKind.STRING, description="")
class Bool(BuiltinConfigScalar):
def __init__(self):
super(Bool, self).__init__(scalar_kind=ConfigScalarKind.BOOL, description="")
class Float(BuiltinConfigScalar):
def __init__(self):
super(Float, self).__init__(scalar_kind=ConfigScalarKind.FLOAT, description="")
def post_process(self, value):
return float(value)
class Any(ConfigType):
def __init__(self):
super(Any, self).__init__(
key="Any",
given_name="Any",
kind=ConfigTypeKind.ANY,
)
[docs]class Noneable(ConfigType):
"""Defines a configuration type that is the union of ``NoneType`` and the type ``inner_type``.
Args:
inner_type (type):
The type of the values that this configuration type can contain.
**Examples:**
.. code-block:: python
config_schema={"name": Noneable(str)}
config={"name": "Hello"} # Ok
config={"name": None} # Ok
config={} # Error
"""
def __init__(self, inner_type: object):
from .field import resolve_to_config_type
self.inner_type = cast(ConfigType, resolve_to_config_type(inner_type))
super(Noneable, self).__init__(
key="Noneable.{inner_type}".format(inner_type=self.inner_type.key),
kind=ConfigTypeKind.NONEABLE,
type_params=[self.inner_type],
)
def type_iterator(self) -> Iterator["ConfigType"]:
yield from self.inner_type.type_iterator()
yield from super().type_iterator()
[docs]class Array(ConfigType):
"""Defines an array (list) configuration type that contains values of type ``inner_type``.
Args:
inner_type (type):
The type of the values that this configuration type can contain.
"""
def __init__(self, inner_type: object):
from .field import resolve_to_config_type
self.inner_type = cast(ConfigType, resolve_to_config_type(inner_type))
super(Array, self).__init__(
key="Array.{inner_type}".format(inner_type=self.inner_type.key),
type_params=[self.inner_type],
kind=ConfigTypeKind.ARRAY,
)
@public # type: ignore
@property
def description(self):
return "List of {inner_type}".format(inner_type=self.key)
def type_iterator(self) -> Iterator["ConfigType"]:
yield from self.inner_type.type_iterator()
yield from super().type_iterator()
[docs]class EnumValue:
"""Define an entry in a :py:class:`Enum`.
Args:
config_value (str):
The string representation of the config to accept when passed.
python_value (Optional[Any]):
The python value to convert the enum entry in to. Defaults to the ``config_value``.
description (Optional[str]):
A human-readable description of the enum entry.
"""
def __init__(
self,
config_value: str,
python_value: Optional[object] = None,
description: Optional[str] = None,
):
self.config_value = check.str_param(config_value, "config_value")
self.python_value = config_value if python_value is None else python_value
self.description = check.opt_str_param(description, "description")
[docs]class Enum(ConfigType):
"""Defines a enum configuration type that allows one of a defined set of possible values.
Args:
name (str):
The name of the enum configuration type.
enum_values (List[EnumValue]):
The set of possible values for the enum configuration type.
**Examples:**
.. code-block:: python
@op(
config_schema=Field(
Enum(
'CowboyType',
[
EnumValue('good'),
EnumValue('bad'),
EnumValue('ugly'),
]
)
)
)
def resolve_standoff(context):
# ...
"""
def __init__(self, name: str, enum_values: Sequence[EnumValue]):
check.str_param(name, "name")
super(Enum, self).__init__(key=name, given_name=name, kind=ConfigTypeKind.ENUM)
self.enum_values = check.sequence_param(enum_values, "enum_values", of_type=EnumValue)
self._valid_python_values = {ev.python_value for ev in enum_values}
check.invariant(len(self._valid_python_values) == len(enum_values))
self._valid_config_values = {ev.config_value for ev in enum_values}
check.invariant(len(self._valid_config_values) == len(enum_values))
@property
def config_values(self):
return [ev.config_value for ev in self.enum_values]
def is_valid_config_enum_value(self, config_value):
return config_value in self._valid_config_values
def post_process(self, value: typing.Any) -> typing.Any:
if isinstance(value, PythonEnum):
value = value.name
for ev in self.enum_values:
if ev.config_value == value:
return ev.python_value
check.failed(
(
"Should never reach this. config_value should be pre-validated. "
"Got {config_value}"
).format(config_value=value)
)
@classmethod
def from_python_enum(cls, enum, name=None):
"""
Create a Dagster enum corresponding to an existing Python enum.
Args:
enum (enum.EnumMeta):
The class representing the enum.
name (Optional[str]):
The name for the enum. If not present, `enum.__name__` will be used.
Example:
.. code-block:: python
class Color(enum.Enum):
RED = enum.auto()
GREEN = enum.auto()
BLUE = enum.auto()
@op(
config_schema={"color": Field(Enum.from_python_enum(Color))}
)
def select_color(context):
# ...
"""
if name is None:
name = enum.__name__
return cls(name, [EnumValue(v.name, python_value=v) for v in enum])
[docs]class ScalarUnion(ConfigType):
"""Defines a configuration type that accepts a scalar value OR a non-scalar value like a
:py:class:`~dagster.List`, :py:class:`~dagster.Dict`, or :py:class:`~dagster.Selector`.
This allows runtime scalars to be configured without a dictionary with the key ``value`` and
instead just use the scalar value directly. However this still leaves the option to
load scalars from a json or pickle file.
Args:
scalar_type (type):
The scalar type of values that this configuration type can hold. For example,
:py:class:`~python:int`, :py:class:`~python:float`, :py:class:`~python:bool`,
or :py:class:`~python:str`.
non_scalar_schema (ConfigSchema):
The schema of a non-scalar Dagster configuration type. For example, :py:class:`List`,
:py:class:`Dict`, or :py:class:`~dagster.Selector`.
key (Optional[str]):
The configuation type's unique key. If not set, then the key will be set to
``ScalarUnion.{scalar_type}-{non_scalar_schema}``.
**Examples:**
.. code-block:: yaml
graph:
transform_word:
inputs:
word:
value: foobar
becomes, optionally,
.. code-block:: yaml
graph:
transform_word:
inputs:
word: foobar
"""
def __init__(
self,
scalar_type: typing.Any,
non_scalar_schema: UserConfigSchema,
_key: Optional[str] = None,
):
from .field import resolve_to_config_type
self.scalar_type = resolve_to_config_type(scalar_type)
self.non_scalar_type = resolve_to_config_type(non_scalar_schema)
check.param_invariant(self.scalar_type.kind == ConfigTypeKind.SCALAR, "scalar_type")
check.param_invariant(
self.non_scalar_type.kind
in {ConfigTypeKind.STRICT_SHAPE, ConfigTypeKind.SELECTOR, ConfigTypeKind.ARRAY},
"non_scalar_type",
)
# https://github.com/dagster-io/dagster/issues/2133
key = check.opt_str_param(
_key, "_key", "ScalarUnion.{}-{}".format(self.scalar_type.key, self.non_scalar_type.key)
)
super(ScalarUnion, self).__init__(
key=key,
kind=ConfigTypeKind.SCALAR_UNION,
type_params=[self.scalar_type, self.non_scalar_type],
)
def type_iterator(self) -> Iterator["ConfigType"]:
yield from self.scalar_type.type_iterator()
yield from self.non_scalar_type.type_iterator()
yield from super().type_iterator()
ConfigAnyInstance = Any()
ConfigBoolInstance = Bool()
ConfigFloatInstance = Float()
ConfigIntInstance = Int()
ConfigStringInstance = String()
_CONFIG_MAP: Dict[check.TypeOrTupleOfTypes, ConfigType] = {
BuiltinEnum.ANY: ConfigAnyInstance,
BuiltinEnum.BOOL: ConfigBoolInstance,
BuiltinEnum.FLOAT: ConfigFloatInstance,
BuiltinEnum.INT: ConfigIntInstance,
BuiltinEnum.STRING: ConfigStringInstance,
}
_CONFIG_MAP_BY_NAME: Dict[str, ConfigType] = {
"Any": ConfigAnyInstance,
"Bool": ConfigBoolInstance,
"Float": ConfigFloatInstance,
"Int": ConfigIntInstance,
"String": ConfigStringInstance,
}
ALL_CONFIG_BUILTINS = set(_CONFIG_MAP.values())
def get_builtin_scalar_by_name(type_name: str):
if type_name not in _CONFIG_MAP_BY_NAME:
check.failed("Scalar {} is not supported".format(type_name))
return _CONFIG_MAP_BY_NAME[type_name]