"""
.. versionadded:: 20.7.0
Sometimes you want to confirm that your target data matches your
code's assumptions. With glom, you don't need a separate validation
step, you can do these checks inline with your glom spec, using
:class:`~glom.Match` and friends.
"""
import re
import sys
from pprint import pprint
from boltons.iterutils import is_iterable
from boltons.typeutils import make_sentinel
from .core import GlomError, glom, T, MODE, bbrepr, bbformat, format_invocation, Path, chain_child, Val, arg_val
_MISSING = make_sentinel('_MISSING')
# NOTE: it is important that MatchErrors be cheap to construct,
# because negative matches are part of normal control flow
# (e.g. often it is idiomatic to cascade from one possible match
# to the next and take the first one that works)
[docs]class MatchError(GlomError):
"""
Raised when a :class:`Match` or :data:`M` check fails.
>>> glom({123: 'a'}, Match({'id': int}))
Traceback (most recent call last):
...
MatchError: key 123 didn't match any of ['id']
"""
def __init__(self, fmt, *args):
super(MatchError, self).__init__(fmt, *args)
def get_message(self):
fmt, args = self.args[0], self.args[1:]
return bbformat(fmt, *args)
[docs]class TypeMatchError(MatchError, TypeError):
""":exc:`MatchError` subtype raised when a
:class:`Match` fails a type check.
>>> glom({'id': 'a'}, Match({'id': int}))
Traceback (most recent call last):
...
TypeMatchError: error raised while processing.
Target-spec trace, with error detail (most recent last):
- Target: {'id': 'a'}
- Spec: Match({'id': <type 'int'>})
- Spec: {'id': <type 'int'>}
- Target: 'a'
- Spec: int
TypeMatchError: expected type int, not str
"""
def __init__(self, actual, expected):
super(TypeMatchError, self).__init__(
"expected type {0.__name__}, not {1.__name__}", expected, actual)
def __copy__(self):
# __init__ args = (actual, expected)
# self.args = (fmt_str, expected, actual)
return TypeMatchError(self.args[2], self.args[1])
[docs]class Match(object):
"""glom's ``Match`` specifier type enables a new mode of glom usage:
pattern matching. In particular, this mode has been designed for
nested data validation.
Pattern specs are evaluated as follows:
1. Spec instances are always evaluated first
2. Types match instances of that type
3. Instances of :class:`dict`, :class:`list`, :class:`tuple`,
:class:`set`, and :class:`frozenset` are matched recursively
4. Any other values are compared for equality to the target with
``==``
By itself, this allows to assert that structures match certain
patterns, and may be especially familiar to users of the `schema`_
library.
For example, let's load some data::
>>> target = [
... {'id': 1, 'email': 'alice@example.com'},
... {'id': 2, 'email': 'bob@example.com'}]
A :class:`Match` pattern can be used to ensure this data is in its expected form:
>>> spec = Match([{'id': int, 'email': str}])
This ``spec`` succinctly describes our data structure's pattern
Specifically, a :class:`list` of :class:`dict` objects, each of
which has exactly two keys, ``'id'`` and ``'email'``, whose values are
an :class:`int` and :class:`str`, respectively. Now,
:func:`~glom.glom` will ensure our ``target`` matches our pattern
``spec``:
>>> result = glom(target, spec)
>>> assert result == \\
... [{'id': 1, 'email': 'alice@example.com'}, {'id': 2, 'email': 'bob@example.com'}]
With a more complex :class:`Match` spec, we can be more precise:
>>> spec = Match([{'id': And(M > 0, int), 'email': Regex('[^@]+@[^@]+')}])
:class:`~glom.And` allows multiple conditions to be applied.
:class:`~glom.Regex` evaluates the regular expression against the
target value under the ``'email'`` key. In this case, we take a
simple approach: an email has exactly one ``@``, with at least one
character before and after.
Finally, :attr:`~glom.M` is our stand-in for the current target
we're matching against, allowing us to perform in-line comparisons
using Python's native greater-than operator (as well as
others). We apply our :class:`Match` pattern as before::
>>> assert glom(target, spec) == \\
... [{'id': 1, 'email': 'alice@example.com'}, {'id': 2, 'email': 'bob@example.com'}]
And as usual, upon a successful match, we get the matched result.
.. note::
For Python 3.6+ where dictionaries are ordered, keys in the target
are matched against keys in the spec in their insertion order.
.. _schema: https://github.com/keleshev/schema
Args:
spec: The glomspec representing the pattern to match data against.
default: The default value to be returned if a match fails. If not
set, a match failure will raise a :class:`MatchError`.
"""
def __init__(self, spec, default=_MISSING):
self.spec = spec
self.default = default
def glomit(self, target, scope):
scope[MODE] = _glom_match
try:
ret = scope[glom](target, self.spec, scope)
except GlomError:
if self.default is _MISSING:
raise
ret = arg_val(target, self.default, scope)
return ret
[docs] def verify(self, target):
"""A convenience function a :class:`Match` instance which returns the
matched value when *target* matches, or raises a
:exc:`MatchError` when it does not.
Args:
target: Target value or data structure to match against.
Raises:
glom.MatchError
"""
return glom(target, self)
[docs] def matches(self, target):
"""A convenience method on a :class:`Match` instance, returns
``True`` if the *target* matches, ``False`` if not.
>>> Match(int).matches(-1.0)
False
Args:
target: Target value or data structure to match against.
"""
try:
glom(target, self)
except GlomError:
return False
return True
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, bbrepr(self.spec))
_RE_FULLMATCH = getattr(re, "fullmatch", None)
_RE_VALID_FUNCS = set((_RE_FULLMATCH, None, re.search, re.match))
_RE_FUNC_ERROR = ValueError("'func' must be one of %s" % (", ".join(
sorted(e and e.__name__ or "None" for e in _RE_VALID_FUNCS))))
_RE_TYPES = ()
try: re.match(u"", u"")
except Exception: pass # pragma: no cover
else: _RE_TYPES += (type(u""),)
try: re.match(b"", b"")
except Exception: pass # pragma: no cover
else: _RE_TYPES += (type(b""),)
[docs]class Regex(object):
"""
checks that target is a string which matches the passed regex pattern
raises MatchError if there isn't a match; returns Target if match
variables captures in regex are added to the scope so they can
be used by downstream processes
"""
__slots__ = ('flags', 'func', 'match_func', 'pattern')
def __init__(self, pattern, flags=0, func=None):
if func not in _RE_VALID_FUNCS:
raise _RE_FUNC_ERROR
regex = re.compile(pattern, flags)
if func is re.match:
match_func = regex.match
elif func is re.search:
match_func = regex.search
else:
if _RE_FULLMATCH:
match_func = regex.fullmatch
else:
regex = re.compile(r"(?:{})\Z".format(pattern), flags)
match_func = regex.match
self.flags, self.func = flags, func
self.match_func, self.pattern = match_func, pattern
def glomit(self, target, scope):
if type(target) not in _RE_TYPES:
raise MatchError(
"{0!r} not valid as a Regex target -- expected {1!r}", type(target), _RE_TYPES)
match = self.match_func(target)
if not match:
raise MatchError("target did not match pattern {0!r}", self.pattern)
scope.update(match.groupdict())
return target
def __repr__(self):
args = '(' + bbrepr(self.pattern)
if self.flags:
args += ', flags=' + bbrepr(self.flags)
if self.func is not None:
args += ', func=' + self.func.__name__
args += ')'
return self.__class__.__name__ + args
#TODO: combine this with other functionality elsewhere?
def _bool_child_repr(child):
if child is M:
return repr(child)
elif isinstance(child, _MExpr):
return "(" + bbrepr(child) + ")"
return bbrepr(child)
class _Bool(object):
def __init__(self, *children, **kw):
self.children = children
if not children:
raise ValueError("need at least one operand for {}".format(
self.__class__.__name__))
self.default = kw.pop('default', _MISSING)
if kw:
raise TypeError('got unexpected kwargs: %r' % list(kw.keys()))
def __and__(self, other):
return And(self, other)
def __or__(self, other):
return Or(self, other)
def __invert__(self):
return Not(self)
def glomit(self, target, scope):
try:
return self._glomit(target, scope)
except GlomError:
if self.default is not _MISSING:
return arg_val(target, self.default, scope)
raise
def _m_repr(self):
"""should this Or() repr as M |?"""
# only used by And() and Or(), not Not(), so len(children) >= 1
if isinstance(self.children[0], (_MType, _MExpr)):
return True
if type(self.children[0]) in (And, Or, Not):
return self.children[0]._m_repr()
return False
def __repr__(self):
child_reprs = [_bool_child_repr(c) for c in self.children]
if self._m_repr() and self.default is _MISSING:
return " {} ".format(self.OP).join(child_reprs)
if self.default is not _MISSING:
child_reprs.append("default=" + repr(self.default))
return self.__class__.__name__ + "(" + ", ".join(child_reprs) + ")"
[docs]class And(_Bool):
"""
Applies child specs one after the other to the target; if none of the
specs raises `GlomError`, returns the last result.
"""
OP = "&"
__slots__ = ('children',)
def _glomit(self, target, scope):
# all children must match without exception
result = target # so that And() == True, similar to all([]) == True
for child in self.children:
result = scope[glom](target, child, scope)
return result
def __and__(self, other):
# reduce number of layers of spec
return And(*(self.children + (other,)))
[docs]class Or(_Bool):
"""
Tries to apply the first child spec to the target, and return the result.
If `GlomError` is raised, try the next child spec until there are no
all child specs have been tried, then raise `MatchError`.
"""
OP = "|"
__slots__ = ('children',)
def _glomit(self, target, scope):
for child in self.children[:-1]:
try: # one child must match without exception
return scope[glom](target, child, scope)
except GlomError:
pass
return scope[glom](target, self.children[-1], scope)
def __or__(self, other):
# reduce number of layers of spec
return Or(*(self.children + (other,)))
[docs]class Not(_Bool):
"""
Inverts the *child*. Child spec will be expected to raise
:exc:`GlomError` (or subtype), in which case the target will be returned.
If the child spec does not raise :exc:`GlomError`, :exc:`MatchError`
will be raised.
"""
__slots__ = ('child',)
def __init__(self, child):
self.child = child
def glomit(self, target, scope):
try: # one child must match without exception
scope[glom](target, self.child, scope)
except GlomError:
return target
else:
raise GlomError("child shouldn't have passed", self.child)
def _m_repr(self):
if isinstance(self.child, (_MType, _MExpr)):
return True
if type(self.child) not in (And, Or, Not):
return False
return self.child._m_repr()
def __repr__(self):
if self.child is M:
return '~M'
if self._m_repr(): # is in M repr
return "~(" + bbrepr(self.child) + ")"
return "Not(" + bbrepr(self.child) + ")"
_M_OP_MAP = {'=': '==', '!': '!=', 'g': '>=', 'l': '<='}
class _MSubspec(object):
"""used by MType.__call__ to wrap a sub-spec for comparison"""
__slots__ = ('spec')
def __init__(self, spec):
self.spec = spec
def __eq__(self, other):
return _MExpr(self, '=', other)
def __ne__(self, other):
return _MExpr(self, '!', other)
def __gt__(self, other):
return _MExpr(self, '>', other)
def __lt__(self, other):
return _MExpr(self, '<', other)
def __ge__(self, other):
return _MExpr(self, 'g', other)
def __le__(self, other):
return _MExpr(self, 'l', other)
def __repr__(self):
return 'M(%s)' % (bbrepr(self.spec),)
def glomit(self, target, scope):
match = scope[glom](target, self.spec, scope)
if match:
return target
raise MatchError('expected truthy value from {0!r}, got {1!r}', self.spec, match)
class _MExpr(object):
__slots__ = ('lhs', 'op', 'rhs')
def __init__(self, lhs, op, rhs):
self.lhs, self.op, self.rhs = lhs, op, rhs
def __and__(self, other):
return And(self, other)
__rand__ = __and__
def __or__(self, other):
return Or(self, other)
def __invert__(self):
return Not(self)
def glomit(self, target, scope):
lhs, op, rhs = self.lhs, self.op, self.rhs
if lhs is M:
lhs = target
if rhs is M:
rhs = target
if type(lhs) is _MSubspec:
lhs = scope[glom](target, lhs.spec, scope)
if type(rhs) is _MSubspec:
rhs = scope[glom](target, rhs.spec, scope)
matched = (
(op == '=' and lhs == rhs) or
(op == '!' and lhs != rhs) or
(op == '>' and lhs > rhs) or
(op == '<' and lhs < rhs) or
(op == 'g' and lhs >= rhs) or
(op == 'l' and lhs <= rhs)
)
if matched:
return target
raise MatchError("{0!r} {1} {2!r}", lhs, _M_OP_MAP.get(op, op), rhs)
def __repr__(self):
op = _M_OP_MAP.get(self.op, self.op)
return "{!r} {} {!r}".format(self.lhs, op, self.rhs)
class _MType(object):
""":attr:`~glom.M` is similar to :attr:`~glom.T`, a stand-in for the
current target, but where :attr:`~glom.T` allows for attribute and
key access and method calls, :attr:`~glom.M` allows for comparison
operators.
If a comparison succeeds, the target is returned unchanged.
If a comparison fails, :class:`~glom.MatchError` is thrown.
Some examples:
>>> glom(1, M > 0)
1
>>> glom(0, M == 0)
0
>>> glom('a', M != 'b') == 'a'
True
:attr:`~glom.M` by itself evaluates the current target for truthiness.
For example, `M | Val(None)` is a simple idiom for normalizing all falsey values to None:
>>> from glom import Val
>>> glom([0, False, "", None], [M | Val(None)])
[None, None, None, None]
For convenience, ``&`` and ``|`` operators are overloaded to
construct :attr:`~glom.And` and :attr:`~glom.Or` instances.
>>> glom(1.0, (M > 0) & float)
1.0
.. note::
Python's operator overloading may make for concise code,
but it has its limits.
Because bitwise operators (``&`` and ``|``) have higher precedence
than comparison operators (``>``, ``<``, etc.), expressions must
be parenthesized.
>>> M > 0 & float
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for &: 'int' and 'type'
Similarly, because of special handling around ternary
comparisons (``1 < M < 5``) are implemented via
short-circuiting evaluation, they also cannot be captured by
:data:`M`.
"""
__slots__ = ()
def __call__(self, spec):
"""wrap a sub-spec in order to apply comparison operators to the result"""
if not isinstance(spec, type(T)):
# TODO: open this up for other specs so we can do other
# checks, like function calls
raise TypeError("M() only accepts T-style specs, not %s" % type(spec).__name__)
return _MSubspec(spec)
def __eq__(self, other):
return _MExpr(self, '=', other)
def __ne__(self, other):
return _MExpr(self, '!', other)
def __gt__(self, other):
return _MExpr(self, '>', other)
def __lt__(self, other):
return _MExpr(self, '<', other)
def __ge__(self, other):
return _MExpr(self, 'g', other)
def __le__(self, other):
return _MExpr(self, 'l', other)
def __and__(self, other):
return And(self, other)
__rand__ = __and__
def __or__(self, other):
return Or(self, other)
def __invert__(self):
return Not(self)
def __repr__(self):
return "M"
def glomit(self, target, spec):
if target:
return target
raise MatchError("{0!r} not truthy", target)
M = _MType()
[docs]class Optional(object):
"""Used as a :class:`dict` key in a :class:`~glom.Match()` spec,
marks that a value match key which would otherwise be required is
optional and should not raise :exc:`~glom.MatchError` even if no
keys match.
For example::
>>> spec = Match({Optional("name"): str})
>>> glom({"name": "alice"}, spec)
{'name': 'alice'}
>>> glom({}, spec)
{}
>>> spec = Match({Optional("name", default=""): str})
>>> glom({}, spec)
{'name': ''}
"""
__slots__ = ('key', 'default')
def __init__(self, key, default=_MISSING):
if type(key) in (Required, Optional):
raise TypeError("double wrapping of Optional")
hash(key) # ensure is a valid key
if _precedence(key) != 0:
raise ValueError("Optional() keys must be == match constants, not {!r}".format(key))
self.key, self.default = key, default
def glomit(self, target, scope):
if target != self.key:
raise MatchError("target {0} != spec {1}", target, self.key)
return target
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, bbrepr(self.key))
[docs]class Required(object):
"""Used as a :class:`dict` key in :class:`~glom.Match()` mode, marks
that a key which might otherwise not be required should raise
:exc:`~glom.MatchError` if the key in the target does not match.
For example::
>>> spec = Match({object: object})
This spec will match any dict, because :class:`object` is the base
type of every object::
>>> glom({}, spec)
{}
``{}`` will also match because match mode does not require at
least one match by default. If we want to require that a key
matches, we can use :class:`~glom.Required`::
>>> spec = Match({Required(object): object})
>>> glom({}, spec)
Traceback (most recent call last):
...
MatchError: error raised while processing.
Target-spec trace, with error detail (most recent last):
- Target: {}
- Spec: Match({Required(object): <type 'object'>})
- Spec: {Required(object): <type 'object'>}
MatchError: target missing expected keys Required(object)
Now our spec requires at least one key of any type. You can refine
the spec by putting more specific subpatterns inside of
:class:`~glom.Required`.
"""
__slots__ = ('key',)
def __init__(self, key):
if type(key) in (Required, Optional):
raise TypeError("double wrapping of Required")
hash(key) # ensure is a valid key
if _precedence(key) == 0:
raise ValueError("== match constants are already required: " + bbrepr(key))
self.key = key
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, bbrepr(self.key))
def _precedence(match):
"""
in a dict spec, target-keys may match many
spec-keys (e.g. 1 will match int, M > 0, and 1);
therefore we need a precedence for which order to try
keys in; higher = later
"""
if type(match) in (Required, Optional):
match = match.key
if type(match) in (tuple, frozenset):
if not match:
return 0
return max([_precedence(item) for item in match])
if isinstance(match, type):
return 2
if hasattr(match, "glomit"):
return 1
return 0 # == match
def _handle_dict(target, spec, scope):
if not isinstance(target, dict):
raise TypeMatchError(type(target), dict)
spec_keys = spec # cheating a little bit here, list-vs-dict, but saves an object copy sometimes
required = {
key for key in spec_keys
if _precedence(key) == 0 and type(key) is not Optional
or type(key) is Required}
defaults = { # pre-load result with defaults
key.key: key.default for key in spec_keys
if type(key) is Optional and key.default is not _MISSING}
result = {}
for key, val in target.items():
for maybe_spec_key in spec_keys:
# handle Required as a special case here rather than letting it be a stand-alone spec
if type(maybe_spec_key) is Required:
spec_key = maybe_spec_key.key
else:
spec_key = maybe_spec_key
try:
key = scope[glom](key, spec_key, scope)
except GlomError:
pass
else:
result[key] = scope[glom](val, spec[maybe_spec_key], chain_child(scope))
required.discard(maybe_spec_key)
break
else:
raise MatchError("key {0!r} didn't match any of {1!r}", key, spec_keys)
for key in set(defaults) - set(result):
result[key] = arg_val(target, defaults[key], scope)
if required:
raise MatchError("target missing expected keys: {0}", ', '.join([bbrepr(r) for r in required]))
return result
def _glom_match(target, spec, scope):
if isinstance(spec, type):
if not isinstance(target, spec):
raise TypeMatchError(type(target), spec)
elif isinstance(spec, dict):
return _handle_dict(target, spec, scope)
elif isinstance(spec, (list, set, frozenset)):
if not isinstance(target, type(spec)):
raise TypeMatchError(type(target), type(spec))
result = []
for item in target:
for child in spec:
try:
result.append(scope[glom](item, child, scope))
break
except GlomError as e:
last_error = e
else: # did not break, something went wrong
if target and not spec:
raise MatchError(
"{0!r} does not match empty {1}", target, type(spec).__name__)
# NOTE: unless error happens above, break will skip else branch
# so last_error will have been assigned
raise last_error
if type(spec) is not list:
return type(spec)(result)
return result
elif isinstance(spec, tuple):
if not isinstance(target, tuple):
raise TypeMatchError(type(target), tuple)
if len(target) != len(spec):
raise MatchError("{0!r} does not match {1!r}", target, spec)
result = []
for sub_target, sub_spec in zip(target, spec):
result.append(scope[glom](sub_target, sub_spec, scope))
return tuple(result)
elif callable(spec):
try:
if spec(target):
return target
except Exception as e:
raise MatchError(
"{0}({1!r}) did not validate (got exception {2!r})", spec.__name__, target, e)
raise MatchError(
"{0}({1!r}) did not validate (non truthy return)", spec.__name__, target)
elif target != spec:
raise MatchError("{0!r} does not match {1!r}", target, spec)
return target
[docs]class Switch(object):
"""The :class:`Switch` specifier type routes data processing based on
matching keys, much like the classic switch statement.
Here is a spec which differentiates between lowercase English
vowel and consonant characters:
>>> switch_spec = Match(Switch([(Or('a', 'e', 'i', 'o', 'u'), Val('vowel')),
... (And(str, M, M(T[2:]) == ''), Val('consonant'))]))
The constructor accepts a :class:`dict` of ``{keyspec: valspec}``
or a list of items, ``[(keyspec, valspec)]``. Keys are tried
against the current target in order. If a keyspec raises
:class:`GlomError`, the next keyspec is tried. Once a keyspec
succeeds, the corresponding valspec is evaluated and returned.
Let's try it out:
>>> glom('a', switch_spec)
'vowel'
>>> glom('z', switch_spec)
'consonant'
If no keyspec succeeds, a :class:`MatchError` is raised. Our spec
only works on characters (strings of length 1). Let's try a
non-character, the integer ``3``:
>>> glom(3, switch_spec)
Traceback (most recent call last):
...
glom.matching.MatchError: error raised while processing, details below.
Target-spec trace (most recent last):
- Target: 3
- Spec: Match(Switch([(Or('a', 'e', 'i', 'o', 'u'), Val('vowel')), (And(str, M, (M(T[2:]) == '')), Val('...
+ Spec: Switch([(Or('a', 'e', 'i', 'o', 'u'), Val('vowel')), (And(str, M, (M(T[2:]) == '')), Val('conson...
|\ Spec: Or('a', 'e', 'i', 'o', 'u')
||\ Spec: 'a'
||X glom.matching.MatchError: 3 does not match 'a'
||\ Spec: 'e'
||X glom.matching.MatchError: 3 does not match 'e'
||\ Spec: 'i'
||X glom.matching.MatchError: 3 does not match 'i'
||\ Spec: 'o'
||X glom.matching.MatchError: 3 does not match 'o'
||\ Spec: 'u'
||X glom.matching.MatchError: 3 does not match 'u'
|X glom.matching.MatchError: 3 does not match 'u'
|\ Spec: And(str, M, (M(T[2:]) == ''))
|| Spec: str
|X glom.matching.TypeMatchError: expected type str, not int
glom.matching.MatchError: no matches for target in Switch
.. note::
:class:`~glom.Switch` is one of several *branching* specifier
types in glom. See ":ref:`branched-exceptions`" for details on
interpreting its exception messages.
A *default* value can be passed to the spec to be returned instead
of raising a :class:`MatchError`.
.. note::
Switch implements control flow similar to the switch statement
proposed in `PEP622 <https://www.python.org/dev/peps/pep-0622/>`_.
"""
def __init__(self, cases, default=_MISSING):
if type(cases) is dict:
cases = list(cases.items())
if type(cases) is not list:
raise TypeError(
"expected cases argument to be of format {{keyspec: valspec}}"
" or [(keyspec, valspec)] not: {}".format(type(cases)))
self.cases = cases
# glom.match(cases, Or([(object, object)], dict))
# start dogfooding ^
self.default = default
if not cases:
raise ValueError('expected at least one case in %s, got: %r'
% (self.__class__.__name__, self.cases))
return
def glomit(self, target, scope):
for keyspec, valspec in self.cases:
try:
scope[glom](target, keyspec, scope)
except GlomError as ge:
continue
return scope[glom](target, valspec, chain_child(scope))
if self.default is not _MISSING:
return arg_val(target, self.default, scope)
raise MatchError("no matches for target in %s" % self.__class__.__name__)
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, bbrepr(self.cases))
RAISE = make_sentinel('RAISE') # flag object for "raise on check failure"
[docs]class Check(object):
"""Check objects are used to make assertions about the target data,
and either pass through the data or raise exceptions if there is a
problem.
If any check condition fails, a :class:`~glom.CheckError` is raised.
Args:
spec: a sub-spec to extract the data to which other assertions will
be checked (defaults to applying checks to the target itself)
type: a type or sequence of types to be checked for exact match
equal_to: a value to be checked for equality match ("==")
validate: a callable or list of callables, each representing a
check condition. If one or more return False or raise an
exception, the Check will fail.
instance_of: a type or sequence of types to be checked with isinstance()
one_of: an iterable of values, any of which can match the target ("in")
default: an optional default value to replace the value when the check fails
(if default is not specified, GlomCheckError will be raised)
Aside from *spec*, all arguments are keyword arguments. Each
argument, except for *default*, represent a check
condition. Multiple checks can be passed, and if all check
conditions are left unset, Check defaults to performing a basic
truthy check on the value.
"""
# TODO: the next level of Check would be to play with the Scope to
# allow checking to continue across the same level of
# dictionary. Basically, collect as many errors as possible before
# raising the unified CheckError.
def __init__(self, spec=T, **kwargs):
self.spec = spec
self._orig_kwargs = dict(kwargs)
self.default = kwargs.pop('default', RAISE)
def _get_arg_val(name, cond, func, val, can_be_empty=True):
if val is _MISSING:
return ()
if not is_iterable(val):
val = (val,)
elif not val and not can_be_empty:
raise ValueError('expected %r argument to contain at least one value,'
' not: %r' % (name, val))
for v in val:
if not func(v):
raise ValueError('expected %r argument to be %s, not: %r'
% (name, cond, v))
return val
# if there are other common validation functions, maybe a
# small set of special strings would work as valid arguments
# to validate, too.
def truthy(val):
return bool(val)
validate = kwargs.pop('validate', _MISSING if kwargs else truthy)
type_arg = kwargs.pop('type', _MISSING)
instance_of = kwargs.pop('instance_of', _MISSING)
equal_to = kwargs.pop('equal_to', _MISSING)
one_of = kwargs.pop('one_of', _MISSING)
if kwargs:
raise TypeError('unexpected keyword arguments: %r' % kwargs.keys())
self.validators = _get_arg_val('validate', 'callable', callable, validate)
self.instance_of = _get_arg_val('instance_of', 'a type',
lambda x: isinstance(x, type), instance_of, False)
self.types = _get_arg_val('type', 'a type',
lambda x: isinstance(x, type), type_arg, False)
if equal_to is not _MISSING:
self.vals = (equal_to,)
if one_of is not _MISSING:
raise TypeError('expected "one_of" argument to be unset when'
' "equal_to" argument is passed')
elif one_of is not _MISSING:
if not is_iterable(one_of):
raise ValueError('expected "one_of" argument to be iterable'
' , not: %r' % one_of)
if not one_of:
raise ValueError('expected "one_of" to contain at least'
' one value, not: %r' % (one_of,))
self.vals = one_of
else:
self.vals = ()
return
class _ValidationError(Exception):
"for internal use inside of Check only"
pass
def glomit(self, target, scope):
ret = target
errs = []
if self.spec is not T:
target = scope[glom](target, self.spec, scope)
if self.types and type(target) not in self.types:
if self.default is not RAISE:
return arg_val(target, self.default, scope)
errs.append('expected type to be %r, found type %r' %
(self.types[0].__name__ if len(self.types) == 1
else tuple([t.__name__ for t in self.types]),
type(target).__name__))
if self.vals and target not in self.vals:
if self.default is not RAISE:
return arg_val(target, self.default, scope)
if len(self.vals) == 1:
errs.append("expected {}, found {}".format(self.vals[0], target))
else:
errs.append('expected one of {}, found {}'.format(self.vals, target))
if self.validators:
for i, validator in enumerate(self.validators):
try:
res = validator(target)
if res is False:
raise self._ValidationError
except Exception as e:
msg = ('expected %r check to validate target'
% getattr(validator, '__name__', None) or ('#%s' % i))
if type(e) is self._ValidationError:
if self.default is not RAISE:
return self.default
else:
msg += ' (got exception: %r)' % e
errs.append(msg)
if self.instance_of and not isinstance(target, self.instance_of):
# TODO: can these early returns be done without so much copy-paste?
# (early return to avoid potentially expensive or even error-causeing
# string formats)
if self.default is not RAISE:
return arg_val(target, self.default, scope)
errs.append('expected instance of %r, found instance of %r' %
(self.instance_of[0].__name__ if len(self.instance_of) == 1
else tuple([t.__name__ for t in self.instance_of]),
type(target).__name__))
if errs:
raise CheckError(errs, self, scope[Path])
return ret
def __repr__(self):
cn = self.__class__.__name__
posargs = (self.spec,) if self.spec is not T else ()
return format_invocation(cn, posargs, self._orig_kwargs, repr=bbrepr)
[docs]class CheckError(GlomError):
"""This :exc:`GlomError` subtype is raised when target data fails to
pass a :class:`Check`'s specified validation.
An uncaught ``CheckError`` looks like this::
>>> target = {'a': {'b': 'c'}}
>>> glom(target, {'b': ('a.b', Check(type=int))})
Traceback (most recent call last):
...
CheckError: target at path ['a.b'] failed check, got error: "expected type to be 'int', found type 'str'"
If the ``Check`` contains more than one condition, there may be
more than one error message. The string rendition of the
``CheckError`` will include all messages.
You can also catch the ``CheckError`` and programmatically access
messages through the ``msgs`` attribute on the ``CheckError``
instance.
"""
def __init__(self, msgs, check, path):
self.msgs = msgs
self.check_obj = check
self.path = path
def get_message(self):
msg = 'target at path %s failed check,' % self.path
if self.check_obj.spec is not T:
msg += ' subtarget at %r' % (self.check_obj.spec,)
if len(self.msgs) == 1:
msg += ' got error: %r' % (self.msgs[0],)
else:
msg += ' got %s errors: %r' % (len(self.msgs), self.msgs)
return msg
def __repr__(self):
cn = self.__class__.__name__
return '%s(%r, %r, %r)' % (cn, self.msgs, self.check_obj, self.path)