Repository: chimpler/pyhocon

Issue:
Inconsistent parsing of periods
Parsing of a period value ('5 minutes') is inconsistent and does not always work, depending on the surrounding values.

Example:

This works as expected:
```python
conf = """
foo = bar
timeout = 5 minutes
"""
ConfigFactory.parse_string(conf).timeout
```
> `datetime.timedelta(seconds=300)`

But if we switch the lines, it does not:

```python
conf = """
timeout = 5 minutes
foo = bar
"""
ConfigFactory.parse_string(conf).timeout
```
> `'5 minutes'`


Tested with Python 3.7.4, pyhocon 0.3.51, pyparsing 2.4.2

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/33056812/badge)](https://coveralls.io/builds/33056812)

Coverage decreased (-0.2%) to 96.055% when pulling **6ee8cecdb106ebbae4eb9ce9059b549f4207d24d on olii:master** into **d279b669443df7ada063755f3dd3f89f308cab1b on chimpler:master**.


Patch:
diff --git a/pyhocon/config_parser.py b/pyhocon/config_parser.py
index 98df3ff..7c80aa6 100644
--- a/pyhocon/config_parser.py
+++ b/pyhocon/config_parser.py
@@ -6,7 +6,9 @@ import logging
 import os
 import re
 import socket
+import sys
 from datetime import timedelta
+from glob import glob
 
 from pyparsing import (Forward, Group, Keyword, Literal, Optional,
                        ParserElement, ParseSyntaxException, QuotedString,
@@ -37,6 +39,14 @@ except NameError:  # pragma: no cover
     basestring = str
     unicode = str
 
+if sys.version_info < (3, 5):
+    def glob(pathname, recursive=False):
+        if recursive and '**' in pathname:
+            import warnings
+            warnings.warn('This version of python (%s) does not support recursive import' % sys.version)
+        from glob import glob as _glob
+        return _glob(pathname)
+
 logger = logging.getLogger(__name__)
 
 #
@@ -338,13 +348,45 @@ class ConfigParser(object):
                 )
             elif file is not None:
                 path = file if basedir is None else os.path.join(basedir, file)
-                logger.debug('Loading config from file %s', path)
-                obj = ConfigFactory.parse_file(
-                    path,
-                    resolve=False,
-                    required=required,
-                    unresolved_value=NO_SUBSTITUTION
-                )
+
+                def _make_prefix(path):
+                    return ('<root>' if path is None else '[%s]' % path).ljust(55).replace('\\', '/')
+                _prefix = _make_prefix(path)
+
+                def _load(path):
+                    _prefix = _make_prefix(path)
+                    logger.debug('%s Loading config from file %r', _prefix, path)
+                    obj = ConfigFactory.parse_file(
+                        path,
+                        resolve=False,
+                        required=required,
+                        unresolved_value=NO_SUBSTITUTION
+                    )
+                    logger.debug('%s Result: %s', _prefix, obj)
+                    return obj
+
+                if '*' in path or '?' in path:
+                    paths = glob(path, recursive=True)
+                    obj = None
+
+                    def _merge(a, b):
+                        if a is None or b is None:
+                            return a or b
+                        elif isinstance(a, ConfigTree) and isinstance(b, ConfigTree):
+                            return ConfigTree.merge_configs(a, b)
+                        elif isinstance(a, list) and isinstance(b, list):
+                            return a + b
+                        else:
+                            raise ConfigException('Unable to make such include (merging unexpected types: {a} and {b}', a=type(a), b=type(b))
+                    logger.debug('%s Loading following configs: %s', _prefix, paths)
+                    for p in paths:
+                        obj = _merge(obj, _load(p))
+                    logger.debug('%s Result: %s', _prefix, obj)
+
+                else:
+                    logger.debug('%s Loading single config: %s', _prefix, path)
+                    obj = _load(path)
+
             else:
                 raise ConfigException('No file or URL specified at: {loc}: {instring}', loc=loc, instring=instring)
 
@@ -371,10 +413,15 @@ class ConfigParser(object):
             comment_no_comma_eol = (comment | eol).suppress()
             number_expr = Regex(r'[+-]?(\d*\.\d+|\d+(\.\d+)?)([eE][+\-]?\d+)?(?=$|[ \t]*([\$\}\],#\n\r]|//))',
                                 re.DOTALL).setParseAction(convert_number)
-
-            period_types = itertools.chain.from_iterable(cls.get_supported_period_type_map().values())
-            period_expr = Regex(r'(?P<value>\d+)\s*(?P<unit>' + '|'.join(period_types) + ')$'
-                                ).setParseAction(convert_period)
+            # Must be sorted from longest to shortest otherwise 'weeks' will match 'w' and 'eeks'
+            # will be parsed as a general string.
+            period_types = sorted(
+                itertools.chain.from_iterable(cls.get_supported_period_type_map().values()),
+                key=lambda x: len(x), reverse=True)
+            period_expr = Regex(
+                r'(?P<value>\d+)\s*(?P<unit>' + '|'.join(period_types) + ')$',
+                flags=re.MULTILINE,
+            ).setParseAction(convert_period)
 
             # multi line string using """
             # Using fix described in http://pyparsing.wikispaces.com/share/view/3778969
diff --git a/pyhocon/converter.py b/pyhocon/converter.py
index 6675819..404c757 100644
--- a/pyhocon/converter.py
+++ b/pyhocon/converter.py
@@ -32,10 +32,18 @@ class HOCONConverter(object):
                 lines += '{\n'
                 bet_lines = []
                 for key, item in config.items():
+                    new_key = key.strip('"')  # for dotted keys enclosed with "" to not be interpreted as nested key
+                    if isinstance(new_key, unicode):
+                        new_key = new_key.encode('utf-8')
+
+                    new_value = cls.to_json(item, compact, indent, level + 1)
+                    if isinstance(new_value, unicode):
+                        new_value = new_value.encode('utf-8')
+
                     bet_lines.append('{indent}"{key}": {value}'.format(
                         indent=''.rjust((level + 1) * indent, ' '),
-                        key=key.strip('"'),  # for dotted keys enclosed with "" to not be interpreted as nested key
-                        value=cls.to_json(item, compact, indent, level + 1))
+                        key=new_key,
+                        value=new_value)
                     )
                 lines += ',\n'.join(bet_lines)
                 lines += '\n{indent}}}'.format(indent=''.rjust(level * indent, ' '))
@@ -53,7 +61,7 @@ class HOCONConverter(object):
                 lines += ',\n'.join(bet_lines)
                 lines += '\n{indent}]'.format(indent=''.rjust(level * indent, ' '))
         elif isinstance(config, basestring):
-            lines = json.dumps(config)
+            lines = json.dumps(config, ensure_ascii=False)
         elif config is None or isinstance(config, NoneValue):
             lines = 'null'
         elif config is True:
diff --git a/samples/all_animals.conf b/samples/all_animals.conf
new file mode 100644
index 0000000..a1200e4
--- /dev/null
+++ b/samples/all_animals.conf
@@ -0,0 +1,3 @@
+animals: {
+    include "animals.d/*.conf"
+}
\ No newline at end of file
diff --git a/samples/all_bars.conf b/samples/all_bars.conf
new file mode 100644
index 0000000..70f92e4
--- /dev/null
+++ b/samples/all_bars.conf
@@ -0,0 +1,4 @@
+bars:
+[
+    include "bars.d/*.conf"
+]
diff --git a/samples/animals.conf b/samples/animals.conf
index 4d548c8..618fafa 100644
--- a/samples/animals.conf
+++ b/samples/animals.conf
@@ -1,9 +1,9 @@
 {
   cat : {
-    include "cat.conf"
+    include "animals.d/cat.conf"
   }
 
   dog: {
-    include "dog.conf"
+    include "animals.d/dog.conf"
   }
 }
diff --git a/samples/cat.conf b/samples/animals.d/cat.conf
similarity index 100%
rename from samples/cat.conf
rename to samples/animals.d/cat.conf
diff --git a/samples/dog.conf b/samples/animals.d/dog.conf
similarity index 100%
rename from samples/dog.conf
rename to samples/animals.d/dog.conf
diff --git a/samples/bars.d/bar_night_city.conf b/samples/bars.d/bar_night_city.conf
new file mode 100644
index 0000000..a5d4840
--- /dev/null
+++ b/samples/bars.d/bar_night_city.conf
@@ -0,0 +1,5 @@
+[
+    { name: "Bloody Mary", type: "cocktail" }
+    { name: "Mohito", type: "cocktail" }
+    { name: "CyberBeer", type: "beer" }
+]
diff --git a/samples/bars.d/bar_springfield.conf b/samples/bars.d/bar_springfield.conf
new file mode 100644
index 0000000..df88d3b
--- /dev/null
+++ b/samples/bars.d/bar_springfield.conf
@@ -0,0 +1,5 @@
+[
+    { name: "Bud №5", type: "beer" }
+    { name: "Bud №7", type: "beer" }
+    { name: "Homer's favorite coffee", type: "coffee" }
+]
diff --git a/samples/bars.d/cafe.conf b/samples/bars.d/cafe.conf
new file mode 100644
index 0000000..31fc27e
--- /dev/null
+++ b/samples/bars.d/cafe.conf
@@ -0,0 +1,6 @@
+[
+    { name: "Cappuccino", type: "coffee" }
+    { name: "Caffè Americano", type: "coffee" }
+    { name: "Green Tea", type: "tea" }
+    { name: "Milk 3.5%", type: "milk" }
+]


Test patch:
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
index 7abc615..e3d3caa 100644
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -138,6 +138,16 @@ class TestConfigParser(object):
 
         assert config['a'] == data_set[1]
 
+    def test_parse_string_with_duration_with_long_unit_name(self):
+        config = ConfigFactory.parse_string(
+            """
+            a: foo
+            b: 10 weeks
+            c: bar
+            """
+        )
+        assert config['b'] == period(weeks=10)
+
     def test_parse_with_enclosing_square_bracket(self):
         config = ConfigFactory.parse_string("[1, 2, 3]")
         assert config == [1, 2, 3]
@@ -660,7 +670,7 @@ class TestConfigParser(object):
     def test_substitution_list_with_append(self):
         config = ConfigFactory.parse_string(
             """
-            application.foo = 128m
+            application.foo = 128mm
             application.large-jvm-opts = ["-XX:+UseParNewGC"] [-Xm16g, ${application.foo}]
             application.large-jvm-opts2 = [-Xm16g, ${application.foo}] ["-XX:+UseParNewGC"]
             """)
@@ -668,19 +678,19 @@ class TestConfigParser(object):
         assert config["application.large-jvm-opts"] == [
             '-XX:+UseParNewGC',
             '-Xm16g',
-            '128m'
+            '128mm'
         ]
 
         assert config["application.large-jvm-opts2"] == [
             '-Xm16g',
-            '128m',
+            '128mm',
             '-XX:+UseParNewGC',
         ]
 
     def test_substitution_list_with_append_substitution(self):
         config = ConfigFactory.parse_string(
             """
-            application.foo = 128m
+            application.foo = 128mm
             application.default-jvm-opts = ["-XX:+UseParNewGC"]
             application.large-jvm-opts = ${application.default-jvm-opts} [-Xm16g, ${application.foo}]
             application.large-jvm-opts2 = [-Xm16g, ${application.foo}] ${application.default-jvm-opts}
@@ -689,12 +699,12 @@ class TestConfigParser(object):
         assert config["application.large-jvm-opts"] == [
             '-XX:+UseParNewGC',
             '-Xm16g',
-            '128m'
+            '128mm'
         ]
 
         assert config["application.large-jvm-opts2"] == [
             '-Xm16g',
-            '128m',
+            '128mm',
             '-XX:+UseParNewGC'
         ]
 
@@ -1066,6 +1076,19 @@ class TestConfigParser(object):
         assert config.get('cat.garfield.say') == 'meow'
         assert config.get('dog.mutt.hates.garfield.say') == 'meow'
 
+    def test_include_glob_dict_from_samples(self):
+        config = ConfigFactory.parse_file("samples/all_animals.conf")
+        assert config.get('animals.garfield.say') == 'meow'
+        assert config.get('animals.mutt.hates.garfield.say') == 'meow'
+
+    def test_include_glob_list_from_samples(self):
+        config = ConfigFactory.parse_file("samples/all_bars.conf")
+        bars = config.get_list('bars')
+        assert len(bars) == 10
+        assert bars[0].get('name') == 'Bloody Mary'
+        assert bars[5].get('name') == 'Homer\'s favorite coffee'
+        assert bars[9].get('type') == 'milk'
+
     def test_list_of_dicts(self):
         config = ConfigFactory.parse_string(
             """
@@ -1203,7 +1226,7 @@ class TestConfigParser(object):
         config = ConfigFactory.parse_string(
             """
             a {
-                include required("samples/cat.conf")
+                include required("samples/animals.d/cat.conf")
                 t = 2
             }
             """
@@ -1221,7 +1244,7 @@ class TestConfigParser(object):
         config2 = ConfigFactory.parse_string(
             """
             a {
-                include required(file("samples/cat.conf"))
+                include required(file("samples/animals.d/cat.conf"))
                 t = 2
             }
             """