Repository: markusressel/container-app-conf

Issue:
Normalize hyphens in env source
The current [KEY_PATH_REGEX](https://github.com/markusressel/container-app-conf/blob/e4ec0c8c51b5a460744c033bc19ab76b4f74617c/container_app_conf/const.py#L22) allows both hyphens and underscores. As brought up in https://github.com/markusressel/keel-telegram-bot/issues/29 hyphens are not POXIS compliant.

# Solution
Normalize hyphens to underscores in the env source and check for both the original and normalized version to avoid a breaking change.

Discussion:

Patch:
diff --git a/Pipfile.lock b/Pipfile.lock
index 884d1ae..700bcba 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -42,11 +42,11 @@
         },
         "ruamel.yaml": {
             "hashes": [
-                "sha256:0850def9ebca23b3a8c64c4b4115ebb6b364a10d49f89d289a26ee965e1e7d9d",
-                "sha256:8f1e15421668b9edf30ed02899f5f81aff9808a4271935776f61a99a569a13da"
+                "sha256:44bc6b54fddd45e4bc0619059196679f9e8b79c027f4131bb072e6a22f4d5e28",
+                "sha256:ac79fb25f5476e8e9ed1c53b8a2286d2c3f5dde49eb37dbcee5c7eb6a8415a22"
             ],
             "index": "pypi",
-            "version": "==0.17.2"
+            "version": "==0.17.4"
         },
         "ruamel.yaml.clib": {
             "hashes": [
@@ -82,7 +82,7 @@
                 "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2",
                 "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f"
             ],
-            "markers": "platform_python_implementation == 'CPython' and python_version < '3.10'",
+            "markers": "python_version < '3.10' and platform_python_implementation == 'CPython'",
             "version": "==0.2.2"
         },
         "six": {
@@ -90,12 +90,14 @@
                 "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
                 "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
             ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==1.15.0"
         },
         "toml": {
             "hashes": [
                 "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
-                "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+                "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e",
+                "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"
             ],
             "index": "pypi",
             "version": "==0.10.0"
@@ -115,6 +117,7 @@
                 "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
                 "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
             ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==20.3.0"
         },
         "iniconfig": {
@@ -129,6 +132,7 @@
                 "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
                 "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
             ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==20.9"
         },
         "pluggy": {
@@ -136,6 +140,7 @@
                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
                 "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
             ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==0.13.1"
         },
         "py": {
@@ -143,6 +148,7 @@
                 "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
                 "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
             ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==1.10.0"
         },
         "pyparsing": {
@@ -150,6 +156,7 @@
                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
             ],
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==2.4.7"
         },
         "pytest": {
@@ -163,7 +170,8 @@
         "toml": {
             "hashes": [
                 "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
-                "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+                "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e",
+                "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"
             ],
             "index": "pypi",
             "version": "==0.10.0"
diff --git a/README.md b/README.md
index caa85ed..6d2c98e 100644
--- a/README.md
+++ b/README.md
@@ -209,15 +209,14 @@ implementations are available:
 
 Since you only specify the key path of a config entry the ENV
 key is generated automatically by concatenating all key path items 
-using an underscore and converting to uppercase:
+using an underscore, converting to uppercase and replacing any remaining
+hyphens also with an underscore:
 
 ```python
-key_path = ["my_app", "example"]
-env_key = "_".join(key_path).upper()
+key_path = ["my_app", "my-example"]
 ```
 
-yields `MY_APP_EXAMPLE`.
-
+would yield `MY_APP_MY_EXAMPLE`.
 
 ### Filesystem Source
 
diff --git a/container_app_conf/source/env_source.py b/container_app_conf/source/env_source.py
index 390dd54..49db3ed 100644
--- a/container_app_conf/source/env_source.py
+++ b/container_app_conf/source/env_source.py
@@ -31,11 +31,14 @@ class EnvSource(DataSource):
     KEY_SPLIT_CHAR = "_"
 
     def has(self, entry: ConfigEntry) -> bool:
-        return self.env_key(entry) in self.root.keys()
+        original_key = self.env_key(entry)
+        normalized_key = original_key.replace('-', '_')
+        return original_key in self.root.keys() or normalized_key in self.root.keys()
 
     def get(self, entry: ConfigEntry) -> str or None:
-        key = self.env_key(entry)
-        return self.root.get(key, None)
+        original_key = self.env_key(entry)
+        normalized_key = original_key.replace('-', '_')
+        return self.root.get(original_key, self.root.get(normalized_key, None))
 
     @staticmethod
     def env_key(entry: ConfigEntry) -> str:


Test patch:
diff --git a/tests/data_source/source_test.py b/tests/data_source/source_test.py
index 8905fa7..280572c 100644
--- a/tests/data_source/source_test.py
+++ b/tests/data_source/source_test.py
@@ -17,11 +17,14 @@
 #  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 #  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 #  SOFTWARE.
+import os
 from typing import Dict
+from unittest import mock
 
 from container_app_conf import ConfigEntry
 from container_app_conf.entry.int import IntConfigEntry
 from container_app_conf.entry.string import StringConfigEntry
+from container_app_conf.source.env_source import EnvSource
 from container_app_conf.source.json_source import JsonSource
 from container_app_conf.source.toml_source import TomlSource
 from container_app_conf.source.yaml_source import YamlSource
@@ -110,3 +113,32 @@ class TestDataSource(TestBase):
         self.assertEqual(source.get(str_entry), "value")
         self.assertTrue(source.has(int_entry))
         self.assertEqual(source.get(int_entry), 2)
+
+    def test_env(self):
+        str_entry = StringConfigEntry(
+            key_path=["test-ing", "key1"],
+            default="value"
+        )
+        int_entry = IntConfigEntry(
+            key_path=["testing", "key2"],
+            default=2
+        )
+
+        source = EnvSource()
+        original_key = EnvSource.env_key(str_entry)
+        expected = "expected"
+        with mock.patch.dict(os.environ, {original_key: expected}, clear=True):
+            source.load()
+
+        self.assertTrue(source.has(str_entry))
+        self.assertEqual(source.get(str_entry), expected)
+        self.assertFalse(source.has(int_entry))
+
+        normalized_env_key = original_key.replace('-', '_')
+        self.assertNotEqual(original_key, normalized_env_key)
+        with mock.patch.dict(os.environ, {normalized_env_key: expected + '2'}, clear=True):
+            source.load()
+
+        self.assertTrue(source.has(str_entry))
+        self.assertEqual(source.get(str_entry), expected + '2')
+        self.assertFalse(source.has(int_entry))