Merge "New argument validate decorator"

This commit is contained in:
Zuul 2020-11-18 03:23:21 +00:00 committed by Gerrit Code Review
commit 0544291e2f
2 changed files with 1012 additions and 0 deletions

394
ironic/common/args.py Executable file
View File

@ -0,0 +1,394 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import functools
import inspect
import jsonschema
from oslo_utils import strutils
from oslo_utils import uuidutils
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import utils
def string(name, value):
"""Validate that the value is a string
:param name: Name of the argument
:param value: A string value
:returns: The string value, or None if value is None
:raises: InvalidParameterValue if the value is not a string
"""
if value is None:
return
if not isinstance(value, str):
raise exception.InvalidParameterValue(
_('Expected string for %s: %s') % (name, value))
return value
def boolean(name, value):
"""Validate that the value is a string representing a boolean
:param name: Name of the argument
:param value: A string value
:returns: The boolean representation of the value, or None if value is None
:raises: InvalidParameterValue if the value cannot be converted to a
boolean
"""
if value is None:
return
try:
return strutils.bool_from_string(value, strict=True)
except ValueError as e:
raise exception.InvalidParameterValue(
_('Invalid %s: %s') % (name, e))
def uuid(name, value):
"""Validate that the value is a UUID
:param name: Name of the argument
:param value: A UUID string value
:returns: The value, or None if value is None
:raises: InvalidParameterValue if the value is not a valid UUID
"""
if value is None:
return
if not uuidutils.is_uuid_like(value):
raise exception.InvalidParameterValue(
_('Expected UUID for %s: %s') % (name, value))
return value
def name(name, value):
"""Validate that the value is a logical name
:param name: Name of the argument
:param value: A logical name string value
:returns: The value, or None if value is None
:raises: InvalidParameterValue if the value is not a valid logical name
"""
if value is None:
return
if not utils.is_valid_logical_name(value):
raise exception.InvalidParameterValue(
_('Expected name for %s: %s') % (name, value))
return value
def uuid_or_name(name, value):
"""Validate that the value is a UUID or logical name
:param name: Name of the argument
:param value: A UUID or logical name string value
:returns: The value, or None if value is None
:raises: InvalidParameterValue if the value is not a valid UUID or
logical name
"""
if value is None:
return
if (not utils.is_valid_logical_name(value)
and not uuidutils.is_uuid_like(value)):
raise exception.InvalidParameterValue(
_('Expected UUID or name for %s: %s') % (name, value))
return value
def string_list(name, value):
"""Validate and convert comma delimited string to a list.
:param name: Name of the argument
:param value: A comma separated string of values
:returns: A list of unique values (lower-cased), maintaining the
same order, or None if value is None
:raises: InvalidParameterValue if the value is not a string
"""
value = string(name, value)
if value is None:
return
items = []
for v in str(value).split(','):
v_norm = v.strip().lower()
if v_norm and v_norm not in items:
items.append(v_norm)
return items
def integer(name, value):
"""Validate that the value represents an integer
:param name: Name of the argument
:param value: A value representing an integer
:returns: The value as an int, or None if value is None
:raises: InvalidParameterValue if the value does not represent an integer
"""
if value is None:
return
try:
return int(value)
except (ValueError, TypeError):
raise exception.InvalidParameterValue(
_('Expected an integer for %s: %s') % (name, value))
def mac_address(name, value):
"""Validate that the value represents a MAC address
:param name: Name of the argument
:param value: A string value representing a MAC address
:returns: The value as a normalized MAC address, or None if value is None
:raises: InvalidParameterValue if the value is not a valid MAC address
"""
if value is None:
return
try:
return utils.validate_and_normalize_mac(value)
except exception.InvalidMAC:
raise exception.InvalidParameterValue(
_('Expected valid MAC address for %s: %s') % (name, value))
def _or(name, value, validators):
last_error = None
for v in validators:
try:
return v(name=name, value=value)
except exception.Invalid as e:
last_error = e
if last_error:
raise last_error
def or_valid(*validators):
"""Validates if at least one supplied validator passes
:param name: Name of the argument
:param value: A value
:returns: The value returned from the first successful validator
:raises: The error from the last validator when
every validation fails
"""
assert validators, 'No validators specified for or_valid'
return functools.partial(_or, validators=validators)
def _and(name, value, validators):
for v in validators:
value = v(name=name, value=value)
return value
def and_valid(*validators):
"""Validates that every supplied validator passes
The value returned from each validator is passed as the value to the next
one.
:param name: Name of the argument
:param value: A value
:returns: The value transformed through every supplied validator
:raises: The error from the first failed validator
"""
assert validators, 'No validators specified for or_valid'
return functools.partial(_and, validators=validators)
def _validate_schema(name, value, schema):
if value is None:
return
try:
jsonschema.validate(value, schema)
except jsonschema.exceptions.ValidationError as e:
# The error message includes the whole schema which can be very
# large and unhelpful, so truncate it to be brief and useful
error_msg = ' '.join(str(e).split("\n")[:3])[:-1]
raise exception.InvalidParameterValue(
_('Schema error for %s: %s') % (name, error_msg))
return value
def schema(schema):
"""Return a validator function which validates the value with jsonschema
:param: schema dict representing jsonschema to validate with
:returns: validator function which takes name and value arguments
"""
jsonschema.Draft4Validator.check_schema(schema)
return functools.partial(_validate_schema, schema=schema)
def _validate_dict(name, value, validators):
if value is None:
return
_validate_types(name, value, (dict, ))
for k, v in validators.items():
if k in value:
value[k] = v(name=k, value=value[k])
return value
def dict_valid(**validators):
"""Return a validator function which validates dict fields
Validators will replace the value with the validation result. Any dict
item which has no validator is ignored. When a key is missing in the value
then the corresponding validator will not be run.
:param: validators dict where the key is a dict key to validate and the
value is a validator function to run on that value
:returns: validator function which takes name and value arguments
"""
return functools.partial(_validate_dict, validators=validators)
def _validate_types(name, value, types):
if not isinstance(value, types):
str_types = ', '.join([str(t) for t in types])
raise exception.InvalidParameterValue(
_('Expected types %s for %s: %s') % (str_types, name, value))
return value
def types(*types):
"""Return a validator function which checks the value is one of the types
:param: types one or more types to use for the isinstance test
:returns: validator function which takes name and value arguments
"""
return functools.partial(_validate_types, types=tuple(types))
def _apply_validator(name, value, val_functions):
if callable(val_functions):
return val_functions(name, value)
for v in val_functions:
value = v(name, value)
return value
def _inspect(function):
sig = inspect.signature(function)
param_keyword = None # **kwargs parameter
param_positional = None # *args parameter
params = []
for param in sig.parameters.values():
if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
params.append(param)
elif param.kind == inspect.Parameter.VAR_KEYWORD:
param_keyword = param
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
param_positional = param
else:
assert False, 'Unsupported parameter kind %s %s' % (
param.name, param.kind
)
return params, param_positional, param_keyword
def validate(*args, **kwargs):
"""Decorator which validates and transforms function arguments
"""
assert not args, 'Validators must be specifed by argument name'
assert kwargs, 'No validators specified'
validators = kwargs
def inner_function(function):
params, param_positional, param_keyword = _inspect(function)
@functools.wraps(function)
def inner_check_args(*args, **kwargs):
args = list(args)
args_len = len(args)
kwargs_next = {}
next_arg_index = 0
if not param_keyword:
# ensure each named argument belongs to a param
kwarg_keys = set(kwargs)
param_names = set(p.name for p in params)
extra_args = kwarg_keys.difference(param_names)
if extra_args:
raise exception.InvalidParameterValue(
_('Unexpected arguments: %s') % ', '.join(extra_args))
for i, param in enumerate(params):
if i == 0 and param.name == 'self':
# skip validating self
continue
val_function = validators.get(param.name)
if not val_function:
continue
if i < args_len:
# validate positional argument
args[i] = val_function(param.name, args[i])
next_arg_index = i + 1
elif param.name in kwargs:
# validate keyword argument
kwargs_next[param.name] = val_function(
param.name, kwargs.pop(param.name))
elif param.default == inspect.Parameter.empty:
# no argument was provided, and there is no default
# in the parameter, so this is a mandatory argument
raise exception.InvalidParameterValue(
_('Missing mandatory parameter: %s') % param.name)
if param_positional:
# handle validating *args
val_function = validators.get(param_positional.name)
remaining = args[next_arg_index:]
if val_function and remaining:
args = args[:next_arg_index]
args.extend(val_function(param_positional.name, remaining))
# handle validating remaining **kwargs
if kwargs:
val_function = (param_keyword
and validators.get(param_keyword.name))
if val_function:
kwargs_next.update(
val_function(param_keyword.name, kwargs))
else:
# make sure unvalidated keyword arguments are kept
kwargs_next.update(kwargs)
return function(*args, **kwargs_next)
return inner_check_args
return inner_function
patch = schema({
'type': 'array',
'items': {
'type': 'object',
'properties': {
'path': {'type': 'string', 'pattern': '^(/[\\w-]+)+$'},
'op': {'type': 'string', 'enum': ['add', 'replace', 'remove']},
'value': {}
},
'additionalProperties': False
}
})
"""Validate a patch API operation"""

View File

@ -0,0 +1,618 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from oslo_utils import uuidutils
from ironic.common import args
from ironic.common import exception
from ironic.tests import base
class ArgsDecorated(object):
@args.validate(one=args.string,
two=args.boolean,
three=args.uuid,
four=args.uuid_or_name)
def method(self, one, two, three, four):
return one, two, three, four
@args.validate(one=args.string)
def needs_string(self, one):
return one
@args.validate(one=args.boolean)
def needs_boolean(self, one):
return one
@args.validate(one=args.uuid)
def needs_uuid(self, one):
return one
@args.validate(one=args.name)
def needs_name(self, one):
return one
@args.validate(one=args.uuid_or_name)
def needs_uuid_or_name(self, one):
return one
@args.validate(one=args.string_list)
def needs_string_list(self, one):
return one
@args.validate(one=args.integer)
def needs_integer(self, one):
return one
@args.validate(one=args.mac_address)
def needs_mac_address(self, one):
return one
@args.validate(one=args.schema({
'type': 'array',
'items': {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'count': {'type': 'integer', 'minimum': 0},
},
'additionalProperties': False,
'required': ['name'],
}
}))
def needs_schema(self, one):
return one
@args.validate(one=args.string, two=args.string, the_rest=args.schema({
'type': 'object',
'properties': {
'three': {'type': 'string'},
'four': {'type': 'string', 'maxLength': 4},
'five': {'type': 'string'},
},
'additionalProperties': False,
'required': ['three']
}))
def needs_schema_kwargs(self, one, two, **the_rest):
return one, two, the_rest
@args.validate(one=args.string, two=args.string, the_rest=args.schema({
'type': 'array',
'items': {'type': 'string'}
}))
def needs_schema_args(self, one, two=None, *the_rest):
return one, two, the_rest
@args.validate(one=args.string, two=args.string, args=args.schema({
'type': 'array',
'items': {'type': 'string'}
}), kwargs=args.schema({
'type': 'object',
'properties': {
'four': {'type': 'string'},
},
}))
def needs_schema_mixed(self, one, two=None, *args, **kwargs):
return one, two, args, kwargs
@args.validate(one=args.string)
def needs_mixed_unvalidated(self, one, two=None, *args, **kwargs):
return one, two, args, kwargs
@args.validate(body=args.patch)
def patch(self, body):
return body
class BaseTest(base.TestCase):
def setUp(self):
super(BaseTest, self).setUp()
self.decorated = ArgsDecorated()
class ValidateDecoratorTest(BaseTest):
def test_decorated_args(self):
uuid = uuidutils.generate_uuid()
self.assertEqual((
'a',
True,
uuid,
'a_name',
), self.decorated.method(
'a',
True,
uuid,
'a_name',
))
def test_decorated_kwargs(self):
uuid = uuidutils.generate_uuid()
self.assertEqual((
'a',
True,
uuid,
'a_name',
), self.decorated.method(
one='a',
two=True,
three=uuid,
four='a_name',
))
def test_decorated_args_kwargs(self):
uuid = uuidutils.generate_uuid()
self.assertEqual((
'a',
True,
uuid,
'a_name',
), self.decorated.method(
'a',
True,
uuid,
four='a_name',
))
def test_decorated_function(self):
@args.validate(one=args.string,
two=args.boolean,
three=args.uuid,
four=args.uuid_or_name)
def func(one, two, three, four):
return one, two, three, four
uuid = uuidutils.generate_uuid()
self.assertEqual((
'a',
True,
uuid,
'a_name',
), func(
'a',
'yes',
uuid,
four='a_name',
))
def test_unexpected_args(self):
uuid = uuidutils.generate_uuid()
e = self.assertRaises(
exception.InvalidParameterValue,
self.decorated.method,
one='a',
two=True,
three=uuid,
four='a_name',
five='5',
six=6
)
self.assertIn('Unexpected arguments: ', str(e))
self.assertIn('five', str(e))
self.assertIn('six', str(e))
def test_string(self):
self.assertEqual('foo', self.decorated.needs_string('foo'))
self.assertIsNone(self.decorated.needs_string(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_string, 123)
def test_boolean(self):
self.assertTrue(self.decorated.needs_boolean('yes'))
self.assertTrue(self.decorated.needs_boolean('true'))
self.assertTrue(self.decorated.needs_boolean(True))
self.assertFalse(self.decorated.needs_boolean('no'))
self.assertFalse(self.decorated.needs_boolean('false'))
self.assertFalse(self.decorated.needs_boolean(False))
self.assertIsNone(self.decorated.needs_boolean(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_boolean,
'yeah nah yeah nah')
def test_uuid(self):
uuid = uuidutils.generate_uuid()
self.assertEqual(uuid, self.decorated.needs_uuid(uuid))
self.assertIsNone(self.decorated.needs_uuid(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_uuid, uuid + 'XXX')
def test_name(self):
self.assertEqual('foo', self.decorated.needs_name('foo'))
self.assertIsNone(self.decorated.needs_name(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_name, 'I am a name')
def test_uuid_or_name(self):
uuid = uuidutils.generate_uuid()
self.assertEqual(uuid, self.decorated.needs_uuid_or_name(uuid))
self.assertEqual('foo', self.decorated.needs_uuid_or_name('foo'))
self.assertIsNone(self.decorated.needs_uuid_or_name(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_uuid_or_name,
'I am a name')
def test_string_list(self):
self.assertEqual([
'foo', 'bar', 'baz'
], self.decorated.needs_string_list('foo, bar ,bAZ'))
self.assertIsNone(self.decorated.needs_name(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_name, True)
def test_integer(self):
self.assertEqual(123, self.decorated.needs_integer(123))
self.assertIsNone(self.decorated.needs_integer(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_integer,
'more than a number')
def test_mac_address(self):
self.assertEqual('02:ce:20:50:68:6f',
self.decorated.needs_mac_address('02:cE:20:50:68:6F'))
self.assertIsNone(self.decorated.needs_mac_address(None))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_mac_address,
'big:mac')
def test_mixed_unvalidated(self):
# valid
self.assertEqual((
'one', 'two', ('three', 'four', 'five'), {}
), self.decorated.needs_mixed_unvalidated(
'one', 'two', 'three', 'four', 'five',
))
self.assertEqual((
'one', 'two', ('three',), {'four': 'four', 'five': 'five'}
), self.decorated.needs_mixed_unvalidated(
'one', 'two', 'three', four='four', five='five',
))
self.assertEqual((
'one', 'two', (), {}
), self.decorated.needs_mixed_unvalidated(
'one', 'two',
))
self.assertEqual((
'one', None, (), {}
), self.decorated.needs_mixed_unvalidated(
'one',
))
# wrong type in one
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_mixed_unvalidated, 1)
def test_mandatory(self):
@args.validate(foo=args.string)
def doit(foo):
return foo
@args.validate(foo=args.string)
def doit_maybe(foo='baz'):
return foo
# valid
self.assertEqual('bar', doit('bar'))
# invalid, argument not provided
self.assertRaises(exception.InvalidParameterValue, doit)
# valid, not mandatory
self.assertEqual('baz', doit_maybe())
def test_or(self):
@args.validate(foo=args.or_valid(
args.string,
args.integer,
args.boolean
))
def doit(foo):
return foo
# valid
self.assertEqual('bar', doit('bar'))
self.assertEqual(1, doit(1))
self.assertEqual(True, doit(True))
# invalid, wrong type
self.assertRaises(exception.InvalidParameterValue, doit, {})
def test_and(self):
@args.validate(foo=args.and_valid(
args.string,
args.name
))
def doit(foo):
return foo
# valid
self.assertEqual('bar', doit('bar'))
# invalid, not a string
self.assertRaises(exception.InvalidParameterValue, doit, 2)
# invalid, not a name
self.assertRaises(exception.InvalidParameterValue, doit, 'not a name')
class ValidateSchemaTest(BaseTest):
def test_schema(self):
valid = [
{'name': 'zero'},
{'name': 'one', 'count': 1},
{'name': 'two', 'count': 2}
]
invalid_count = [
{'name': 'neg', 'count': -1},
{'name': 'one', 'count': 1},
{'name': 'two', 'count': 2}
]
invalid_root = {}
self.assertEqual(valid, self.decorated.needs_schema(valid))
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema,
invalid_count)
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema,
invalid_root)
def test_schema_needs_kwargs(self):
# valid
self.assertEqual((
'one', 'two', {
'three': 'three',
'four': 'four',
'five': 'five',
}
), self.decorated.needs_schema_kwargs(
one='one',
two='two',
three='three',
four='four',
five='five',
))
self.assertEqual((
'one', 'two', {
'three': 'three',
}
), self.decorated.needs_schema_kwargs(
one='one',
two='two',
three='three',
))
self.assertEqual((
'one', 'two', {}
), self.decorated.needs_schema_kwargs(
one='one',
two='two',
))
# missing mandatory 'three'
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema_kwargs,
one='one', two='two', four='four', five='five')
# 'four' value exceeds length
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema_kwargs,
one='one', two='two', three='three',
four='beforefore', five='five')
def test_schema_needs_args(self):
# valid
self.assertEqual((
'one', 'two', ('three', 'four', 'five')
), self.decorated.needs_schema_args(
'one', 'two', 'three', 'four', 'five',
))
self.assertEqual((
'one', 'two', ('three',)
), self.decorated.needs_schema_args(
'one', 'two', 'three',
))
self.assertEqual((
'one', 'two', ()
), self.decorated.needs_schema_args(
'one', 'two',
))
self.assertEqual((
'one', None, ()
), self.decorated.needs_schema_args(
'one',
))
# failed, non string *the_rest value
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema_args,
'one', 'two', 'three', 4, False)
def test_schema_needs_mixed(self):
# valid
self.assertEqual((
'one', 'two', ('three', 'four', 'five'), {}
), self.decorated.needs_schema_mixed(
'one', 'two', 'three', 'four', 'five',
))
self.assertEqual((
'one', 'two', ('three', ), {'four': 'four'}
), self.decorated.needs_schema_mixed(
'one', 'two', 'three', four='four',
))
self.assertEqual((
'one', 'two', (), {'four': 'four'}
), self.decorated.needs_schema_mixed(
'one', 'two', four='four',
))
self.assertEqual((
'one', None, (), {}
), self.decorated.needs_schema_mixed(
'one',
))
# wrong type in *args
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema_mixed,
'one', 'two', 3, four='four')
# wrong type in *kwargs
self.assertRaises(exception.InvalidParameterValue,
self.decorated.needs_schema_mixed,
'one', 'two', 'three', four=4)
class ValidatePatchSchemaTest(BaseTest):
def test_patch(self):
data = [{
'path': '/foo',
'op': 'replace',
'value': 'bar'
}, {
'path': '/foo/bar',
'op': 'add',
'value': True
}, {
'path': '/foo/bar/baz',
'op': 'remove',
'value': 123
}]
self.assertEqual(
data,
self.decorated.patch(data)
)
def assertValidationFailed(self, data, error_snippets=None):
e = self.assertRaises(exception.InvalidParameterValue,
self.decorated.patch, data)
if error_snippets:
for s in error_snippets:
self.assertIn(s, str(e))
def test_patch_validation_failed(self):
self.assertValidationFailed(
{},
["Schema error for body:",
"{} is not of type 'array'"])
self.assertValidationFailed(
[{
'path': '/foo/bar/baz',
'op': 'fribble',
'value': 123
}],
["Schema error for body:",
"'fribble' is not one of ['add', 'replace', 'remove']"])
self.assertValidationFailed(
[{
'path': '/',
'op': 'add',
'value': 123
}],
["Schema error for body:",
"'/' does not match"])
self.assertValidationFailed(
[{
'path': 'foo/',
'op': 'add',
'value': 123
}],
["Schema error for body:",
"'foo/' does not match"])
self.assertValidationFailed(
[{
'path': '/foo bar',
'op': 'add',
'value': 123
}],
["Schema error for body:",
"'/foo bar' does not match"])
class ValidateDictTest(BaseTest):
def test_dict_valid(self):
uuid = uuidutils.generate_uuid()
@args.validate(foo=args.dict_valid(
bar=args.uuid
))
def doit(foo):
return foo
# validate passes
doit(foo={'bar': uuid})
# tolerate other keys
doit(foo={'bar': uuid, 'baz': 'baz'})
# key missing
doit({})
# value fails validation
e = self.assertRaises(exception.InvalidParameterValue,
doit, {'bar': uuid + 'XXX'})
self.assertIn('Expected UUID for bar:', str(e))
# not a dict
e = self.assertRaises(exception.InvalidParameterValue,
doit, 'asdf')
self.assertIn("Expected types <class 'dict'> for foo: asdf", str(e))
def test_dict_valid_colon_key_name(self):
uuid = uuidutils.generate_uuid()
@args.validate(foo=args.dict_valid(**{
'bar:baz': args.uuid
}
))
def doit(foo):
return foo
# validate passes
doit(foo={'bar:baz': uuid})
# value fails validation
e = self.assertRaises(exception.InvalidParameterValue,
doit, {'bar:baz': uuid + 'XXX'})
self.assertIn('Expected UUID for bar:', str(e))
class ValidateTypesTest(BaseTest):
def test_types(self):
@args.validate(foo=args.types(type(None), dict, str))
def doit(foo):
return foo
# valid None
self.assertIsNone(doit(None))
# valid dict
self.assertEqual({'foo': 'bar'}, doit({'foo': 'bar'}))
# valid string
self.assertEqual('foo', doit('foo'))
# invalid integer
e = self.assertRaises(exception.InvalidParameterValue,
doit, 123)
self.assertIn("Expected types "
"<class 'NoneType'>, <class 'dict'>, <class 'str'> "
"for foo: 123", str(e))