From a826b18fc52d4d8839e77e8201aedf09c65ea8eb Mon Sep 17 00:00:00 2001 From: gabi Date: Sun, 23 May 2021 08:13:05 +0300 Subject: [PATCH] added support for serializing timedelta and relativedelta --- pyhocon/converter.py | 77 +++++++++++++++++++++++++++++++++++------ tests/test_converter.py | 35 +++++++++++++++++++ tests/test_tool.py | 38 ++++++++++++-------- 3 files changed, 125 insertions(+), 25 deletions(-) diff --git a/pyhocon/converter.py b/pyhocon/converter.py index cf9d5c30..b55df582 100644 --- a/pyhocon/converter.py +++ b/pyhocon/converter.py @@ -1,6 +1,7 @@ import json import re import sys +from datetime import timedelta from pyhocon import ConfigFactory from pyhocon.config_tree import ConfigQuotedString @@ -9,13 +10,17 @@ from pyhocon.config_tree import ConfigValues from pyhocon.config_tree import NoneValue - try: basestring except NameError: basestring = str unicode = str +try: + from dateutil.relativedelta import relativedelta +except Exception: + relativedelta = None + class HOCONConverter(object): @classmethod @@ -55,6 +60,8 @@ def to_json(cls, config, compact=False, indent=2, level=0): ) lines += ',\n'.join(bet_lines) lines += '\n{indent}]'.format(indent=''.rjust(level * indent, ' ')) + elif cls._is_timedelta_like(config): + lines += cls._timedelta_to_str(config) elif isinstance(config, basestring): lines = json.dumps(config, ensure_ascii=False) elif config is None or isinstance(config, NoneValue): @@ -130,6 +137,8 @@ def to_hocon(cls, config, compact=False, indent=2, level=0): lines = '"""{value}"""'.format(value=config.value) # multilines else: lines = '"{value}"'.format(value=cls.__escape_string(config.value)) + elif cls._is_timedelta_like(config): + lines += cls._timedelta_to_hocon(config) elif config is None or isinstance(config, NoneValue): lines = 'null' elif config is True: @@ -171,6 +180,8 @@ def to_yaml(cls, config, compact=False, indent=2, level=0): bet_lines.append('{indent}- {value}'.format(indent=''.rjust(level * indent, ' '), value=cls.to_yaml(item, compact, indent, level + 1))) lines += '\n'.join(bet_lines) + elif cls._is_timedelta_like(config): + lines += cls._timedelta_to_str(config) elif isinstance(config, basestring): # if it contains a \n then it's multiline lines = config.split('\n') @@ -189,13 +200,14 @@ def to_yaml(cls, config, compact=False, indent=2, level=0): return lines @classmethod - def to_properties(cls, config, compact=False, indent=2, key_stack=[]): + def to_properties(cls, config, compact=False, indent=2, key_stack=None): """Convert HOCON input into a .properties output :return: .properties string representation :type return: basestring :return: """ + key_stack = key_stack or [] def escape_value(value): return value.replace('=', '\\=').replace('!', '\\!').replace('#', '\\#').replace('\n', '\\\n') @@ -210,6 +222,8 @@ def escape_value(value): for index, item in enumerate(config): if item is not None: lines.append(cls.to_properties(item, compact, indent, stripped_key_stack + [str(index)])) + elif cls._is_timedelta_like(config): + lines.append('.'.join(stripped_key_stack) + ' = ' + cls._timedelta_to_str(config)) elif isinstance(config, basestring): lines.append('.'.join(stripped_key_stack) + ' = ' + escape_value(config)) elif config is True: @@ -264,15 +278,58 @@ def convert_from_file(cls, input_file=None, output_file=None, output_format='jso def __escape_match(cls, match): char = match.group(0) return { - '\b': r'\b', - '\t': r'\t', - '\n': r'\n', - '\f': r'\f', - '\r': r'\r', - '"': r'\"', - '\\': r'\\', - }.get(char) or (r'\u%04x' % ord(char)) + '\b': r'\b', + '\t': r'\t', + '\n': r'\n', + '\f': r'\f', + '\r': r'\r', + '"': r'\"', + '\\': r'\\', + }.get(char) or (r'\u%04x' % ord(char)) @classmethod def __escape_string(cls, string): return re.sub(r'[\x00-\x1F"\\]', cls.__escape_match, string) + + @classmethod + def _is_timedelta_like(cls, config): + return isinstance(config, timedelta) or relativedelta is not None and isinstance(config, relativedelta) + + @classmethod + def _timedelta_to_str(cls, config): + if isinstance(config, relativedelta): + time_delta = cls._relative_delta_to_timedelta(config) + else: + time_delta = config + return str(int(time_delta.total_seconds() * 1000)) + + @classmethod + def _timedelta_to_hocon(cls, config): + """ + :type config: timedelta + """ + if relativedelta is not None and isinstance(config, relativedelta): + if config.hours > 0: + return str(config.hours) + ' hours' + elif config.minutes > 0: + return str(config.minutes) + ' minutes' + + if config.days > 0: + return str(config.days) + ' days' + elif config.seconds > 0: + return str(config.seconds) + ' seconds' + elif config.microseconds > 0: + return str(config.microseconds) + ' microseconds' + else: + return '0 seconds' + + @classmethod + def _relative_delta_to_timedelta(cls, relative_delta): + """ + :type relative_delta: relativedelta + """ + return timedelta(days=relative_delta.days, + hours=relative_delta.hours, + minutes=relative_delta.minutes, + seconds=relative_delta.seconds, + microseconds=relative_delta.microseconds) diff --git a/tests/test_converter.py b/tests/test_converter.py index b1a711c4..b2bb6a36 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,8 +1,17 @@ # -*- encoding: utf-8 -*- +from datetime import timedelta + +import pytest from pyhocon import ConfigTree from pyhocon.converter import HOCONConverter +try: + from dateutil.relativedelta import relativedelta +except Exception: + def relativedelta(**kwargs): + return None + def to_json(obj): return HOCONConverter.to_json(ConfigTree(obj), compact=True, indent=1) @@ -106,3 +115,29 @@ def test_format_multiline_string(self): assert 'a = """\nc"""' == to_hocon({'a': '\nc'}) assert 'a = """b\n"""' == to_hocon({'a': 'b\n'}) assert 'a = """\n\n"""' == to_hocon({'a': '\n\n'}) + + @pytest.mark.parametrize('time_delta, expected_result', + ( + (timedelta(days=0), 'td = 0 seconds'), + (timedelta(days=5), 'td = 5 days'), + (timedelta(seconds=51), 'td = 51 seconds'), + (timedelta(microseconds=786), 'td = 786 microseconds'), + ) + ) + def test_format_time_delta(self, time_delta, expected_result): + assert expected_result == to_hocon({'td': time_delta}) + + @pytest.mark.parametrize('time_delta, expected_result', + ( + (relativedelta(seconds=0), 'td = 0 seconds'), + (relativedelta(hours=0), 'td = 0 seconds'), + (relativedelta(days=5), 'td = 5 days'), + (relativedelta(weeks=3), 'td = 21 days'), + (relativedelta(hours=2), 'td = 2 hours'), + (relativedelta(minutes=43), 'td = 43 minutes'), + + ) + ) + @pytest.mark.skipif(relativedelta(hours=1) is None, reason='dateutils.relativedelta not available') + def test_format_relativedelta(self, time_delta, expected_result): + assert expected_result == to_hocon({'td': time_delta}) diff --git a/tests/test_tool.py b/tests/test_tool.py index b50895ff..285370b0 100644 --- a/tests/test_tool.py +++ b/tests/test_tool.py @@ -19,6 +19,7 @@ class TestHOCONConverter(object): h = null i = {} "a.b" = 2 + td_days = 4 days """ CONFIG = ConfigFactory.parse_string(CONFIG_STRING) @@ -41,7 +42,8 @@ class TestHOCONConverter(object): "g": [], "h": null, "i": {}, - "a.b": 2 + "a.b": 2, + "td_days": 345600000 } """ @@ -63,6 +65,7 @@ class TestHOCONConverter(object): h = null i {} "a.b" = 2 + td_days = 4 days """ EXPECTED_COMPACT_HOCON = \ @@ -81,6 +84,7 @@ class TestHOCONConverter(object): h = null i {} "a.b" = 2 + td_days = 4 days """ EXPECTED_YAML = \ @@ -102,6 +106,7 @@ class TestHOCONConverter(object): h: null i: a.b: 2 + td_days: 345600000 """ EXPECTED_PROPERTIES = \ @@ -117,32 +122,33 @@ class TestHOCONConverter(object): f1 = true f2 = false a.b = 2 + td_days = 345600000 """ def test_to_json(self): converted = HOCONConverter.to_json(TestHOCONConverter.CONFIG) - assert [line.strip() for line in TestHOCONConverter.EXPECTED_JSON.split('\n') if line.strip()]\ - == [line.strip() for line in converted.split('\n') if line.strip()] + assert [line.strip() for line in TestHOCONConverter.EXPECTED_JSON.split('\n') if line.strip()] \ + == [line.strip() for line in converted.split('\n') if line.strip()] def test_to_yaml(self): converted = HOCONConverter.to_yaml(TestHOCONConverter.CONFIG) - assert [line.strip() for line in TestHOCONConverter.EXPECTED_YAML.split('\n') if line.strip()]\ - == [line.strip() for line in converted.split('\n') if line.strip()] + assert [line.strip() for line in TestHOCONConverter.EXPECTED_YAML.split('\n') if line.strip()] \ + == [line.strip() for line in converted.split('\n') if line.strip()] def test_to_properties(self): converted = HOCONConverter.to_properties(TestHOCONConverter.CONFIG) - assert [line.strip() for line in TestHOCONConverter.EXPECTED_PROPERTIES.split('\n') if line.strip()]\ - == [line.strip() for line in converted.split('\n') if line.strip()] + assert [line.strip() for line in TestHOCONConverter.EXPECTED_PROPERTIES.split('\n') if line.strip()] \ + == [line.strip() for line in converted.split('\n') if line.strip()] def test_to_hocon(self): converted = HOCONConverter.to_hocon(TestHOCONConverter.CONFIG) - assert [line.strip() for line in TestHOCONConverter.EXPECTED_HOCON.split('\n') if line.strip()]\ - == [line.strip() for line in converted.split('\n') if line.strip()] + assert [line.strip() for line in TestHOCONConverter.EXPECTED_HOCON.split('\n') if line.strip()] \ + == [line.strip() for line in converted.split('\n') if line.strip()] def test_to_compact_hocon(self): converted = HOCONConverter.to_hocon(TestHOCONConverter.CONFIG, compact=True) - assert [line.strip() for line in TestHOCONConverter.EXPECTED_COMPACT_HOCON.split('\n') if line.strip()]\ - == [line.strip() for line in converted.split('\n') if line.strip()] + assert [line.strip() for line in TestHOCONConverter.EXPECTED_COMPACT_HOCON.split('\n') if line.strip()] \ + == [line.strip() for line in converted.split('\n') if line.strip()] def _test_convert_from_file(self, input, expected_output, format): with tempfile.NamedTemporaryFile('w') as fdin: @@ -152,18 +158,20 @@ def _test_convert_from_file(self, input, expected_output, format): HOCONConverter.convert_from_file(fdin.name, fdout.name, format) with open(fdout.name) as fdi: converted = fdi.read() - assert [line.strip() for line in expected_output.split('\n') if line.strip()]\ - == [line.strip() for line in converted.split('\n') if line.strip()] + assert [line.strip() for line in expected_output.split('\n') if line.strip()] \ + == [line.strip() for line in converted.split('\n') if line.strip()] def test_convert_from_file(self): self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_JSON, 'json') self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_YAML, 'yaml') - self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_PROPERTIES, 'properties') + self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_PROPERTIES, + 'properties') self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_HOCON, 'hocon') def test_invalid_format(self): with pytest.raises(Exception): - self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_PROPERTIES, 'invalid') + self._test_convert_from_file(TestHOCONConverter.CONFIG_STRING, TestHOCONConverter.EXPECTED_PROPERTIES, + 'invalid') def test_substitutions_conversions():