From de2db5e1cefd2a2fe85856c72e4915039eeaf3f6 Mon Sep 17 00:00:00 2001 From: "Jindrich K. Smitka" Date: Mon, 22 May 2017 19:07:21 +0200 Subject: [PATCH 1/4] Added json decoder --- pydecoder/jsondecode.py | 44 +++++++++++++++ tests/test_decodes.py | 111 ++++++++++++++++++++++++++++++++++++++ tests/test_xml_decoder.py | 72 ------------------------- 3 files changed, 155 insertions(+), 72 deletions(-) create mode 100644 pydecoder/jsondecode.py create mode 100644 tests/test_decodes.py delete mode 100644 tests/test_xml_decoder.py diff --git a/pydecoder/jsondecode.py b/pydecoder/jsondecode.py new file mode 100644 index 00000000..3cc42832 --- /dev/null +++ b/pydecoder/jsondecode.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +'''Library fo json decode''' + +from pyresult import ( + ok, + error, + rmap, + fold, + # and_then as andthen # pylint: disable=import-error +) +from toolz import curry, get_in + + +def _to_key_list(keys): + if not isinstance(keys, tuple) and not isinstance(keys, list): + return (keys, ) + return keys + + +@curry +def getter(json, keys): + '''Get data from json''' + + try: + return ok( + get_in( + _to_key_list(keys), + json, + no_default=True + ) + ) + except KeyError: + return error(u'Value is empty or path/key {0!r} not found...'.format(keys)) + + +@curry +def json(creator, decoders, json_data): + '''Run decoders on json and result pass to creator function + + json: (args -> value) -> List Decoder -> Json -> Result err value + ''' + values = [decoder(getter(json_data)) for decoder in decoders] # pylint: disable=no-value-for-parameter + + return rmap(creator, fold(values)) diff --git a/tests/test_decodes.py b/tests/test_decodes.py new file mode 100644 index 00000000..d51bad83 --- /dev/null +++ b/tests/test_decodes.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-docstring, import-error, redefined-outer-name, no-value-for-parameter + +from json import loads +from xml.etree import ElementTree as ET + +import pytest + +from pydecoder.fields import required +from pydecoder.xmldecode import xml, to_string, to_int +from pydecoder.jsondecode import json +from pyresult import is_ok, is_error, value + + +PARAM = pytest.mark.parametrize + + +def xmlstring(): + return ''' + + 1 + 2 + 3 + foo + true + + ''' + + +def tree(): + return ET.fromstring(xmlstring()) + + +def jsonstring(): + return '{"a": [1, 2, 3], "c": "foo", "d": true}' + + +def json_data(): + return loads(jsonstring()) + + +DECODERS_AND_DATA = ( + (xml, tree()), + (json, json_data()), +) + + +IDS = ( + 'XML', + 'JSON', +) + +def creator(values): + return values + + + +@PARAM('decoder, data', DECODERS_AND_DATA, ids=IDS) +def test_return_ok_result(decoder, data): + rv = decoder( + creator, + ( + required('c', to_string), + ), + data + ) + + assert is_ok(rv) + assert value(rv) == ['foo', ] + + +@PARAM('decoder, data', DECODERS_AND_DATA, ids=IDS) +def test_return_error_result(decoder, data): + rv = decoder( + creator, + ( + required('f', to_string), + ), + data + ) + + assert is_error(rv) + + +@PARAM('decoder, data', DECODERS_AND_DATA, ids=IDS) +def test_return_error_result_with_aggregate_messages(decoder, data): + rv = decoder( + creator, + ( + required('f', to_string), + required('e', to_string), + ), + data + ) + + assert is_error(rv) + for msg in rv.value: + assert msg.find(u'Value is empty') > -1 + + +def test_json_decode_return_ok_result_by_path(): + rv = json( + creator, + ( + required(['a', 2], to_int), + ), + json_data() + ) + + assert is_ok(rv) + assert rv.value == [3, ] diff --git a/tests/test_xml_decoder.py b/tests/test_xml_decoder.py deleted file mode 100644 index e0d08021..00000000 --- a/tests/test_xml_decoder.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=missing-docstring, import-error, redefined-outer-name, no-value-for-parameter - -from xml.etree import ElementTree as ET - -import pytest - -from pydecoder.fields import required -from pydecoder.xml import xml, to_string -from pyresult import is_ok, is_error, value - - -def creator(values): - return values - - -@pytest.fixture -def xmlstring(): - return ''' - - 1 - 2 - 3 - foo - true - - ''' - - -@pytest.fixture -def tree(xmlstring): - return ET.fromstring(xmlstring) - - -def test_xml_return_ok_result(tree): - rv = xml( - creator, - ( - required('c', to_string), - ), - tree - ) - - assert is_ok(rv) - assert value(rv) == ['foo', ] - - -def test_xml_return_error_result(tree): - rv = xml( - creator, - ( - required('f', to_string), - ), - tree - ) - - assert is_error(rv) - - -def test_xml_return_error_result_with_aggregate_messages(tree): - rv = xml( - creator, - ( - required('f', to_string), - required('e', to_string), - ), - tree - ) - - assert is_error(rv) - for msg in rv.value: - assert msg.find(u'Value is empty.') > -1 From b6d1a09a61a2bd75c37f5f1a987958076b64330e Mon Sep 17 00:00:00 2001 From: "Jindrich K. Smitka" Date: Thu, 25 May 2017 12:44:53 +0200 Subject: [PATCH 2/4] Refactored module import names --- pydecoder/{jsondecode.py => json.py} | 0 tests/test_decodes.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename pydecoder/{jsondecode.py => json.py} (100%) diff --git a/pydecoder/jsondecode.py b/pydecoder/json.py similarity index 100% rename from pydecoder/jsondecode.py rename to pydecoder/json.py diff --git a/tests/test_decodes.py b/tests/test_decodes.py index d51bad83..c720e082 100644 --- a/tests/test_decodes.py +++ b/tests/test_decodes.py @@ -7,8 +7,8 @@ import pytest from pydecoder.fields import required -from pydecoder.xmldecode import xml, to_string, to_int -from pydecoder.jsondecode import json +from pydecoder.xml import xml, to_string, to_int +from pydecoder.json import json from pyresult import is_ok, is_error, value From 9dcbf522d4e311ae57bda7e420b2f6e7eed88734 Mon Sep 17 00:00:00 2001 From: "Jindrich K. Smitka" Date: Thu, 25 May 2017 13:25:21 +0200 Subject: [PATCH 3/4] Refactored project structure --- pydecoder/json.py | 15 ++++-- pydecoder/operators.py | 37 +++++++++++++++ pydecoder/primitives.py | 55 +++++++++++++++++++++ pydecoder/structs.py | 10 ++++ pydecoder/xml.py | 103 +++++++--------------------------------- tests/test_decodes.py | 24 +++++----- 6 files changed, 141 insertions(+), 103 deletions(-) create mode 100644 pydecoder/operators.py create mode 100644 pydecoder/primitives.py create mode 100644 pydecoder/structs.py diff --git a/pydecoder/json.py b/pydecoder/json.py index 3cc42832..dc037473 100644 --- a/pydecoder/json.py +++ b/pydecoder/json.py @@ -5,11 +5,18 @@ ok, error, rmap, - fold, - # and_then as andthen # pylint: disable=import-error + fold ) from toolz import curry, get_in +from pydecoder.primitives import ( # noqa pylint: disable=unused-import + to_int, + to_float, + to_string, + to_bool, + null, +) + def _to_key_list(keys): if not isinstance(keys, tuple) and not isinstance(keys, list): @@ -18,7 +25,7 @@ def _to_key_list(keys): @curry -def getter(json, keys): +def getter(json, keys): # pylint: disable=redefined-outer-name '''Get data from json''' try: @@ -34,7 +41,7 @@ def getter(json, keys): @curry -def json(creator, decoders, json_data): +def decode(creator, decoders, json_data): '''Run decoders on json and result pass to creator function json: (args -> value) -> List Decoder -> Json -> Result err value diff --git a/pydecoder/operators.py b/pydecoder/operators.py new file mode 100644 index 00000000..5e3412ef --- /dev/null +++ b/pydecoder/operators.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +'''Decoder operators''' + +from pyresult import ( + ok, + rmap, + and_then as andthen +) +from six.moves import reduce +from toolz import curry + + +@curry +def and_then(next_decoder, decoder, val): + '''Run `next_decoder` after `decoder` success run with `val` + + and_then: (a -> Result b) -> (val -> Result a) -> val -> Result b + ''' + return andthen(next_decoder, decoder(val)) + + +@curry +def dmap(func, decoder, val): + '''Run `decoder` with `val` and then map `func` to result + + dmap: (a -> value) -> (val -> Result a) -> val -> Result value + ''' + return rmap(func, decoder(val)) + + +@curry +def pipe(funcs, val): + '''Pass value trough funcs + + pipe: List (value -> Result a) -> value -> Result a + ''' + return reduce(lambda acc, item: andthen(item, acc), funcs, ok(val)) diff --git a/pydecoder/primitives.py b/pydecoder/primitives.py new file mode 100644 index 00000000..8d21d621 --- /dev/null +++ b/pydecoder/primitives.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +'''Decode primitives''' + +from pyresult import ( + ok, + error +) +from six import u, string_types, text_type + + +def to_int(val): + '''Decode string/value to int''' + try: + return ok(int(val)) + except (TypeError, ValueError) as err: + return error(text_type(err)) + + +def to_float(val): + '''Decode string/value to float''' + try: + return ok(float(val)) + except (TypeError, ValueError) as err: + return error(text_type(err)) + + +def to_string(val): + '''Decode string/value to string''' + if val is None: + return error(u'Can\'t be null') + + return ok(val) if isinstance(val, text_type) else ok(text_type(val, 'utf-8')) + + +def to_bool(val): + '''Decode string/value to boolean''' + if isinstance(val, string_types): # pylint: disable=no-else-return + lowered_string = val.lower() + if lowered_string == 'true': # pylint: disable=no-else-return + return ok(True) + elif lowered_string == 'false': + return ok(False) + else: + return error(u('String %s is invalid boolean value.' % val)) + elif isinstance(val, bool): + return ok(val) + else: + return error(u('Value can\'t be decoded')) + + +def null(val): + '''Decode string/value to None''' + if val is not None and val.lower() not in ('none', 'null'): + return error(u('Value can\'t be decoded')) + return ok(None) diff --git a/pydecoder/structs.py b/pydecoder/structs.py new file mode 100644 index 00000000..16b958e3 --- /dev/null +++ b/pydecoder/structs.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +'''Decoder structures''' + +from toolz import curry + + +@curry +def array(factory, vals): + '''Decode string/value as list''' + return [factory(val) for val in vals] diff --git a/pydecoder/xml.py b/pydecoder/xml.py index a70bbebd..db1ab3cf 100644 --- a/pydecoder/xml.py +++ b/pydecoder/xml.py @@ -3,8 +3,8 @@ from collections import Iterator -from six import wraps, u, string_types, text_type -from six.moves import map, reduce +from six import wraps, u +from six.moves import map from toolz import curry, first @@ -13,7 +13,14 @@ error, rmap, fold, - and_then as andthen # pylint: disable=import-error +) + +from pydecoder.primitives import ( + to_int, + to_float, + to_string, + to_bool, + null, ) @@ -38,7 +45,7 @@ def getter(tree, key): @curry -def xml(creator, decoders, tree): +def decode(creator, decoders, tree): '''Run decoders on xml and result pass to creator function xml: (args -> value) -> List Decoder -> ElementTree -> Result value err @@ -48,86 +55,8 @@ def xml(creator, decoders, tree): return rmap(creator, fold(values)) -@curry -def array(factory, vals): - '''Decode string/value as list''' - return [factory(val) for val in vals] - - -@curry -def and_then(next_decoder, decoder, val): - '''Run `next_decoder` after `decoder` success run with `val` - - and_then: (a -> Result b) -> (val -> Result a) -> val -> Result b - ''' - return andthen(next_decoder, decoder(val)) - - -@curry -def dmap(func, decoder, val): - '''Run `decoder` with `val` and then map `func` to result - - dmap: (a -> value) -> (val -> Result a) -> val -> Result value - ''' - return rmap(func, decoder(val)) - - -@curry -def pipe(funcs, val): - '''Pass value trough funcs - - pipe: List (value -> Result a) -> value -> Result a - ''' - return reduce(lambda acc, item: andthen(item, acc), funcs, ok(val)) - - -@to_val -def to_int(val): - '''Decode string/value to int''' - try: - return ok(int(val)) - except (TypeError, ValueError) as err: - return error(text_type(err)) - - -@to_val -def to_float(val): - '''Decode string/value to float''' - try: - return ok(float(val)) - except (TypeError, ValueError) as err: - return error(text_type(err)) - - -@to_val -def to_string(val): - '''Decode string/value to string''' - if val is None: - return error(u'Can\'t be null') - - return ok(val) if isinstance(val, text_type) else ok(text_type(val, 'utf-8')) - - -@to_val -def to_bool(val): - '''Decode string/value to boolean''' - if isinstance(val, string_types): # pylint: disable=no-else-return - lowered_string = val.lower() - if lowered_string == 'true': # pylint: disable=no-else-return - return ok(True) - elif lowered_string == 'false': - return ok(False) - else: - return error(u('String %s is invalid boolean value.' % val)) - elif isinstance(val, bool): - return ok(val) - else: - return error(u('Value can\'t be decoded')) - - -@to_val -def null(val): - '''Decode string/value to None''' - if val is not None and val.lower() not in ('none', 'null'): - return error(u('Value can\'t be decoded')) - return ok(None) +to_int = to_val(to_int) # pylint: disable=invalid-name +to_float = to_val(to_float) # pylint: disable=invalid-name +to_string = to_val(to_string) # pylint: disable=invalid-name +to_bool = to_val(to_bool) # pylint: disable=invalid-name +null = to_val(null) # pylint: disable=invalid-name diff --git a/tests/test_decodes.py b/tests/test_decodes.py index c720e082..eae82e3b 100644 --- a/tests/test_decodes.py +++ b/tests/test_decodes.py @@ -7,8 +7,8 @@ import pytest from pydecoder.fields import required -from pydecoder.xml import xml, to_string, to_int -from pydecoder.json import json +from pydecoder import xml +from pydecoder import json from pyresult import is_ok, is_error, value @@ -50,17 +50,17 @@ def json_data(): 'JSON', ) + def creator(values): return values - @PARAM('decoder, data', DECODERS_AND_DATA, ids=IDS) def test_return_ok_result(decoder, data): - rv = decoder( + rv = decoder.decode( creator, ( - required('c', to_string), + required('c', decoder.to_string), ), data ) @@ -71,10 +71,10 @@ def test_return_ok_result(decoder, data): @PARAM('decoder, data', DECODERS_AND_DATA, ids=IDS) def test_return_error_result(decoder, data): - rv = decoder( + rv = decoder.decode( creator, ( - required('f', to_string), + required('f', decoder.to_string), ), data ) @@ -84,11 +84,11 @@ def test_return_error_result(decoder, data): @PARAM('decoder, data', DECODERS_AND_DATA, ids=IDS) def test_return_error_result_with_aggregate_messages(decoder, data): - rv = decoder( + rv = decoder.decode( creator, ( - required('f', to_string), - required('e', to_string), + required('f', decoder.to_string), + required('e', decoder.to_string), ), data ) @@ -99,10 +99,10 @@ def test_return_error_result_with_aggregate_messages(decoder, data): def test_json_decode_return_ok_result_by_path(): - rv = json( + rv = json.decode( creator, ( - required(['a', 2], to_int), + required(['a', 2], json.to_int), ), json_data() ) From db530ec8362bbedd5f87c37076b8f71dc6c4074a Mon Sep 17 00:00:00 2001 From: "Jindrich K. Smitka" Date: Thu, 25 May 2017 14:37:41 +0200 Subject: [PATCH 4/4] Added missing tests --- tests/test_operators.py | 59 ++++++++++++++++++++++++++++++++++++++++ tests/test_primitives.py | 12 ++++++++ tests/test_structs.py | 18 ++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/test_operators.py create mode 100644 tests/test_primitives.py create mode 100644 tests/test_structs.py diff --git a/tests/test_operators.py b/tests/test_operators.py new file mode 100644 index 00000000..9806a123 --- /dev/null +++ b/tests/test_operators.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-docstring + +from pyresult import ok, is_ok, value, error, is_error + +from pydecoder.operators import pipe, dmap, and_then +from pydecoder.primitives import to_int + + +def test_pipe_pass_value_trough_pipeline(): + def inc(val): + return ok(val + 1) + + rv = pipe( + [ + inc, + inc, + inc, + ], + 1 + ) + + assert is_ok(rv) + assert value(rv) == 4 + + +def test_dmap_apply_function_to_decoded_val(): + rv = dmap(lambda x: x + 1, to_int, '1') + + assert is_ok(rv) + assert value(rv) == 2 + + +def test_and_then_run_next_decoder(): + def dec(val): + return ok(val - 1) + + rv = and_then( + dec, + to_int, + 5 + ) + + assert is_ok(rv) + assert value(rv) == 4 + + +def test_and_then_dont_run_next_decoder(): + def dec(_val): + return error('ERROR') + + rv = and_then( + dec, + to_int, + 5 + ) + + assert is_error(rv) + assert rv.value == 'ERROR' diff --git a/tests/test_primitives.py b/tests/test_primitives.py new file mode 100644 index 00000000..3aba6e7c --- /dev/null +++ b/tests/test_primitives.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-docstring, redefined-outer-name + +from pyresult import is_error + +from pydecoder.primitives import to_string + + +def test_to_string_return_error_if_value_is_none(): + rv = to_string(None) + + assert is_error(rv) diff --git a/tests/test_structs.py b/tests/test_structs.py new file mode 100644 index 00000000..4eeeaf2c --- /dev/null +++ b/tests/test_structs.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-docstring, redefined-outer-name, no-value-for-parameter + +from pyresult import is_ok, value +from six.moves import zip + +from pydecoder.primitives import to_int +from pydecoder.structs import array + + +def test_array_returns_decoded_result_list(): + ins = ['1', '2', '3'] + + rv = array(to_int, ins) + + for orig, res in zip(ins, rv): + assert is_ok(res) + assert value(res) == int(orig)