Skip to content

Commit 3fc85c1

Browse files
committed
Implement declarative payloads
Add the logic to automatically package values as a bundle. Implement declarative argument definitions and logic to bundle values. carve out payloads and rework them to make use of the validation helper Expand the arguments module with the notions of a defined grouping of arguments (ArgumentBundleDefinition) and bundles of particular arguments (ArgumentBundle). Use this as the mechanism by which payloads are checked for validity. As part of declaring a bundle definition the expected positional arguments are declared so implement basic length checks that catch missing positional arguments which were required. Each argument itself is also tested aganst an optional validity function which can be specified at the point of definition. try to combine payloads with the argument stuff move the test further re-integration match the other branch fixup fixup fixup repair and test error conditions fixup fixup fixup quack like a named tuple raise uniform payload exceptions and properly handle dictionaries
1 parent 187b17c commit 3fc85c1

File tree

3 files changed

+292
-0
lines changed

3 files changed

+292
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ downloads/
1515
eggs/
1616
.eggs/
1717
lib/
18+
!/mig/lib/
1819
lib64/
1920
parts/
2021
sdist/

mig/lib/coresvc/payloads.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
from collections import defaultdict, namedtuple, OrderedDict
2+
3+
from mig.shared.safeinput import validate_helper
4+
5+
6+
_EMPTY_LIST = {}
7+
_REQUIRED_FIELD = object()
8+
9+
10+
def _is_not_none(value):
11+
"""value is not None"""
12+
assert value is not None, _is_not_none.__doc__
13+
14+
15+
def _is_string_and_non_empty(value):
16+
"""value is a non-empty string"""
17+
assert isinstance(value, str) and len(value) > 0, _is_string_and_non_empty.__doc__
18+
19+
20+
class PayloadException(ValueError):
21+
def __str__(self):
22+
return self.serialize(output_format='text')
23+
24+
def serialize(self, output_format='text'):
25+
error_message = self.args[0]
26+
27+
if output_format == 'json':
28+
return dict(error=error_message)
29+
else:
30+
return error_message
31+
32+
33+
class PayloadReport(PayloadException):
34+
def __init__(self, errors_by_field):
35+
self.errors_by_field = errors_by_field
36+
37+
def serialize(self, output_format='text'):
38+
if output_format == 'json':
39+
return dict(errors=self.errors_by_field)
40+
else:
41+
lines = ["- %s: %s" %
42+
(k, v) for k, v in self.errors_by_field.items()]
43+
lines.insert(0, '')
44+
return 'payload failed to validate:%s' % ('\n'.join(lines),)
45+
46+
47+
class _MissingField:
48+
def __init__(self, field, message=None):
49+
assert message is not None
50+
self._field = field
51+
self._message = message
52+
53+
def replace(self, _, __):
54+
return self._field
55+
56+
@classmethod
57+
def assert_not_instance(cls, value):
58+
assert not isinstance(value, cls), value._message
59+
60+
61+
class Payload(OrderedDict):
62+
def __init__(self, definition, dictionary):
63+
super(Payload, self).__init__(dictionary)
64+
self._definition = definition
65+
66+
@property
67+
def _fields(self):
68+
return self._definition._fields
69+
70+
@property
71+
def name(self):
72+
return self._definition._definition_name
73+
74+
def __iter__(self):
75+
return iter(self.values())
76+
77+
def items(self):
78+
return zip(self._definition._item_names, self.values())
79+
80+
@staticmethod
81+
def define(payload_name, payload_fields, validators_by_field):
82+
positionals = list((field, validators_by_field[field]) for field in payload_fields)
83+
return PayloadDefinition(payload_name, positionals)
84+
85+
86+
class PayloadDefinition:
87+
def __init__(self, name, positionals=_EMPTY_LIST):
88+
self._definition_name = name
89+
self._expected_positions = 0
90+
self._item_checks = []
91+
self._item_names = []
92+
93+
if positionals is not _EMPTY_LIST:
94+
for positional in positionals:
95+
self._define_positional(positional)
96+
97+
@property
98+
def _fields(self):
99+
return self._item_names
100+
101+
def __call__(self, *args):
102+
return self._extract_and_bundle(args, extract_by='position')
103+
104+
def _define_positional(self, positional):
105+
assert len(positional) == 2
106+
107+
name, validator_fn = positional
108+
109+
self._item_names.append(name)
110+
self._item_checks.append(validator_fn)
111+
112+
self._expected_positions += 1
113+
114+
def _extract_and_bundle(self, args, extract_by=None):
115+
definition = self
116+
117+
if extract_by == 'position':
118+
actual_positions = len(args)
119+
expected_positions = definition._expected_positions
120+
if actual_positions < expected_positions:
121+
raise PayloadException('Error: too few arguments given (expected %d got %d)' % (
122+
expected_positions, actual_positions))
123+
positions = list(range(actual_positions))
124+
dictionary = {definition._item_names[position]: args[position] for position in positions}
125+
elif extract_by == 'name':
126+
dictionary = {key: args.get(key, None) for key in definition._item_names}
127+
else:
128+
raise RuntimeError()
129+
130+
return Payload(definition, dictionary)
131+
132+
def ensure(self, bundle_or_args):
133+
bundle_definition = self
134+
135+
if isinstance(bundle_or_args, Payload):
136+
assert bundle_or_args.name == bundle_definition._definition_name
137+
return bundle_or_args
138+
elif isinstance(bundle_or_args, dict):
139+
bundle = self._extract_and_bundle(bundle_or_args, extract_by='name')
140+
else:
141+
bundle = bundle_definition(*bundle_or_args)
142+
143+
return _validate_bundle(self, bundle)
144+
145+
def ensure_bundle(self, bundle_or_args):
146+
return self.ensure(bundle_or_args)
147+
148+
def to_checks(self):
149+
type_checks = {}
150+
for key in self._fields:
151+
type_checks[key] = _MissingField.assert_not_instance
152+
153+
value_checks = dict(zip(self._item_names, self._item_checks))
154+
155+
return type_checks, value_checks
156+
157+
158+
def _extract_field_error(bad_value):
159+
try:
160+
message = bad_value[0][1]
161+
if not message:
162+
raise IndexError
163+
return message
164+
except IndexError:
165+
return 'required'
166+
167+
168+
def _prepare_validate_helper_input(definition, payload):
169+
def _covert_field_value(payload, field):
170+
value = payload.get(field, _REQUIRED_FIELD)
171+
if value is _REQUIRED_FIELD:
172+
return _MissingField(field, 'required')
173+
if value is None:
174+
return _MissingField(field, 'missing')
175+
return value
176+
return {field: _covert_field_value(payload, field)
177+
for field in definition._fields}
178+
179+
180+
def _validate_bundle(definition, payload):
181+
assert isinstance(payload, Payload)
182+
183+
input_dict = _prepare_validate_helper_input(definition, payload)
184+
type_checks, value_checks = definition.to_checks()
185+
_, bad_values = validate_helper(input_dict, definition._fields,
186+
type_checks, value_checks, list_wrap=True)
187+
188+
if bad_values:
189+
errors_by_field = {field_name: _extract_field_error(bad_value)
190+
for field_name, bad_value in bad_values.items()}
191+
raise PayloadReport(errors_by_field)
192+
193+
return payload
194+
195+
196+
PAYLOAD_POST_USER = Payload.define('PostUserArgs', [
197+
'full_name',
198+
'organization',
199+
'state',
200+
'country',
201+
'email',
202+
'comment',
203+
'password',
204+
], defaultdict(lambda: _is_not_none, dict(
205+
full_name=_is_string_and_non_empty,
206+
organization=_is_string_and_non_empty,
207+
state=_is_string_and_non_empty,
208+
country=_is_string_and_non_empty,
209+
email=_is_string_and_non_empty,
210+
comment=_is_string_and_non_empty,
211+
password=_is_string_and_non_empty,
212+
)))
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import print_function
2+
import sys
3+
4+
from tests.support import MigTestCase, testmain
5+
6+
from mig.lib.coresvc.payloads import \
7+
Payload as ArgumentBundle, \
8+
PayloadDefinition as ArgumentBundleDefinition, \
9+
PayloadException
10+
11+
12+
def _contains_a_thing(value):
13+
assert 'thing' in value
14+
15+
16+
def _upper_case_only(value):
17+
"""value must be upper case"""
18+
assert value == value.upper(), _upper_case_only.__doc__
19+
20+
21+
class TestMigSharedArguments__bundles(MigTestCase):
22+
ThingsBundle = ArgumentBundleDefinition('Things', [
23+
('some_field', _contains_a_thing),
24+
('other_field', _contains_a_thing),
25+
])
26+
27+
def assertBundleOfKind(self, value, bundle_kind=None):
28+
assert isinstance(bundle_kind, str) and bundle_kind
29+
self.assertIsInstance(value, ArgumentBundle, "value is not an argument bundle")
30+
self.assertEqual(value.name, bundle_kind, "expected %s bundle, got %s" % (bundle_kind, value.name))
31+
32+
def test_bundling_arguments_produces_a_bundle(self):
33+
bundle = self.ThingsBundle('abcthing', 'thingdef')
34+
35+
self.assertBundleOfKind(bundle, bundle_kind='Things')
36+
37+
def test_raises_on_missing_positional_arguments(self):
38+
with self.assertRaises(PayloadException) as raised:
39+
self.ThingsBundle(['a'])
40+
self.assertEqual(str(raised.exception), 'Error: too few arguments given (expected 2 got 1)')
41+
42+
def test_ensuring_arguments_returns_a_bundle(self):
43+
bundle = self.ThingsBundle.ensure_bundle(['abcthing', 'thingdef'])
44+
45+
self.assertBundleOfKind(bundle, bundle_kind='Things')
46+
47+
def test_ensuring_an_existing_bundle_returns_it_unchanged(self):
48+
existing_bundle = self.ThingsBundle('abcthing', 'thingdef')
49+
50+
bundle = self.ThingsBundle.ensure_bundle(existing_bundle)
51+
52+
self.assertIs(bundle, existing_bundle)
53+
54+
def test_ensuring_on_a_list_of_args_validates_them(self):
55+
with self.assertRaises(Exception) as raised:
56+
bundle = self.ThingsBundle.ensure_bundle(['abcthing', 'def'])
57+
self.assertEqual(str(raised.exception), 'payload failed to validate:\n- other_field: required')
58+
59+
def test_ensuring_on_invalid_args_produces_reports_with_errors(self):
60+
UpperCaseValue = ArgumentBundle.define('UpperCaseValue', ['ustring'], {
61+
'ustring': _upper_case_only
62+
})
63+
64+
with self.assertRaises(Exception) as raised:
65+
bundle = UpperCaseValue.ensure_bundle(['lowerCHARS'])
66+
self.assertEqual(str(raised.exception), 'payload failed to validate:\n- ustring: value must be upper case')
67+
68+
def test_ensuring_on_invalid_args_containing_none_behaves_correctly(self):
69+
UpperCaseValue = ArgumentBundle.define('UpperCaseValue', ['ustring'], {
70+
'ustring': _upper_case_only
71+
})
72+
73+
with self.assertRaises(Exception) as raised:
74+
bundle = UpperCaseValue.ensure_bundle([None])
75+
self.assertEqual(str(raised.exception), 'payload failed to validate:\n- ustring: missing')
76+
77+
78+
if __name__ == '__main__':
79+
testmain()

0 commit comments

Comments
 (0)